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
4 changes: 2 additions & 2 deletions packages/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import chalk from "chalk";
import {
agentApiBase,
apiBaseFromContextUrl,
getAgentApiToken,
getCurrentContextName,
getToken,
resolveContext,
resolveGatewayUrl,
} from "../internal/index.js";
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 8 additions & 7 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/internal/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
findContextByMemoryUrl,
findContextByUrl,
getActiveOrg,
getMemoryUrl,
getServerConfig,
loadContextConfig,
removeContext,
Expand Down Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/internal/__tests__/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as context from "../context";
import {
clearCredentials,
type Credentials,
getAgentApiToken,
getToken,
loadCredentials,
refreshCredentials,
Expand Down Expand Up @@ -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<string, Credentials>;
};
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<string, Credentials>;
};
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",
Expand Down
31 changes: 27 additions & 4 deletions packages/cli/src/internal/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,7 @@ export async function getMemoryUrl(contextName?: string): Promise<string> {

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(
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
}
Comment on lines +512 to +521
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

IPv6 loopback detection fails for bracketed addresses.

The hostname property of a parsed IPv6 URL includes brackets. For example, new URL("http://[::1]:8787").hostname returns "[::1]", not "::1" (as noted in the comment at line 432). The check hostname === "::1" will never match a stored context URL like "http://[::1]:8787/api/v1", causing isLoopbackContextUrl to return false and preventing local memory URL derivation for IPv6 loopback contexts.

🔧 Proposed fix to handle bracketed IPv6 addresses
 function isLoopbackContextUrl(input: string): boolean {
   try {
     const { hostname } = new URL(input);
     return (
-      hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
+      hostname === "localhost" ||
+      hostname === "127.0.0.1" ||
+      hostname === "::1" ||
+      hostname === "[::1]"
     );
   } catch {
     return false;
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 isLoopbackContextUrl(input: string): boolean {
try {
const { hostname } = new URL(input);
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/internal/context.ts` around lines 512 - 521,
isLoopbackContextUrl fails to detect bracketed IPv6 hostnames because new
URL(...).hostname returns "[::1]"; update the isLoopbackContextUrl function to
normalize the parsed hostname by stripping surrounding brackets if present
before comparing: obtain hostname via new URL(input).hostname, remove a leading
"[" and trailing "]" when both exist, then check equality against "localhost",
"127.0.0.1", and "::1" (keeping the existing try/catch behavior) so bracketed
IPv6 URLs like "http://[::1]:8787/..." are correctly recognized as loopback.


function contextToResolvedContext(
name: string,
context: LobuContextEntry
Expand Down
77 changes: 66 additions & 11 deletions packages/cli/src/internal/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -127,16 +129,51 @@ export async function clearCredentials(contextName?: string): Promise<void> {
*
* 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<string | null> {
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<string | null> {
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<string | null> {
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;
Comment on lines +169 to +176
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Also heal loopback creds when accessToken is still the worker token.

This migration only reruns /api/local-init when localWorkerToken is missing. But Lines 224-228 now persist accessToken === localWorkerToken === device_token for older local servers, so after that server is upgraded to return session_token, getToken() will keep returning the stale worker PAT and admin REST/MCP stays broken until the user manually deletes credentials.

Proposed fix
-  } else if (
-    !creds.localWorkerToken &&
-    (await isLoopbackContext(contextName))
-  ) {
+  } else if (
+    (await isLoopbackContext(contextName)) &&
+    (!creds.localWorkerToken ||
+      creds.accessToken === creds.localWorkerToken)
+  ) {
     // 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;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/internal/credentials.ts` around lines 169 - 176, The
migration only calls tryLocalInit when localWorkerToken is missing, but we also
need to heal cases where accessToken is still the old worker PAT (accessToken
=== localWorkerToken/device_token); update the branch that checks
isLoopbackContext(contextName) to also trigger remint when creds.accessToken
equals creds.localWorkerToken (or device token field) so
tryLocalInit(contextName) is run and creds replaced; locate the block using
isLoopbackContext, creds, tryLocalInit and getToken and add the equality check
for accessToken->localWorkerToken to force re-minting and persisting the new
session token.

}
if (!creds) return null;
if (!needsRefresh(creds)) return creds.accessToken;
Expand Down Expand Up @@ -165,25 +202,30 @@ async function tryLocalInit(contextName?: string): Promise<Credentials | null> {
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;
session_token?: string;
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 } : {}),
Expand Down Expand Up @@ -221,6 +263,11 @@ async function tryLocalInit(contextName?: string): Promise<Credentials | null> {
}
}

async function isLoopbackContext(contextName?: string): Promise<boolean> {
const target = await resolveContext(contextName);
return isLoopbackUrl(target.url);
}

function isLoopbackUrl(url: string): boolean {
try {
const { hostname } = new URL(url);
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
type Credentials,
type OAuthClientInfo,
clearCredentials,
getAgentApiToken,
getToken,
loadCredentials,
refreshCredentials,
Expand Down
Loading