Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ Local dev Telegram bot: `@clawdotfreebot`. Production: `@lobuaibot`.

For any UI verification that needs a signed-in session (anything past the auth wall), use the `agent-browser` CLI with a session cookie minted from the DB. The user's regular Chrome doesn't expose a remote-debug port, so `--auto-connect` will land on a wrong tab; mint a cookie instead.

**Scope of this recipe.** The forged session cookie authenticates the **web admin REST mounted at `/`** (`/api/auth/*`, `/api/<orgSlug>/...`, the SPA — anything `lobu apply` and the web app talk to). It does **NOT** authenticate the **public Agent API at `/lobu`** (`/lobu/api/v1/agents/*`, `/lobu/api/v1/agents/<id>/sessions`) — that path expects a JWT bearer token from the OAuth device flow (`lobu login`) or a PAT. If `/lobu/api/v1/agents` returns `401 Unauthorized` despite a valid cookie, that's why; switch to `lobu chat` / `lobu token` to talk to the Agent API.

**Pick a target.** Local dev backend (with prod DB attached over Tailscale) is reachable at `https://buraks-macbook-pro-1.brill-kanyu.ts.net:8443` — use this when you only need to verify behavior end-to-end without a fresh prod deploy. For prod itself use `https://app.lobu.ai`.

**Grab the secret + a session token.**
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
findEnclosingMonorepoRoot,
isSharedDatabaseUrl,
resolveBackendBundle,
} from "../commands/dev";

Expand Down Expand Up @@ -146,6 +147,31 @@ describe("lobu run backend bundle resolution", () => {
).toBe(true);
});

test("isSharedDatabaseUrl flags non-loopback hosts only", () => {
// Loopback variants are NOT shared.
expect(isSharedDatabaseUrl("postgres://user@localhost:5432/db")).toBe(
false
);
expect(isSharedDatabaseUrl("postgres://user@127.0.0.1:5432/db")).toBe(
false
);
expect(isSharedDatabaseUrl("postgres://user@[::1]:5432/db")).toBe(false);

// Tailnet, prod, private LAN — all shared.
expect(
isSharedDatabaseUrl(
"postgres://u:p@summaries-db.brill-kanyu.ts.net:5432/owletto"
)
).toBe(true);
expect(isSharedDatabaseUrl("postgres://u:p@db.example.com:5432/prod")).toBe(
true
);
expect(isSharedDatabaseUrl("postgres://u:p@10.0.0.5:5432/dev")).toBe(true);

// Garbage URL → not "shared" (the boot path will fail elsewhere).
expect(isSharedDatabaseUrl("not-a-url")).toBe(false);
});

test("CLI build copies local runtime assets for installed lobu run", () => {
expect(existsSync(join(repoRoot, "db", "migrations"))).toBe(true);
expect(
Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,32 @@ export interface DevOptions {
quiet?: boolean;
verbose?: boolean;
logLevel?: string;
/**
* Acknowledge that `lobu run` is about to point at a shared/non-local
* Postgres inherited from the shell. Required when the project's own .env
* doesn't pin DATABASE_URL — protects against the silent footgun of running
* "local dev" against a teammate's tailnet DB or, worse, prod.
*/
unsafeSharedDb?: boolean;
}

/**
* Treat any DATABASE_URL whose host isn't loopback as "shared". The check
* is intentionally crude — anything resolvable from the network counts,
* including tailnet (`*.ts.net`), private IPs, and prod hostnames.
*
* Exported for unit tests; the safety gate in `devCommand` is the consumer.
*/
export function isSharedDatabaseUrl(databaseUrl: string): boolean {
try {
const url = new URL(databaseUrl);
// `new URL("postgres://[::1]:5432/x").hostname` returns `[::1]` with the
// brackets, so strip them before comparing.
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
} catch {
return false;
}
}

type BackendBundleKind = "postgres" | "pglite";
Expand Down Expand Up @@ -45,6 +71,57 @@ export async function devCommand(

const mergedEnv = { ...envVars, ...(process.env as Record<string, string>) };
const hasDatabaseUrl = Boolean(mergedEnv.DATABASE_URL?.trim());

// Refuse to boot against a shared/non-local DATABASE_URL that came from the
// parent shell rather than the project's own .env — a common footgun where
// "local lobu run" silently writes into prod / a teammate's tailnet DB.
// The project pinning its own DATABASE_URL is treated as explicit consent.
if (
hasDatabaseUrl &&
!envVars.DATABASE_URL?.trim() &&
isSharedDatabaseUrl(mergedEnv.DATABASE_URL!) &&
!options.unsafeSharedDb
) {
spinner.fail("DATABASE_URL inherited from shell points at a shared DB");
console.error(
chalk.red(
`\n Refusing to start: DATABASE_URL=${redactUrl(mergedEnv.DATABASE_URL!)}\n`
)
);
console.error(
chalk.dim(
` This URL is set in your shell environment, not in ${envPath}.`
)
);
console.error(
chalk.dim(
" Its host isn't loopback — likely a teammate's tailnet DB or prod."
)
);
console.error(
chalk.dim(
" Local dev runs against this DB silently mutate shared data and"
)
);
console.error(
chalk.dim(" let prod workers race local-dev runs (see AGENTS.md).\n")
);
console.error(chalk.dim(" Fix one of:"));
console.error(
chalk.dim(
` • pin a project-local DB in ${envPath} (e.g. postgres://localhost/<project>_dev)`
)
);
console.error(
chalk.dim(" • unset DATABASE_URL in this shell (PGlite will be used)")
);
console.error(
chalk.dim(
" • pass --unsafe-shared-db if you really mean to share this DB\n"
)
);
process.exit(1);
}
const bundleKind: BackendBundleKind = hasDatabaseUrl ? "postgres" : "pglite";
const bundlePath = resolveBackendBundle(undefined, bundleKind);
if (!bundlePath) {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,12 +354,17 @@ Memory:
.option("--quiet", "Suppress startup banner; raise log level to warn")
.option("--verbose", "Lower log level to debug")
.option("--log-level <level>", "Forwarded as LOG_LEVEL to the bundle")
.option(
"--unsafe-shared-db",
"Allow running against a non-loopback DATABASE_URL inherited from the shell"
)
.action(
async (options: {
port?: string;
quiet?: boolean;
verbose?: boolean;
logLevel?: string;
unsafeSharedDb?: boolean;
}) => {
const { devCommand } = await import("./commands/dev.js");
await devCommand(process.cwd(), options);
Expand Down
1 change: 0 additions & 1 deletion packages/landing/src/components/FeatureGraphics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,4 +353,3 @@ export function SkillsGraphic() {
</div>
);
}

Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ describeIfSubmodule('getMcpInstallTargets', () => {
const targets = getMcpInstallTargets(mcpUrl);

expect(targets.map((target) => target.id)).toEqual([
'skills',
'codex',
'chatgpt',
'claude-desktop',
'claude-code',
'gemini-cli',
'cursor',
'lobu-cli',
'openclaw',
]);
});
Expand Down
Loading