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
70 changes: 70 additions & 0 deletions packages/cli/src/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
findEnclosingMonorepoRoot,
isSharedDatabaseUrl,
resolveBackendBundle,
shouldRefuseSharedDatabaseUrl,
} from "../commands/dev";

const here = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -172,6 +173,75 @@ describe("lobu run backend bundle resolution", () => {
expect(isSharedDatabaseUrl("not-a-url")).toBe(false);
});

describe("shouldRefuseSharedDatabaseUrl", () => {
const SHARED = "postgres://u:p@db.example.com:5432/prod";
const LOCAL = "postgres://localhost:5432/proj_dev";

test("refuses when a shared shell URL overrides a loopback .env URL", () => {
// The footgun: .env pins a local DB, but the shell exports a prod URL
// that wins the merge. Gating on .env presence alone used to pass here.
expect(
shouldRefuseSharedDatabaseUrl({
effectiveDatabaseUrl: SHARED,
projectEnvDatabaseUrl: LOCAL,
unsafeSharedDb: false,
})
).toBe(true);
});

test("allows when the project's own .env shared URL survives the merge", () => {
// Pinning the shared URL in .env is explicit consent — the effective
// value equals the project .env value, so the project owns it.
expect(
shouldRefuseSharedDatabaseUrl({
effectiveDatabaseUrl: SHARED,
projectEnvDatabaseUrl: SHARED,
unsafeSharedDb: false,
})
).toBe(false);
});

test("refuses a shared shell URL when .env pins nothing", () => {
expect(
shouldRefuseSharedDatabaseUrl({
effectiveDatabaseUrl: SHARED,
projectEnvDatabaseUrl: undefined,
unsafeSharedDb: false,
})
).toBe(true);
});

test("allows a loopback effective URL regardless of source", () => {
expect(
shouldRefuseSharedDatabaseUrl({
effectiveDatabaseUrl: LOCAL,
projectEnvDatabaseUrl: undefined,
unsafeSharedDb: false,
})
).toBe(false);
});

test("--unsafe-shared-db bypasses the refusal", () => {
expect(
shouldRefuseSharedDatabaseUrl({
effectiveDatabaseUrl: SHARED,
projectEnvDatabaseUrl: LOCAL,
unsafeSharedDb: true,
})
).toBe(false);
});

test("no effective URL means no refusal (PGlite path)", () => {
expect(
shouldRefuseSharedDatabaseUrl({
effectiveDatabaseUrl: undefined,
projectEnvDatabaseUrl: undefined,
unsafeSharedDb: false,
})
).toBe(false);
});
});

test("CLI build copies local runtime assets for installed lobu run", () => {
expect(existsSync(join(repoRoot, "db", "migrations"))).toBe(true);
expect(
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/__tests__/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe("token create", () => {
test("creates an org-scoped PAT using the current OAuth login", async () => {
spyOn(context, "resolveContext").mockResolvedValue({
name: "prod",
apiUrl: "https://app.lobu.ai/api/v1",
url: "https://app.lobu.ai/api/v1",
source: "config",
});
spyOn(credentials, "getToken").mockResolvedValue("oauth-access-token");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,14 @@ describe("applyCommand --dry-run", () => {
silenceOutput();
spyOn(context, "resolveContext").mockResolvedValue({
name: "prod",
apiUrl: "https://app.lobu.ai/api/v1",
url: "https://app.lobu.ai/api/v1",
source: "config",
});
spyOn(credentials, "getToken").mockResolvedValue("tok");
spyOn(context, "getActiveOrg").mockResolvedValue("acme");
spyOn(context, "loadContextConfig").mockResolvedValue({
currentContext: "prod",
contexts: { prod: { apiUrl: "https://app.lobu.ai/api/v1" } },
contexts: { prod: { url: "https://app.lobu.ai/api/v1" } },
});
});

Expand Down Expand Up @@ -219,14 +219,14 @@ describe("applyCommand org resolution", () => {
silenceOutput();
spyOn(context, "resolveContext").mockResolvedValue({
name: "prod",
apiUrl: "https://app.lobu.ai/api/v1",
url: "https://app.lobu.ai/api/v1",
source: "config",
});
spyOn(credentials, "getToken").mockResolvedValue("tok");
spyOn(context, "getActiveOrg").mockResolvedValue(undefined);
spyOn(context, "loadContextConfig").mockResolvedValue({
currentContext: "prod",
contexts: { prod: { apiUrl: "https://app.lobu.ai/api/v1" } },
contexts: { prod: { url: "https://app.lobu.ai/api/v1" } },
});
});

Expand Down Expand Up @@ -328,14 +328,14 @@ describe("applyCommand — missing lobu.toml", () => {
silenceOutput();
spyOn(context, "resolveContext").mockResolvedValue({
name: "prod",
apiUrl: "https://app.lobu.ai/api/v1",
url: "https://app.lobu.ai/api/v1",
source: "config",
});
spyOn(credentials, "getToken").mockResolvedValue("tok");
spyOn(context, "getActiveOrg").mockResolvedValue("acme");
spyOn(context, "loadContextConfig").mockResolvedValue({
currentContext: "prod",
contexts: { prod: { apiUrl: "https://app.lobu.ai/api/v1" } },
contexts: { prod: { url: "https://app.lobu.ai/api/v1" } },
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/_lib/connector-run-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export async function connectorRun(
const explicitUrl = args.url?.trim();
const apiBase = explicitUrl
? new URL(explicitUrl).origin
: apiBaseFrom(ctx.apiUrl);
: apiBaseFrom(ctx.url);

const envToken = process.env.LOBU_API_TOKEN?.trim();
let token: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export async function chatCommand(
// gateway was running, or worse, hitting the wrong instance.
const ctx = await resolveContext(options.context).catch(() => null);
gatewayUrl = ctx
? apiBaseFromContextUrl(ctx.apiUrl)
? apiBaseFromContextUrl(ctx.url)
: await resolveGatewayUrl({ cwd });
}
// The Agent API lives under `<origin>/lobu` on every Lobu deployment; the
Expand Down
20 changes: 6 additions & 14 deletions packages/cli/src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function contextListCommand(): Promise<void> {
console.log(chalk.bold("\n Lobu contexts"));
for (const [name, context] of Object.entries(config.contexts)) {
const marker = name === currentContext ? chalk.green(" *") : " ";
console.log(`${marker} ${name} ${chalk.dim(context.apiUrl)}`);
console.log(`${marker} ${name} ${chalk.dim(context.url)}`);
}

if (process.env.LOBU_CONTEXT || process.env.LOBU_API_URL) {
Expand All @@ -37,7 +37,7 @@ export async function contextCurrentCommand(): Promise<void> {

console.log(chalk.bold("\n Current context"));
console.log(chalk.dim(` Name: ${context.name}`));
console.log(chalk.dim(` API URL: ${context.apiUrl}`));
console.log(chalk.dim(` URL: ${context.url}`));
if (context.source === "env") {
console.log(chalk.dim(" Source: environment override"));
}
Expand All @@ -46,29 +46,21 @@ export async function contextCurrentCommand(): Promise<void> {

export async function contextAddCommand(options: {
name: string;
apiUrl: string;
port?: number;
host?: string;
databaseUrl?: string;
dataDir?: string;
url: string;
cwd?: string;
lifecycle?: "managed" | "external";
}): Promise<void> {
const server: LobuServerConfig = {};
if (options.port !== undefined) server.port = options.port;
if (options.host) server.host = options.host;
if (options.databaseUrl) server.databaseUrl = options.databaseUrl;
if (options.dataDir) server.dataDir = options.dataDir;
if (options.cwd) server.cwd = options.cwd;
if (options.lifecycle) server.lifecycle = options.lifecycle;

await addContext(
options.name,
options.apiUrl,
options.url,
Object.keys(server).length === 0 ? undefined : server
);
console.log(
chalk.green(`\n Saved context ${options.name} -> ${options.apiUrl}\n`)
chalk.green(`\n Saved context ${options.name} -> ${options.url}\n`)
);
}

Expand All @@ -87,5 +79,5 @@ export async function contextUseCommand(name: string): Promise<void> {
}

console.log(chalk.green(`\n Switched to context ${trimmedName}`));
console.log(chalk.dim(` API URL: ${context.apiUrl}\n`));
console.log(chalk.dim(` URL: ${context.url}\n`));
}
52 changes: 38 additions & 14 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,34 @@ export function isSharedDatabaseUrl(databaseUrl: string): boolean {
}
}

/**
* Decide whether `lobu run` must refuse to boot because the EFFECTIVE
* DATABASE_URL points at a shared/non-local DB the project never opted into.
*
* `mergedEnv` gives the shell higher precedence than the project's `.env`, so
* the project only "owns" the URL when its `.env` value is the exact one that
* survived the merge. Gating on project-`.env` *presence* alone (the old bug)
* let a shared/prod shell URL win silently whenever `.env` also happened to
* define its own DATABASE_URL — re-pointing "local dev" at shared/prod data.
*
* Exported for unit tests; the safety gate in `devCommand` is the consumer.
*/
export function shouldRefuseSharedDatabaseUrl(input: {
effectiveDatabaseUrl: string | undefined;
projectEnvDatabaseUrl: string | undefined;
unsafeSharedDb: boolean | undefined;
}): boolean {
const effective = input.effectiveDatabaseUrl?.trim();
if (!effective) return false;
if (input.unsafeSharedDb) return false;

const projectEnv = input.projectEnvDatabaseUrl?.trim();
const projectEnvOwnsIt = !!projectEnv && projectEnv === effective;
if (projectEnvOwnsIt) return false;

return isSharedDatabaseUrl(effective);
}

type BackendBundleKind = "postgres" | "pglite";

/**
Expand Down Expand Up @@ -82,32 +110,28 @@ export async function devCommand(
// Precedence: shell > project .env > user config > defaults.
const userServerConfig = await getServerConfig().catch(() => undefined);
const userServerEnv: Record<string, string> = {};
if (userServerConfig?.databaseUrl)
userServerEnv.DATABASE_URL = userServerConfig.databaseUrl;
if (userServerConfig?.port)
userServerEnv.PORT = String(userServerConfig.port);
if (userServerConfig?.host) userServerEnv.HOST = userServerConfig.host;
if (userServerConfig?.dataDir)
userServerEnv.LOBU_DATA_DIR = userServerConfig.dataDir;

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

// Refuse to boot against a shared/non-local DATABASE_URL that came from the
// parent shell rather than the project's own .env or the user's config.
// A common footgun: "local lobu run" silently writes into prod / a
// teammate's tailnet DB. The project pinning its own DATABASE_URL, or the
// user persisting one in ~/.config/lobu/config.json, is explicit consent.
// parent shell rather than the project's own .env. A common footgun:
// "local lobu run" silently writes into prod / a teammate's tailnet DB.
// Project pinning in .env is explicit consent.
if (
hasDatabaseUrl &&
!envVars.DATABASE_URL?.trim() &&
!userServerEnv.DATABASE_URL?.trim() &&
isSharedDatabaseUrl(mergedEnv.DATABASE_URL!) &&
!options.unsafeSharedDb
shouldRefuseSharedDatabaseUrl({
effectiveDatabaseUrl,
projectEnvDatabaseUrl: envVars.DATABASE_URL,
unsafeSharedDb: options.unsafeSharedDb,
})
) {
spinner.fail("DATABASE_URL inherited from shell points at a shared DB");
console.error(
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export async function loginCommand(options: LoginOptions): Promise<void> {

let discovery: OAuthDiscovery;
try {
discovery = await discoverOAuth(target.apiUrl);
discovery = await discoverOAuth(target.url);
} catch (err) {
const message =
err instanceof OAuthError ? err.message : String((err as Error).message);
Expand Down Expand Up @@ -300,7 +300,7 @@ export async function loginCommand(options: LoginOptions): Promise<void> {
}

async function loginWithToken(
target: { apiUrl: string; name: string },
target: { url: string; name: string },
rawToken: string
): Promise<void> {
const token = rawToken.trim();
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ const CLOUD_MCP_URL = "https://lobu.ai/mcp";
function mockProdMemoryContext() {
spyOn(internal, "resolveContext").mockResolvedValue({
name: "prod",
apiUrl: "https://community.lobu.ai/api/v1",
url: "https://community.lobu.ai/api/v1",
source: "config",
});
spyOn(internal, "getMemoryUrl").mockImplementation(async () => CLOUD_MCP_URL);
spyOn(internal, "getActiveOrg").mockImplementation(async () => "buremba");
spyOn(internal, "findContextByMemoryUrl").mockResolvedValue({
name: "lobu",
apiUrl: "https://app.lobu.ai/api/v1",
url: "https://app.lobu.ai/api/v1",
source: "default",
});
spyOn(internal, "getToken").mockImplementation(async (contextName) =>
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export async function orgCreateCommand(
options?: { name?: string; context?: string }
): Promise<void> {
const target = await resolveContext(options?.context);
const origin = new URL(target.apiUrl).origin;
const origin = new URL(target.url).origin;
const name = options?.name?.trim() || slug;
const url = `${origin}/orgs/new?slug=${encodeURIComponent(slug)}&name=${encodeURIComponent(name)}`;
console.log(
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function tokenCommand(options: {
}

console.log(chalk.cyan(`\n Context: ${target.name}`));
console.log(chalk.dim(` API URL: ${target.apiUrl}`));
console.log(chalk.dim(` API URL: ${target.url}`));
console.log(" Token: available (use `lobu token --raw` to print it)\n");
}

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,20 @@ export async function whoamiCommand(options?: {
chalk.dim("\n Authenticated via LOBU_API_TOKEN environment variable.")
);
console.log(chalk.dim(` Context: ${target.name}`));
console.log(chalk.dim(` API URL: ${target.apiUrl}`));
console.log(chalk.dim(` API URL: ${target.url}`));
console.log(chalk.dim(" Lobu Cloud is in early access.\n"));
return;
}
console.log(chalk.dim("\n Not logged in."));
console.log(chalk.dim(` Context: ${target.name}`));
console.log(chalk.dim(` API URL: ${target.apiUrl}`));
console.log(chalk.dim(` API URL: ${target.url}`));
console.log(chalk.dim(" Run `lobu login` to authenticate.\n"));
return;
}

console.log(chalk.bold("\n Lobu CLI"));
console.log(chalk.dim(` Context: ${target.name}`));
console.log(chalk.dim(` API URL: ${target.apiUrl}`));
console.log(chalk.dim(` API URL: ${target.url}`));
if (creds.name) {
console.log(chalk.dim(` Name: ${creds.name}`));
}
Expand Down
Loading
Loading