diff --git a/packages/server/src/gateway/auth/api-auth-middleware.ts b/packages/server/src/gateway/auth/api-auth-middleware.ts index a9414d740..6a5f825b7 100644 --- a/packages/server/src/gateway/auth/api-auth-middleware.ts +++ b/packages/server/src/gateway/auth/api-auth-middleware.ts @@ -6,6 +6,25 @@ import { getRevokedTokenStore } from "./revoked-token-store.js"; export const TOKEN_EXPIRATION_MS = 24 * 60 * 60 * 1000; +/** + * Caller identity surfaced to handlers via `c.get("authContext")` after a + * successful auth check. `organizationId` is the token-bound or personal-org + * id when the auth path can resolve one (worker token payload, or + * `/oauth/userinfo` org slug); otherwise undefined. `createAgent` uses it to + * stamp the worker token for ephemeral agents so the cross-pod conversation + * lock can be acquired (#1068). + */ +export interface ApiAuthContext { + userId?: string; + organizationId?: string; +} + +declare module "hono" { + interface ContextVariableMap { + authContext?: ApiAuthContext; + } +} + /** * Creates a Hono middleware that enforces the standard auth check: * 1. Settings session cookie 2. Worker token (local) 3. External OAuth @@ -27,6 +46,7 @@ export function createApiAuthMiddleware(opts: { if (opts.allowSettingsSession) { const session = await verifySettingsSession(c); if (session) { + c.set("authContext", { userId: session.userId } satisfies ApiAuthContext); return next(); } } @@ -46,6 +66,10 @@ export function createApiAuthMiddleware(opts: { if (workerData.jti && (await revokedTokens.isRevoked(workerData.jti))) { return c.json({ success: false, error: "Unauthorized" }, 401); } + c.set("authContext", { + userId: workerData.userId, + organizationId: workerData.organizationId, + } satisfies ApiAuthContext); return next(); } } @@ -55,7 +79,13 @@ export function createApiAuthMiddleware(opts: { if (opts.externalAuthClient) { try { const userInfo = await opts.externalAuthClient.fetchUserInfo(token); - if (userInfo?.sub) return next(); + if (userInfo?.sub) { + c.set("authContext", { + userId: userInfo.sub, + organizationId: userInfo.organizationId, + } satisfies ApiAuthContext); + return next(); + } } catch { // Token not valid for external auth, continue to next method } diff --git a/packages/server/src/gateway/auth/external/client.ts b/packages/server/src/gateway/auth/external/client.ts index 3fb843e47..82a06a931 100644 --- a/packages/server/src/gateway/auth/external/client.ts +++ b/packages/server/src/gateway/auth/external/client.ts @@ -43,10 +43,28 @@ interface WellKnownMetadata { grant_types_supported?: string[]; } -interface UserInfoResponse { +export interface UserInfoResponse { sub: string; email: string; name?: string; + /** + * Token-bound org id, or — when the token has no org binding (e.g. a + * device-flow PAT) — the user's personal-org id. Resolved by mapping + * `organization_slug` to its entry in `organizations[]` (both already + * returned by `/oauth/userinfo`). Surfaced so the gateway middleware can + * attach an auth-context org to handlers like `createAgent`, which + * otherwise has no way to find the org for an ephemeral agent and + * downstream cross-pod conversation-lock acquisition fails (#1068). + */ + organizationId?: string; +} + +interface UserInfoApiResponse { + sub: string; + email: string; + name?: string; + organization_slug?: string | null; + organizations?: { id: string; slug: string; name: string }[]; } interface DynamicClientCredentials { @@ -167,12 +185,23 @@ export class ExternalAuthClient { ); } - const data = (await response.json()) as UserInfoResponse; + const data = (await response.json()) as UserInfoApiResponse; + const orgId = + data.organization_slug && data.organizations + ? (data.organizations.find((o) => o.slug === data.organization_slug) + ?.id ?? undefined) + : undefined; logger.info("Fetched external auth user info", { sub: data.sub, email: data.email, + orgId, }); - return data; + return { + sub: data.sub, + email: data.email, + name: data.name, + organizationId: orgId, + }; } async getCapabilities(): Promise { diff --git a/packages/server/src/gateway/routes/public/agent.ts b/packages/server/src/gateway/routes/public/agent.ts index 4cc6bb488..d483e0fd4 100644 --- a/packages/server/src/gateway/routes/public/agent.ts +++ b/packages/server/src/gateway/routes/public/agent.ts @@ -422,7 +422,7 @@ export interface AgentApiConfig { "getSettings" | "listAgents" | "getMetadata" >; userAgentsStore?: UserAgentsStore; - agentMetadataStore?: Pick; + agentMetadataStore?: Pick; platformRegistry?: PlatformRegistry; approveToolCall?: ( requestId: string, @@ -638,21 +638,51 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { if (denial) return denial; } - // Stamp the worker token with the agent's owning org so the egress - // proxy's per-tenant gates (grant/deny, judge cache, judge policy) - // can scope decisions by org. Ephemeral agents have no preexisting - // metadata; their token mints without orgId and the proxy falls - // through to unscoped checks for that worker — flagged for a - // future fix that derives org from the auth session. - const tokenOrganizationId = + // Stamp the worker token with the owning org so the egress proxy's + // per-tenant gates (grant/deny, judge cache, judge policy) can scope + // decisions by org. Prefer the agent's own metadata; fall back to the + // caller's organizationId (already resolved by `createLobuAuthBridge` + // from the PAT or Better Auth session). Without the fallback, + // ephemeral agents under `lobu chat -c local` mint a tokenless org and + // the downstream cross-pod conversation-lock guard refuses to spawn + // the worker (#1068). + const callerOrgId = + (c.get("organizationId") as string | undefined) ?? + c.get("authContext")?.organizationId; + const metadataOrgId = !isEphemeral && ownershipMetadataStore ? (await ownershipMetadataStore.getMetadata(agentId))?.organizationId : undefined; - - // For ephemeral agents, auto-provision settings from system-key - // providers (env-var-based API keys). No more template-agent fallback — - // there are no template/sandbox agents anymore. + const tokenOrganizationId = metadataOrgId ?? callerOrgId; + + // For ephemeral agents, create the `agents` row first so subsequent + // `saveSettings` (an UPDATE-only path) actually persists. Without this, + // the row never exists, the UPDATE matches 0 rows silently, and the + // worker's session-context resolves `installedProviders = []` → no + // provider → "No model configured". Followed by provisioning system- + // key providers (env-var-based API keys) so the worker has something + // to talk to. No more template-agent fallback — there are no + // template/sandbox agents anymore. if (isEphemeral && agentSettingsStore) { + if (agentMetadataStore?.createAgent) { + try { + await agentMetadataStore.createAgent( + agentId, + agentId, + "api", + agentId, + ); + } catch (err) { + // saveMetadata is INSERT ... ON CONFLICT DO UPDATE under the hood, + // so a re-create of the same id within the same org is benign. + // Genuine errors (FK / org mismatch) bubble below and surface as + // failed-create. + logger.debug( + `Ephemeral agent ${agentId}: createAgent threw (likely re-create): ${err}`, + ); + } + } + const providerModules = getModelProviderModules(); const systemProviders: InstalledProvider[] = providerModules .filter((m) => m.hasSystemKey()) diff --git a/packages/server/src/lobu/stores/postgres-stores.ts b/packages/server/src/lobu/stores/postgres-stores.ts index 5827af5a8..ccadf5297 100644 --- a/packages/server/src/lobu/stores/postgres-stores.ts +++ b/packages/server/src/lobu/stores/postgres-stores.ts @@ -215,7 +215,7 @@ export function createPostgresAgentConfigStore(): AgentConfigStore { const sql = getDb(); const orgId = getOrgId(); const now = new Date(); - await sql` + const result = await sql` UPDATE agents SET model = ${settings.model ?? null}, model_selection = ${sql.json(settings.modelSelection ?? {})}, @@ -237,6 +237,16 @@ export function createPostgresAgentConfigStore(): AgentConfigStore { updated_at = ${now} WHERE id = ${agentId} AND organization_id = ${orgId} `; + // UPDATE-only by design (agents row identity belongs to saveMetadata). + // Fail loud when no row matches so a save can't silently no-op — the + // ephemeral-chat path hit exactly this footgun (#1068 follow-up: agent + // row never existed → 0 rows updated → empty installedProviders → "No + // model configured"). Callers must call saveMetadata first. + if (result.count === 0) { + throw new Error( + `saveSettings: no agents row matches id=${agentId} org=${orgId}; call saveMetadata first` + ); + } }, async updateSettings(agentId, updates) { const existing = await store.getSettings(agentId);