diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index c83272338..503183902 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -5,8 +5,8 @@ import chalk from "chalk"; import { agentApiBase, apiBaseFromContextUrl, + getAgentApiToken, getCurrentContextName, - getToken, resolveContext, resolveGatewayUrl, } from "../internal/index.js"; @@ -90,7 +90,7 @@ export async function chatCommand( // context apiUrl and `.env` PORT only give the origin. gatewayUrl = agentApiBase(gatewayUrl); - const authToken = await getToken(options.context); + const authToken = await getAgentApiToken(options.context); if (!authToken) { console.error( chalk.red("\n Session expired or not logged in. Run `lobu login`.\n") diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index a4bf46c55..43e4eaeef 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -567,19 +567,20 @@ async function announceLocalSignIn( user?: { id?: string; email?: string; name?: string }; organization?: { id?: string; slug?: string; name?: string }; }; - // CLI gets the worker-scoped PAT — works against /api/workers/* (used - // by lobu apply and everything else). The session_token is - // for the browser deep-link URL: exchange-token validates either, but - // the cookie path needs a session (we pass session_token in the URL - // so the SPA hook reaches /api/exchange-token → Better Auth session - // cookie). - const cliToken = body.device_token ?? body.session_token; + // CLI's default token is the Better Auth session token; session auth + // carries the user's org membership and works for admin REST + MCP calls. + // Persist the companion worker PAT too for the gateway agent API, which + // still authenticates that surface via worker/OAuth bearer tokens. The + // same session token is passed to the browser deep-link URL so the SPA + // hook reaches /api/exchange-token → Better Auth session cookie. + const cliToken = body.session_token ?? body.device_token; if (!cliToken) return false; const contextName = "local"; await addContext(contextName, gatewayUrl); const creds: Credentials = { accessToken: cliToken, + ...(body.device_token ? { localWorkerToken: body.device_token } : {}), ...(body.user?.email ? { email: body.user.email } : {}), ...(body.user?.name ? { name: body.user.name } : {}), ...(body.user?.id ? { userId: body.user.id } : {}), diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts index 174be8c04..0e7ba5a53 100644 --- a/packages/cli/src/internal/__tests__/context.test.ts +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -14,6 +14,7 @@ import { findContextByMemoryUrl, findContextByUrl, getActiveOrg, + getMemoryUrl, getServerConfig, loadContextConfig, removeContext, @@ -142,6 +143,25 @@ describe("context management", () => { expect(matched?.name).toBe("local"); }); + test("derives local memory URL from a loopback context URL", async () => { + const configData = { + currentContext: "local", + contexts: { + lobu: { url: "https://app.lobu.ai/api/v1" }, + local: { url: "http://localhost:8787/api/v1" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + expect(await getMemoryUrl("local")).toBe("http://localhost:8787/mcp"); + expect( + (await findContextByMemoryUrl("http://127.0.0.1:8787/mcp"))?.name + ).toBeUndefined(); + expect( + (await findContextByMemoryUrl("http://localhost:8787/mcp"))?.name + ).toBe("local"); + }); + test("derives managed server settings from flat context fields", async () => { const configData = { currentContext: "local", diff --git a/packages/cli/src/internal/__tests__/credentials.test.ts b/packages/cli/src/internal/__tests__/credentials.test.ts index 740adc2e2..184709f6c 100644 --- a/packages/cli/src/internal/__tests__/credentials.test.ts +++ b/packages/cli/src/internal/__tests__/credentials.test.ts @@ -12,6 +12,7 @@ import * as context from "../context"; import { clearCredentials, type Credentials, + getAgentApiToken, getToken, loadCredentials, refreshCredentials, @@ -193,6 +194,102 @@ describe("credentials", () => { expect(token).toBeNull(); }); + test("getToken local-init prefers the session token over the worker PAT", async () => { + spyOn(context, "resolveContext").mockResolvedValue({ + name: currentContextName, + url: "http://localhost:8787/api/v1", + source: "config", + }); + readFileSpy.mockRejectedValue(new Error("ENOENT")); + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + device_token: "worker-pat", + session_token: "session-token", + user: { id: "user-1", email: "u@example.com", name: "User" }, + organization: { id: "org-1", slug: "local-org", name: "Local" }, + }), + { status: 200 } + ) + ); + + const token = await getToken(currentContextName); + + expect(token).toBe("session-token"); + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:8787/api/local-init", + { + method: "POST", + headers: { "X-Lobu-Client": "cli" }, + } + ); + const [, written] = writeFileSpy.mock.calls[0]!; + const persisted = JSON.parse(written as string) as { + contexts: Record; + }; + expect(persisted.contexts[currentContextName]?.accessToken).toBe( + "session-token" + ); + expect(persisted.contexts[currentContextName]?.localWorkerToken).toBe( + "worker-pat" + ); + }); + + test("getAgentApiToken uses the local-init worker PAT when present", async () => { + const store = { + version: 2, + contexts: { + [currentContextName]: buildCreds({ + accessToken: "session-token", + localWorkerToken: "worker-pat", + }), + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(store)); + + const token = await getAgentApiToken(currentContextName); + + expect(token).toBe("worker-pat"); + }); + + test("getToken heals stale local credentials that only stored the worker PAT", async () => { + spyOn(context, "resolveContext").mockResolvedValue({ + name: currentContextName, + url: "http://localhost:8787/api/v1", + source: "config", + }); + const store = { + version: 2, + contexts: { + [currentContextName]: buildCreds({ accessToken: "old-worker-pat" }), + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(store)); + spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + device_token: "new-worker-pat", + session_token: "new-session-token", + }), + { status: 200 } + ) + ); + + const token = await getToken(currentContextName); + + expect(token).toBe("new-session-token"); + const [, written] = writeFileSpy.mock.calls[0]!; + const persisted = JSON.parse(written as string) as { + contexts: Record; + }; + expect(persisted.contexts[currentContextName]?.accessToken).toBe( + "new-session-token" + ); + expect(persisted.contexts[currentContextName]?.localWorkerToken).toBe( + "new-worker-pat" + ); + }); + test("getToken returns the stored access token when not expired", async () => { const creds = buildCreds({ accessToken: "still-good", diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index cbfbe4a73..e22c6e728 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -102,9 +102,7 @@ export async function getMemoryUrl(contextName?: string): Promise { const config = await loadContextConfig(); const name = contextName || config.currentContext; - return normalizeApiUrl( - config.contexts[name]?.memoryUrl || DEFAULT_MEMORY_URL - ); + return normalizeApiUrl(defaultMemoryUrlForContext(config.contexts[name])); } export async function setActiveOrg( @@ -487,7 +485,7 @@ export async function findContextByMemoryUrl( for (const [name, context] of Object.entries(config.contexts)) { const candidate = normalizeMemoryBaseUrl( - context.memoryUrl || DEFAULT_MEMORY_URL + defaultMemoryUrlForContext(context) ); if (candidate === normalizedSearch) { return contextToResolvedContext(name, context); @@ -497,6 +495,31 @@ export async function findContextByMemoryUrl( return undefined; } +function defaultMemoryUrlForContext( + context: LobuContextEntry | undefined +): string { + if (context?.memoryUrl) return context.memoryUrl; + if (context && isLoopbackContextUrl(context.url)) { + const url = new URL(context.url); + url.pathname = "/mcp"; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); + } + return DEFAULT_MEMORY_URL; +} + +function isLoopbackContextUrl(input: string): boolean { + try { + const { hostname } = new URL(input); + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ); + } catch { + return false; + } +} + function contextToResolvedContext( name: string, context: LobuContextEntry diff --git a/packages/cli/src/internal/credentials.ts b/packages/cli/src/internal/credentials.ts index d44ad1109..6de23d854 100644 --- a/packages/cli/src/internal/credentials.ts +++ b/packages/cli/src/internal/credentials.ts @@ -30,6 +30,8 @@ export interface Credentials { name?: string; userId?: string; agentId?: string; + /** Local-init worker PAT used only by the gateway agent API. */ + localWorkerToken?: string; /** Registered OAuth client + endpoints used to mint these tokens. */ oauth?: OAuthClientInfo; } @@ -127,16 +129,51 @@ export async function clearCredentials(contextName?: string): Promise { * * For loopback contexts with no stored creds, transparently POSTs * /api/local-init to mint a fresh Better Auth session for the - * embedded bootstrap user — `lobu chat -c local` works without a - * prior `lobu login`. + * embedded bootstrap user. Agent API callers should use getAgentApiToken(), + * which returns the companion worker PAT when local-init provides one. */ export async function getToken(contextName?: string): Promise { const envToken = process.env.LOBU_API_TOKEN; if (envToken) return envToken; + return getCredentialsToken(contextName); +} + +/** + * Token for the gateway agent API (`/lobu/api/v1/agents/*`). Local embedded + * installs need the worker PAT from /api/local-init for that surface, while + * admin REST + MCP need the Better Auth session token returned by getToken(). + */ +export async function getAgentApiToken( + contextName?: string +): Promise { + const envToken = process.env.LOBU_API_TOKEN; + if (envToken) return envToken; + + const token = await getCredentialsToken(contextName); + if (!token) return null; + + let creds = await loadCredentials(contextName); + if (!creds?.localWorkerToken && (await isLoopbackContext(contextName))) { + creds = await tryLocalInit(contextName); + } + return creds?.localWorkerToken ?? token; +} + +async function getCredentialsToken( + contextName?: string +): Promise { let creds = await loadCredentials(contextName); if (!creds) { creds = await tryLocalInit(contextName); + } else if ( + !creds.localWorkerToken && + (await isLoopbackContext(contextName)) + ) { + // Heal credentials saved by older CLIs that stored only the local-init + // worker PAT as accessToken. Re-mint so admin REST/MCP get the session + // token while chat keeps the companion worker PAT. + creds = (await tryLocalInit(contextName)) ?? creds; } if (!creds) return null; if (!needsRefresh(creds)) return creds.accessToken; @@ -165,10 +202,13 @@ async function tryLocalInit(contextName?: string): Promise { const target = await resolveContext(contextName); if (!isLoopbackUrl(target.url)) return null; try { - const res = await fetch(`${target.url}/api/local-init`, { - method: "POST", - headers: { "X-Lobu-Client": "cli" }, - }); + const res = await fetch( + `${originFromContextUrl(target.url)}/api/local-init`, + { + method: "POST", + headers: { "X-Lobu-Client": "cli" }, + } + ); if (!res.ok) return null; const body = (await res.json()) as { device_token?: string; @@ -176,14 +216,16 @@ async function tryLocalInit(contextName?: string): Promise { user?: { id?: string; email?: string; name?: string }; organization?: { id?: string; slug?: string; name?: string }; }; - // Prefer device_token (PAT scoped with device_worker:run + mcp:*) so - // `lobu chat` / `lobu apply` / worker poll all pass the scope gate on - // /api/workers/*. Fall back to session_token only against older - // servers that don't issue a PAT. - const token = body.device_token ?? body.session_token; + // Prefer the Better Auth session token for CLI commands. The worker PAT + // from /api/local-init is intentionally device-scoped; session auth carries + // the user's org membership and works for admin REST + MCP calls. Fall back + // to device_token only against older local servers that did not return a + // session token. + const token = body.session_token ?? body.device_token; if (!token) return null; const creds: Credentials = { accessToken: token, + ...(body.device_token ? { localWorkerToken: body.device_token } : {}), ...(body.user?.email ? { email: body.user.email } : {}), ...(body.user?.name ? { name: body.user.name } : {}), ...(body.user?.id ? { userId: body.user.id } : {}), @@ -221,6 +263,11 @@ async function tryLocalInit(contextName?: string): Promise { } } +async function isLoopbackContext(contextName?: string): Promise { + const target = await resolveContext(contextName); + return isLoopbackUrl(target.url); +} + function isLoopbackUrl(url: string): boolean { try { const { hostname } = new URL(url); @@ -232,6 +279,14 @@ function isLoopbackUrl(url: string): boolean { } } +function originFromContextUrl(input: string): string { + const url = new URL(input); + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + export async function refreshCredentials( existing?: Credentials | null, contextName?: string diff --git a/packages/cli/src/internal/index.ts b/packages/cli/src/internal/index.ts index b09d8b0c0..555d91895 100644 --- a/packages/cli/src/internal/index.ts +++ b/packages/cli/src/internal/index.ts @@ -15,6 +15,7 @@ export { type Credentials, type OAuthClientInfo, clearCredentials, + getAgentApiToken, getToken, loadCredentials, refreshCredentials,