From 3b12d0ef2ac8e44e8000b61aa33cc3981b4fa871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 19 May 2026 23:43:18 +0100 Subject: [PATCH 1/4] fix(server): mount embedded gateway at /lobu in start-local + accept PATs at /oauth/userinfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lobu run` boots via `start-local.ts`, which called `initLobuGateway()` but threw away the returned Hono sub-app. The production entry (`server.ts`) mounts that app at `/lobu`, so `lobu chat`'s `POST /lobu/api/v1/agents` worked in prod but 404'd against every local install. Mount the lobu sub-app at `/lobu` before the main app catch-all so both entries expose the same public Agent API surface. Once mounted, the gateway rejected the local-init PAT because the route's auth middleware delegates to the OIDC issuer's `/oauth/userinfo`, and that handler only validated rows in `oauth_tokens` — PATs (`owl_pat_*`, `personal_access_tokens`) returned 403. Extend `OAuthProvider.verifyAccessToken` to fall through to `PersonalAccessTokenService.verify` for `owl_pat_*` bearers and add `profile:read` to the local-init PAT scope so `getUserInfo` returns the user. With both fixes, the same `device_token` issued by `/api/local-init` now authenticates the admin REST (`/api//*`), the OAuth introspection endpoint, and the public Agent API. --- packages/server/src/auth/oauth/provider.ts | 16 +++++++++++++++- packages/server/src/auth/routes.ts | 7 ++++++- packages/server/src/start-local.ts | 12 +++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/server/src/auth/oauth/provider.ts b/packages/server/src/auth/oauth/provider.ts index bf11ddb13..92c1fb56e 100644 --- a/packages/server/src/auth/oauth/provider.ts +++ b/packages/server/src/auth/oauth/provider.ts @@ -6,6 +6,7 @@ */ import type { DbClient } from '../../db/client'; +import { PersonalAccessTokenService } from '../tokens'; import { OAuthClientsStore } from './clients'; import { AVAILABLE_SCOPES } from './scopes'; import type { @@ -352,9 +353,22 @@ export class OAuthProvider { // ============================================ /** - * Verify an access token and return auth info + * Verify an access token and return auth info. + * + * Accepts both OAuth 2.1 access tokens (`oauth_tokens` rows) and Personal + * Access Tokens (`personal_access_tokens` rows, prefix `owl_pat_`). The + * `/oauth/userinfo` route delegates here, so making PATs work for OAuth + * introspection lets a single bearer token authenticate against + * `/oauth/userinfo`, `/api//*`, the gateway's `/lobu/api/v1/*` + * via `createApiAuthMiddleware`, and the CLI's `lobu apply` + * org-resolution call. Without this, `lobu chat -c local` against the + * embedded server fails with a 404/401 even after a successful + * `/api/local-init` because the gateway can't introspect the PAT. */ async verifyAccessToken(token: string): Promise { + if (token.startsWith('owl_pat_')) { + return new PersonalAccessTokenService(this.sql).verify(token); + } const tokenHash = hashToken(token); const result = await this.sql` diff --git a/packages/server/src/auth/routes.ts b/packages/server/src/auth/routes.ts index 2e79119ea..e0c4cd7e4 100644 --- a/packages/server/src/auth/routes.ts +++ b/packages/server/src/auth/routes.ts @@ -526,7 +526,12 @@ credentialRoutes.post('/local-init', async (c) => { 'local-init', { description: 'Auto-minted by POST /api/local-init for local-runner clients.', - scope: 'device_worker:run mcp:read mcp:write mcp:admin', + // `profile:read` lets the same PAT hit `/oauth/userinfo`, which the + // gateway's `createApiAuthMiddleware` (used by `/lobu/api/v1/agents/*`) + // and the CLI's `lobu apply` org-resolution path both call. Without it + // the PAT works for `/api//*` and worker poll but is rejected + // by the agent-session and userinfo endpoints with a confusing 403/404. + scope: 'device_worker:run mcp:read mcp:write mcp:admin profile:read', } ); diff --git a/packages/server/src/start-local.ts b/packages/server/src/start-local.ts index 27dabff4c..50710d239 100644 --- a/packages/server/src/start-local.ts +++ b/packages/server/src/start-local.ts @@ -168,7 +168,7 @@ async function main() { const { bootTaskScheduler } = await import('./scheduled/jobs'); await initWorkspaceProvider(); - await initLobuGateway(); + const lobuApp = await initLobuGateway(); const env = getEnvFromProcess(); const taskScheduler = await bootTaskScheduler(getLobuCoreServices(), env); @@ -202,6 +202,16 @@ async function main() { Object.assign(c.env, env); return next(); }); + // Mount the embedded Lobu gateway (Agent API + worker proxy + MCP) at + // /lobu BEFORE the main app's catch-all. Mirrors server.ts so that + // `lobu chat` / `lobu run` / SDK clients targeting `/lobu/api/v1/*` + // resolve identically in local-mode and production. Without this the + // bundle exposes /api//* (admin REST) but nothing under /lobu — + // so the public Agent API 404s and `lobu chat` fails before the worker + // even starts. + if (lobuApp) { + wrapper.route('/lobu', lobuApp); + } wrapper.route('/', mainApp); const honoListener = getRequestListener(wrapper.fetch); From 19570a973deb6edf33d4fdc2716f3d70f8304aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 19 May 2026 23:43:32 +0100 Subject: [PATCH 2/4] fix(gateway): propagate organizationId from session to message queue `EmbeddedDeploymentManager.storeDeploymentConfigs` calls `grantStore.grant(..., orgId)` and `GrantStore.grant` refuses any call without an org id. The Agent API was constructing the queue payload from `ThreadSession`, which had no `organizationId` field, so the very first message after `lobu chat` crashed at worker spawn with "GrantStore.grant requires organizationId" even though the session itself was created cleanly. Cache the owning org on `ThreadSession` at create time (it's already resolved from agent metadata for the worker token), then stamp it on `enqueueMessage`. No metadata round-trip per message; one read per session. --- packages/server/src/gateway/routes/public/agent.ts | 4 ++++ packages/server/src/gateway/session.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/server/src/gateway/routes/public/agent.ts b/packages/server/src/gateway/routes/public/agent.ts index f73576b3d..7bb3ca811 100644 --- a/packages/server/src/gateway/routes/public/agent.ts +++ b/packages/server/src/gateway/routes/public/agent.ts @@ -754,6 +754,7 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { : undefined, nixConfig, agentId, + ...(tokenOrganizationId ? { organizationId: tokenOrganizationId } : {}), dryRun: effectiveDryRun, intent: watcherIntent ?? undefined, isEphemeral, @@ -1210,6 +1211,9 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { channelId, teamId: "api", agentId: realAgentId, + ...(session.organizationId + ? { organizationId: session.organizationId } + : {}), botId: "lobu-api", platform: "api", messageText: messageContent, diff --git a/packages/server/src/gateway/session.ts b/packages/server/src/gateway/session.ts index bb8c28c62..c1d1cce62 100644 --- a/packages/server/src/gateway/session.ts +++ b/packages/server/src/gateway/session.ts @@ -37,6 +37,15 @@ export interface ThreadSession { nixConfig?: NixConfig; /** Original agent ID (before composite session key generation) */ agentId?: string; + /** + * Owning organization of the agent. Cached at session-create time so the + * message-send path can stamp it on the queue payload without re-reading + * `agent_metadata` for every message. EmbeddedDeploymentManager refuses + * `grantStore.grant()` calls without an org id, so without this the very + * first message after `lobu chat` would fail at worker spawn even though + * the session itself was created cleanly. + */ + organizationId?: string; /** Process without persisting history */ dryRun?: boolean; /** Internal automation intent for one-shot system sessions. */ From 2643f1cd68667dc4f8ee4aef084546e3db90538b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 19 May 2026 23:43:44 +0100 Subject: [PATCH 3/4] fix(cli): persist local-init's returned org slug as context activeOrg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/local-init` returns the install operator's auto-provisioned personal org (slug + id), but neither `tryLocalInit` nor `announceLocalSignIn` saved it. The CLI then had no `activeOrg` for the loopback context, so `lobu apply` exited with "No organization selected. Run `lobu org set ` …" until the user manually copied the slug from the boot banner. Pipe the response's `organization.slug` into `setActiveOrg(slug, contextName)` so `lobu init && lobu run && lobu apply` works end-to-end without a manual `lobu org set` step. --- packages/cli/src/commands/dev.ts | 16 +++++++++++++++- packages/cli/src/internal/credentials.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index d0bea09a3..62c9c9b75 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -9,7 +9,11 @@ import chalk from "chalk"; import ora from "ora"; import { isLoadError, loadConfig } from "../config/loader.js"; import { resolveApiClient } from "../internal/api-client.js"; -import { addContext, getServerConfig } from "../internal/context.js"; +import { + addContext, + getServerConfig, + setActiveOrg, +} from "../internal/context.js"; import { type Credentials, saveCredentials } from "../internal/credentials.js"; import { parseEnvContent } from "../internal/index.js"; import { loadProjectLink } from "../internal/project-link.js"; @@ -412,6 +416,7 @@ async function announceLocalSignIn( device_token?: string; session_token?: string; 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 @@ -431,6 +436,15 @@ async function announceLocalSignIn( ...(body.user?.id ? { userId: body.user.id } : {}), }; await saveCredentials(creds, contextName); + // Bind the bootstrap org slug returned by /api/local-init to the + // context. Without this, `lobu apply -c local` errors with + // "No organization selected" until the user manually runs + // `lobu org set `. The server is the source of truth — it + // auto-provisioned this org for the install operator. + const orgSlug = body.organization?.slug?.trim(); + if (orgSlug) { + await setActiveOrg(orgSlug, contextName).catch(() => undefined); + } const url = new URL(gatewayUrl); url.searchParams.set("lobu_token", body.session_token ?? cliToken); diff --git a/packages/cli/src/internal/credentials.ts b/packages/cli/src/internal/credentials.ts index 47b3f396b..feffd60bb 100644 --- a/packages/cli/src/internal/credentials.ts +++ b/packages/cli/src/internal/credentials.ts @@ -4,6 +4,7 @@ import { DEFAULT_CONTEXT_NAME, LOBU_CONFIG_DIR, resolveContext, + setActiveOrg, } from "./context.js"; import { refreshTokens } from "./oauth.js"; @@ -171,6 +172,7 @@ async function tryLocalInit(contextName?: string): Promise { 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 @@ -185,6 +187,15 @@ async function tryLocalInit(contextName?: string): Promise { ...(body.user?.id ? { userId: body.user.id } : {}), }; await saveCredentials(creds, target.name); + // Bind the local-init org slug to the context so `lobu apply` / + // `lobu chat` / `lobu org current` find it without a manual + // `lobu org set `. The server's bootstrap auto-provisions the + // single user's personal org and returns it in the response — that + // slug is the source of truth for this loopback install. + const orgSlug = body.organization?.slug?.trim(); + if (orgSlug) { + await setActiveOrg(orgSlug, target.name).catch(() => undefined); + } return creds; } catch { return null; From 62efff4d3fb8105330cca008745b739f20d52d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 00:59:22 +0100 Subject: [PATCH 4/4] fix(cli): auto-switch active context to "local" after successful bootstrap --- packages/cli/src/commands/dev.ts | 19 +++++++++++++++++++ packages/cli/src/internal/credentials.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 62c9c9b75..445feea6e 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -11,8 +11,10 @@ import { isLoadError, loadConfig } from "../config/loader.js"; import { resolveApiClient } from "../internal/api-client.js"; import { addContext, + getCurrentContextName, getServerConfig, setActiveOrg, + setCurrentContext, } from "../internal/context.js"; import { type Credentials, saveCredentials } from "../internal/credentials.js"; import { parseEnvContent } from "../internal/index.js"; @@ -445,6 +447,23 @@ async function announceLocalSignIn( if (orgSlug) { await setActiveOrg(orgSlug, contextName).catch(() => undefined); } + // Auto-switch the active context so plain `lobu apply` / `lobu chat` + // from any shell hit this loopback server instead of whatever cloud + // context was active. Announce on stderr when we actually flip so the + // user isn't surprised — `lobu run` on a fresh box silently lands on + // `local`; `lobu run` from a shell previously on `lobu` cloud prints + // the switch. + try { + const current = await getCurrentContextName(); + if (current !== contextName) { + await setCurrentContext(contextName); + process.stderr.write( + `Switched active context to "${contextName}" (lobu run)\n` + ); + } + } catch { + // Best-effort — failing to switch shouldn't kill the run banner. + } const url = new URL(gatewayUrl); url.searchParams.set("lobu_token", body.session_token ?? cliToken); diff --git a/packages/cli/src/internal/credentials.ts b/packages/cli/src/internal/credentials.ts index feffd60bb..1589513af 100644 --- a/packages/cli/src/internal/credentials.ts +++ b/packages/cli/src/internal/credentials.ts @@ -2,9 +2,11 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { DEFAULT_CONTEXT_NAME, + getCurrentContextName, LOBU_CONFIG_DIR, resolveContext, setActiveOrg, + setCurrentContext, } from "./context.js"; import { refreshTokens } from "./oauth.js"; @@ -196,6 +198,23 @@ async function tryLocalInit(contextName?: string): Promise { if (orgSlug) { await setActiveOrg(orgSlug, target.name).catch(() => undefined); } + // Auto-switch the active context so subsequent `lobu apply` / `lobu chat` + // invocations (without `-c `) hit the same loopback server. Without + // this, a user previously on the `lobu` cloud context who runs `lobu run` + // locally still sees cloud for every other command — and the fact that a + // local context exists is invisible. Announce on stderr so the change is + // visible but doesn't pollute stdout pipelines. + try { + const current = await getCurrentContextName(); + if (current !== target.name) { + await setCurrentContext(target.name); + process.stderr.write( + `Switched active context to "${target.name}" (lobu run)\n` + ); + } + } catch { + // Best-effort — a write failure here shouldn't break the auth flow. + } return creds; } catch { return null;