diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index d0bea09a3..445feea6e 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -9,7 +9,13 @@ 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, + getCurrentContextName, + getServerConfig, + setActiveOrg, + setCurrentContext, +} 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 +418,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 +438,32 @@ 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); + } + // 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 47b3f396b..1589513af 100644 --- a/packages/cli/src/internal/credentials.ts +++ b/packages/cli/src/internal/credentials.ts @@ -2,8 +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"; @@ -171,6 +174,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 +189,32 @@ 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); + } + // 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; 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/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. */