diff --git a/AGENTS.md b/AGENTS.md index 91a56ff3c..d4f1fc0a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//...`, 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//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.** diff --git a/packages/cli/src/__tests__/dev.test.ts b/packages/cli/src/__tests__/dev.test.ts index c6e695b8e..c430c88ef 100644 --- a/packages/cli/src/__tests__/dev.test.ts +++ b/packages/cli/src/__tests__/dev.test.ts @@ -12,6 +12,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { findEnclosingMonorepoRoot, + isSharedDatabaseUrl, resolveBackendBundle, } from "../commands/dev"; @@ -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( diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index e53e37f0d..1c77368f5 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -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"; @@ -45,6 +71,57 @@ export async function devCommand( const mergedEnv = { ...envVars, ...(process.env as Record) }; 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/_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) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ba5f5574f..dca6d18c6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 ", "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); diff --git a/packages/landing/src/components/FeatureGraphics.tsx b/packages/landing/src/components/FeatureGraphics.tsx index 2a4707dc5..1d4a00614 100644 --- a/packages/landing/src/components/FeatureGraphics.tsx +++ b/packages/landing/src/components/FeatureGraphics.tsx @@ -353,4 +353,3 @@ export function SkillsGraphic() { ); } - diff --git a/packages/server/src/utils/__tests__/mcp-install-targets.test.ts b/packages/server/src/utils/__tests__/mcp-install-targets.test.ts index ef0d52618..931b1f8f4 100644 --- a/packages/server/src/utils/__tests__/mcp-install-targets.test.ts +++ b/packages/server/src/utils/__tests__/mcp-install-targets.test.ts @@ -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', ]); });