-
Notifications
You must be signed in to change notification settings - Fork 20
fix(cli): honor local context for memory and token auth #1011
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also heal loopback creds when This migration only reruns 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 |
||
| } | ||
| if (!creds) return null; | ||
| if (!needsRefresh(creds)) return creds.accessToken; | ||
|
|
@@ -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 } : {}), | ||
|
|
@@ -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); | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IPv6 loopback detection fails for bracketed addresses.
The
hostnameproperty of a parsed IPv6 URL includes brackets. For example,new URL("http://[::1]:8787").hostnamereturns"[::1]", not"::1"(as noted in the comment at line 432). The checkhostname === "::1"will never match a stored context URL like"http://[::1]:8787/api/v1", causingisLoopbackContextUrlto 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
🤖 Prompt for AI Agents