diff --git a/packages/backend/src/create-backend-host.ts b/packages/backend/src/create-backend-host.ts index d7377f4..960de01 100644 --- a/packages/backend/src/create-backend-host.ts +++ b/packages/backend/src/create-backend-host.ts @@ -152,7 +152,10 @@ export function createBackendHost(options: CreateBackendHostOptions = {}): Backe if (!durableActor) throw new Error("Model offering actor is required"); const actor = await identity.resolveDurableActor(durableActor); if (!actor) throw new Error("Model offering is not available"); - return identity.resolveModelOfferingForUse(actor, offeringId); + return { + ...(await identity.resolveModelOfferingForUse(actor, offeringId)), + executionScope: "host" as const, + }; } : undefined, authorizeSkillManagement: identity @@ -215,7 +218,6 @@ export function createBackendHost(options: CreateBackendHostOptions = {}): Backe } c.set("actor", actor); const humanAdminOnly = - c.req.path.startsWith("/api/host/auth/") || c.req.path.startsWith("/api/host/mcp-connections") || (c.req.path === "/api/host/custom-instructions" && c.req.method !== "GET") || (c.req.path.startsWith("/api/host/skills") && diff --git a/packages/backend/src/host/bootstrap.ts b/packages/backend/src/host/bootstrap.ts index 72fbc42..a386d88 100644 --- a/packages/backend/src/host/bootstrap.ts +++ b/packages/backend/src/host/bootstrap.ts @@ -49,7 +49,7 @@ export async function createHostContext( resolveModelOffering?: ( offeringId: string, actor?: DurableActor, - ) => Promise<{ connectionId: string; modelId: string }>; + ) => Promise<{ connectionId: string; modelId: string; executionScope: "host" }>; homeDirectory?: string; skillSourceResolver?: SkillSourceResolver; authorizeSkillManagement?: (actor: DurableActor | undefined) => Promise; diff --git a/packages/backend/src/host/opengui-host.auth.test.ts b/packages/backend/src/host/opengui-host.auth.test.ts index 29e5dde..9c260bf 100644 --- a/packages/backend/src/host/opengui-host.auth.test.ts +++ b/packages/backend/src/host/opengui-host.auth.test.ts @@ -83,6 +83,42 @@ describe("OpenGuiHost authentication persistence", () => { await restarted.close(); } }); + test("refreshes the Codex catalog when only personal subscriptions exist", async () => { + const dataDirectory = await directory(); + const initial = new OpenGuiHost(dataDirectory, { + fetchImpl: vi.fn().mockRejectedValue("offline"), + }); + await initial.start(); + await initial.setCustomInstructions("initialize state"); + await initial.close(); + const statePath = join(dataDirectory, HOST_STATE_FILENAME); + const state = JSON.parse(await readFile(statePath, "utf8")); + state.secrets.personalCodexTokens = { + "member-1": { + connectionId: "personal-opaque", + tokens: { + accessToken: "access", + refreshToken: "refresh", + accountId: "account", + expiresAt: Date.now() + 3_600_000, + }, + }, + }; + await writeFile(statePath, JSON.stringify(state)); + const fetchImpl = vi.fn(async () => Response.json({})); + const host = new OpenGuiHost(dataDirectory, { fetchImpl: fetchImpl as typeof fetch }); + + await host.start(); + try { + expect(fetchImpl).toHaveBeenCalledWith( + "https://pi.dev/api/models/providers/openai-codex", + expect.any(Object), + ); + } finally { + await host.close(); + } + }); + test("keeps durable provider continuation state out of snapshots and events", async () => { const dataDirectory = await directory(); const host = new OpenGuiHost(dataDirectory, { diff --git a/packages/backend/src/host/opengui-host.ts b/packages/backend/src/host/opengui-host.ts index 7cecbad..460b622 100644 --- a/packages/backend/src/host/opengui-host.ts +++ b/packages/backend/src/host/opengui-host.ts @@ -207,10 +207,17 @@ interface HostSettingsFile { customInstructions: string; } +type PersonalCredential = { connectionId: string; tokens: T }; + type HostSecretsFile = { apiKeys: Record; codexTokens: CodexTokens | null; subscriptionTokens: Partial>; + personalCodexTokens: Record>; + personalSubscriptionTokens: Record< + string, + Partial>> + >; mcp: Record; bearerToken?: string }>; }; @@ -339,10 +346,14 @@ export class OpenGuiHost { #codexTokens: CodexTokens | null = null; #deviceAuth: DeviceAuthorization | null = null; #subscriptionTokens: Partial> = {}; + #personalCodexTokens: HostSecretsFile["personalCodexTokens"] = {}; + #personalDeviceAuth: Record = {}; + #personalSubscriptionTokens: HostSecretsFile["personalSubscriptionTokens"] = {}; #mcpSecrets: HostSecretsFile["mcp"] = {}; #subscriptionPending: Partial> = {}; - #codexRefresh: Promise | null = null; - #xaiRefresh: Promise | null = null; + #personalSubscriptionPending: Record>> = {}; + readonly #codexRefreshes = new Map>(); + readonly #subscriptionRefreshes = new Map>(); #stateStore: DurableJsonTransaction | null = null; readonly #listeners = new Set<(event: HostEvent) => void | Promise>(); readonly #activeRuns = new Map>(); @@ -365,7 +376,7 @@ export class OpenGuiHost { | (( offeringId: string, actor?: DurableActor, - ) => Promise<{ connectionId: string; modelId: string }>) + ) => Promise<{ connectionId: string; modelId: string; executionScope: "host" }>) | undefined; #starting: Promise | null = null; #closing: Promise | null = null; @@ -382,7 +393,7 @@ export class OpenGuiHost { resolveModelOffering?: ( offeringId: string, actor?: DurableActor, - ) => Promise<{ connectionId: string; modelId: string }>; + ) => Promise<{ connectionId: string; modelId: string; executionScope: "host" }>; homeDirectory?: string; skillSourceResolver?: SkillSourceResolver; authorizeSkillManagement?: (actor: DurableActor | undefined) => Promise; @@ -415,7 +426,8 @@ export class OpenGuiHost { resolve: async (request) => { const selection = request.context.findLast((item) => item.type === "user_message")?.model; if (!selection) throw new Error("Model request has no selected model"); - const connection = this.listModelConnections().find( + const credentialActor = request.executionScope === "host" ? undefined : request.actor; + const connection = this.listModelConnections(credentialActor).find( (item) => item.id === selection.connectionId, ); if (!connection) throw new Error(`Unknown model connection: ${selection.connectionId}`); @@ -424,8 +436,8 @@ export class OpenGuiHost { } const configuredRoute = connection.modelRoutes?.[selection.modelId]; const capabilities = connection.modelCapabilities?.[selection.modelId]; - if (connection.id === CODEX_CONNECTION.id) { - const credential = await this.#codexCredential(); + if (this.#isCodexConnection(connection.id, credentialActor)) { + const credential = await this.#codexCredential(false, credentialActor); return { backendId: connection.id, providerId: "openai-codex", @@ -644,14 +656,18 @@ export class OpenGuiHost { selection: ModelSelection, reasoning: string, actor?: DurableActor, + executionScope: "actor" | "host" = "actor", ) { if (reasoning === "none") return; - const routedSelection = + const resolved = selection.connectionId === MODEL_OFFERING_CONNECTION_ID ? await this.#resolveModelOffering?.(selection.modelId, actor) - : selection; + : undefined; + const routedSelection = resolved ?? selection; if (!routedSelection) throw new Error("Model offerings are not available"); - const connection = this.listModelConnections().find( + const credentialActor = + (resolved?.executionScope ?? executionScope) === "host" ? undefined : actor; + const connection = this.listModelConnections(credentialActor).find( (item) => item.id === routedSelection.connectionId, ); if (!connection) throw new Error(`Unknown model connection: ${routedSelection.connectionId}`); @@ -682,13 +698,13 @@ export class OpenGuiHost { ) { if (!this.#resolveModelOffering) throw new Error("Model offerings are not available"); const resolved = await this.#resolveModelOffering(selected.model.modelId, request.actor); - connectionId = resolved.connectionId; + const { executionScope, ...model } = resolved; + connectionId = model.connectionId; effectiveRequest = { ...request, + executionScope, context: request.context.map((item, index) => - index === selectedIndex && item.type === "user_message" - ? { ...item, model: resolved } - : item, + index === selectedIndex && item.type === "user_message" ? { ...item, model } : item, ), }; } @@ -697,9 +713,17 @@ export class OpenGuiHost { )?.model; const modelId = routedSelection?.modelId ?? "unknown"; if (selected?.type === "user_message" && routedSelection) { - await this.#assertReasoningSupported(routedSelection, selected.reasoning, request.actor); + await this.#assertReasoningSupported( + routedSelection, + selected.reasoning, + request.actor, + effectiveRequest.executionScope, + ); } - const connection = this.listModelConnections().find((item) => item.id === connectionId); + const credentialActor = effectiveRequest.executionScope === "host" ? undefined : request.actor; + const connection = this.listModelConnections(credentialActor).find( + (item) => item.id === connectionId, + ); const input = connection?.modelCapabilities?.[modelId]?.input; if (input && !input.includes("image")) { effectiveRequest = { @@ -707,10 +731,10 @@ export class OpenGuiHost { context: withoutModelContextImages(effectiveRequest.context), }; } + const isCodex = this.#isCodexConnection(connectionId, credentialActor); + const isXaiSubscription = this.#isXaiSubscriptionConnection(connectionId, credentialActor); const protocol: ModelProtocol = - connectionId === CODEX_CONNECTION.id || - connectionId === XAI_CONNECTION.id || - connectionId === XAI_API_CONNECTION.id + isCodex || isXaiSubscription || connectionId === XAI_API_CONNECTION.id ? "codex-responses" : connection?.modelRoutes?.[modelId] === "anthropic-messages" ? "anthropic-messages" @@ -722,16 +746,22 @@ export class OpenGuiHost { yield* this.#model.stream(effectiveRequest, deliverySignal); return; } - if (connectionId === CODEX_CONNECTION.id) { + if (isCodex) { if (this.#usePiAiCodexTransport) { yield* this.#streamCodexPi(effectiveRequest, deliverySignal); + } else if (credentialActor) { + yield* this.#codexTransportFor(credentialActor).stream(effectiveRequest, deliverySignal); } else { yield* this.#codexTransport.stream(effectiveRequest, deliverySignal); } return; } - if (connectionId === XAI_CONNECTION.id) { - yield* this.#xaiTransport.stream(effectiveRequest, deliverySignal); + if (isXaiSubscription) { + if (credentialActor) { + yield* this.#xaiTransportFor(credentialActor).stream(effectiveRequest, deliverySignal); + } else { + yield* this.#xaiTransport.stream(effectiveRequest, deliverySignal); + } return; } if (connectionId === XAI_API_CONNECTION.id) { @@ -780,6 +810,28 @@ export class OpenGuiHost { } } + #codexTransportFor(actor: DurableActor) { + return new CodexResponsesTransport({ + fetchImpl: this.#fetch, + getCredential: (forceRefresh) => this.#codexCredential(forceRefresh, actor), + }); + } + + #xaiTransportFor(actor: DurableActor) { + return new CodexResponsesTransport({ + fetchImpl: this.#fetch, + endpoint: "https://cli-chat-proxy.grok.com/v1/responses", + headers: { + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-identifier": "opengui", + }, + requestLabel: "SuperGrok experimental subscription proxy", + unauthorizedMessage: + "SuperGrok proxy authorization expired. Reconnect the experimental third-party OAuth authorization in Providers.", + getCredential: (forceRefresh) => this.#subscriptionCredential("xai", forceRefresh, actor), + }); + } + async *#streamCodexPi(request: ModelRequest, signal: AbortSignal) { let attempts = 0; while (attempts < 2) { @@ -820,7 +872,10 @@ export class OpenGuiHost { } // A provider 401 occurs before response output. Refresh exactly once; // never replay after any model delta or tool argument has escaped. - await this.#codexCredential(true); + await this.#codexCredential( + true, + request.executionScope === "host" ? undefined : request.actor, + ); } } } @@ -956,9 +1011,23 @@ export class OpenGuiHost { ? (parsed.subscriptions as Record) : {}; const xai = validOAuthTokens(subscriptions.xai); - return { apiKeys, codexTokens, subscriptionTokens: xai ? { xai } : {}, mcp: {} }; + return { + apiKeys, + codexTokens, + subscriptionTokens: xai ? { xai } : {}, + personalCodexTokens: {}, + personalSubscriptionTokens: {}, + mcp: {}, + }; } catch { - return { apiKeys: {}, codexTokens: null, subscriptionTokens: {}, mcp: {} }; + return { + apiKeys: {}, + codexTokens: null, + subscriptionTokens: {}, + personalCodexTokens: {}, + personalSubscriptionTokens: {}, + mcp: {}, + }; } } @@ -1008,6 +1077,37 @@ export class OpenGuiHost { subscriptionTokens: validOAuthTokens(subscriptions.xai) ? { xai: validOAuthTokens(subscriptions.xai)! } : {}, + personalCodexTokens: + state.secrets.personalCodexTokens && typeof state.secrets.personalCodexTokens === "object" + ? Object.fromEntries( + Object.entries(state.secrets.personalCodexTokens).flatMap(([actorId, value]) => { + if (!value || typeof value !== "object") return []; + const credential = value as Record; + const valid = validCodexTokens(credential.tokens); + return typeof credential.connectionId === "string" && valid + ? [[actorId, { connectionId: credential.connectionId, tokens: valid }]] + : []; + }), + ) + : {}, + personalSubscriptionTokens: + state.secrets.personalSubscriptionTokens && + typeof state.secrets.personalSubscriptionTokens === "object" + ? Object.fromEntries( + Object.entries(state.secrets.personalSubscriptionTokens).flatMap( + ([actorId, providers]) => { + if (!providers || typeof providers !== "object") return []; + const xaiValue = (providers as Record).xai; + if (!xaiValue || typeof xaiValue !== "object") return []; + const credential = xaiValue as Record; + const tokens = validOAuthTokens(credential.tokens); + return typeof credential.connectionId === "string" && tokens + ? [[actorId, { xai: { connectionId: credential.connectionId, tokens } }]] + : []; + }, + ), + ) + : {}, mcp: state.secrets.mcp && typeof state.secrets.mcp === "object" ? structuredClone(state.secrets.mcp) @@ -1021,6 +1121,8 @@ export class OpenGuiHost { this.#apiKeys = { ...state.secrets.apiKeys }; this.#codexTokens = state.secrets.codexTokens ? { ...state.secrets.codexTokens } : null; this.#subscriptionTokens = structuredClone(state.secrets.subscriptionTokens); + this.#personalCodexTokens = structuredClone(state.secrets.personalCodexTokens); + this.#personalSubscriptionTokens = structuredClone(state.secrets.personalSubscriptionTokens); this.#mcpSecrets = structuredClone(state.secrets.mcp); this.#refreshTransport(); } @@ -1047,6 +1149,8 @@ export class OpenGuiHost { apiKeys: { ...this.#apiKeys }, codexTokens: this.#codexTokens ? { ...this.#codexTokens } : null, subscriptionTokens: structuredClone(this.#subscriptionTokens), + personalCodexTokens: structuredClone(this.#personalCodexTokens), + personalSubscriptionTokens: structuredClone(this.#personalSubscriptionTokens), mcp: structuredClone(this.#mcpSecrets), }; await this.#updateState((state) => ({ ...state, secrets })); @@ -1103,7 +1207,9 @@ export class OpenGuiHost { async refreshModelCatalogs(force = false) { await Promise.all([ - ...(this.#codexTokens ? [this.#catalog.refresh("openai-codex", force)] : []), + ...(this.#codexTokens || Object.keys(this.#personalCodexTokens).length > 0 + ? [this.#catalog.refresh("openai-codex", force)] + : []), ...this.#settings.modelConnections.flatMap((connection) => connection.id === OPENCODE_GO_PRESET.id ? [this.#catalog.refresh("opencode-go", force)] @@ -1334,12 +1440,26 @@ export class OpenGuiHost { })); } - listModelConnections() { + listModelConnections(actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + const personalCodex = actorId ? this.#personalCodexTokens[actorId] : undefined; + const personalXai = actorId ? this.#personalSubscriptionTokens[actorId]?.xai : undefined; return [ - ...(this.#codexTokens - ? [this.#catalog.connection("openai-codex", CHATGPT_CODEX_PRESET)] - : []), - ...(this.#subscriptionTokens.xai ? [XAI_CONNECTION] : []), + ...(personalCodex + ? [ + this.#catalog.connection("openai-codex", { + ...CHATGPT_CODEX_PRESET, + id: personalCodex.connectionId, + }), + ] + : this.#codexTokens + ? [this.#catalog.connection("openai-codex", CHATGPT_CODEX_PRESET)] + : []), + ...(personalXai + ? [{ ...XAI_CONNECTION, id: personalXai.connectionId }] + : this.#subscriptionTokens.xai + ? [XAI_CONNECTION] + : []), ...this.#settings.modelConnections .filter( (connection) => @@ -1358,87 +1478,153 @@ export class OpenGuiHost { ]; } - codexAuthStatus() { + #personalActorId(actor?: DurableActor) { + return actor?.type === "user" ? actor.id : null; + } + + #isCodexConnection(connectionId: string, actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + return actorId + ? this.#personalCodexTokens[actorId]?.connectionId === connectionId + : connectionId === CODEX_CONNECTION.id; + } + + #isXaiSubscriptionConnection(connectionId: string, actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + return actorId + ? this.#personalSubscriptionTokens[actorId]?.xai?.connectionId === connectionId + : connectionId === XAI_CONNECTION.id; + } + + personalSubscriptionConnection(provider: "codex" | "xai", actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + if (!actorId) return undefined; + const connectionId = + provider === "codex" + ? this.#personalCodexTokens[actorId]?.connectionId + : this.#personalSubscriptionTokens[actorId]?.xai?.connectionId; + return connectionId + ? this.listModelConnections(actor).find((connection) => connection.id === connectionId) + : undefined; + } + + codexAuthStatus(actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + const pending = actorId ? this.#personalDeviceAuth[actorId] : this.#deviceAuth; return { - connected: Boolean(this.#codexTokens), - pending: this.#deviceAuth + connected: Boolean(actorId ? this.#personalCodexTokens[actorId] : this.#codexTokens), + pending: pending ? { - userCode: this.#deviceAuth.userCode, - verificationUri: this.#deviceAuth.verificationUri, - expiresAt: this.#deviceAuth.expiresAt, + userCode: pending.userCode, + verificationUri: pending.verificationUri, + expiresAt: pending.expiresAt, } : null, }; } - async beginCodexAuth() { - this.#deviceAuth = await beginCodexDeviceAuth(); - return this.codexAuthStatus(); + async beginCodexAuth(actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + const pending = await beginCodexDeviceAuth(); + if (actorId) this.#personalDeviceAuth[actorId] = pending; + else this.#deviceAuth = pending; + return this.codexAuthStatus(actor); } - async pollCodexAuth() { - const pending = this.#deviceAuth; + async pollCodexAuth(actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + const pending = actorId ? this.#personalDeviceAuth[actorId] : this.#deviceAuth; if (!pending) throw new Error("No ChatGPT sign-in is pending"); if (Date.now() >= pending.expiresAt) { - this.#deviceAuth = null; + if (actorId) delete this.#personalDeviceAuth[actorId]; + else this.#deviceAuth = null; throw new Error("The device code expired. Start sign-in again."); } const result = await pollCodexDeviceAuth(pending); - if (result && this.#deviceAuth === pending) { - this.#codexTokens = result; - this.#deviceAuth = null; + const current = actorId ? this.#personalDeviceAuth[actorId] : this.#deviceAuth; + if (result && current === pending) { + if (actorId) { + this.#personalCodexTokens[actorId] = { + connectionId: `personal-${randomUUID()}`, + tokens: result, + }; + delete this.#personalDeviceAuth[actorId]; + } else { + this.#codexTokens = result; + this.#deviceAuth = null; + } await this.#saveSecrets(); await this.refreshModelCatalogs(); } - return this.codexAuthStatus(); - } - async cancelCodexAuth() { - this.#deviceAuth = null; - return this.codexAuthStatus(); - } - async disconnectCodex() { - const tokens = this.#codexTokens; - this.#codexTokens = null; - this.#deviceAuth = null; + return this.codexAuthStatus(actor); + } + async cancelCodexAuth(actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + if (actorId) delete this.#personalDeviceAuth[actorId]; + else this.#deviceAuth = null; + return this.codexAuthStatus(actor); + } + async disconnectCodex(actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + const tokens = actorId ? this.#personalCodexTokens[actorId]?.tokens : this.#codexTokens; + if (actorId) { + delete this.#personalCodexTokens[actorId]; + delete this.#personalDeviceAuth[actorId]; + } else { + this.#codexTokens = null; + this.#deviceAuth = null; + } await this.#saveSecrets(); this.#piAiTransport.close(); if (tokens) await revokeCodexToken(tokens.refreshToken); } - async #codexCredential(forceRefresh = false) { - if (!this.#codexTokens) throw new Error("Sign in to ChatGPT in Providers before using Codex"); - if (forceRefresh || this.#codexTokens.expiresAt <= Date.now() + 60_000) { - const current = this.#codexTokens; - try { - this.#codexRefresh ??= refreshCodexTokens(current).finally(() => { - this.#codexRefresh = null; + async #codexCredential(forceRefresh = false, actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + let tokens = actorId ? this.#personalCodexTokens[actorId]?.tokens : this.#codexTokens; + if (!tokens) throw new Error("Sign in to ChatGPT in Providers before using Codex"); + if (forceRefresh || tokens.expiresAt <= Date.now() + 60_000) { + const refreshKey = actorId ?? "global"; + let refresh = this.#codexRefreshes.get(refreshKey); + if (!refresh) { + const current = tokens; + refresh = (async () => { + try { + const refreshed = await refreshCodexTokens(current); + const latest = actorId ? this.#personalCodexTokens[actorId]?.tokens : this.#codexTokens; + if (latest !== current) throw new Error("ChatGPT sign-in changed"); + if (actorId) this.#personalCodexTokens[actorId]!.tokens = refreshed; + else this.#codexTokens = refreshed; + await this.#saveSecrets(); + return refreshed; + } catch { + const latest = actorId ? this.#personalCodexTokens[actorId]?.tokens : this.#codexTokens; + if (latest === current) { + if (actorId) delete this.#personalCodexTokens[actorId]; + else this.#codexTokens = null; + await this.#saveSecrets(); + } + throw new Error("ChatGPT sign-in expired or was revoked. Sign in again in Providers."); + } + })(); + this.#codexRefreshes.set(refreshKey, refresh); + void refresh.finally(() => { + if (this.#codexRefreshes.get(refreshKey) === refresh) + this.#codexRefreshes.delete(refreshKey); }); - const refreshed = await this.#codexRefresh; - if (this.#codexTokens !== current) { - if ( - this.#codexTokens?.accessToken === refreshed.accessToken && - this.#codexTokens.refreshToken === refreshed.refreshToken - ) - return { - accessToken: this.#codexTokens.accessToken, - accountId: this.#codexTokens.accountId, - }; - throw new Error("ChatGPT sign-in changed"); - } - this.#codexTokens = refreshed; - await this.#saveSecrets(); - } catch { - if (this.#codexTokens === current) { - this.#codexTokens = null; - await this.#saveSecrets(); - } - throw new Error("ChatGPT sign-in expired or was revoked. Sign in again in Providers."); } + tokens = await refresh; } - return { accessToken: this.#codexTokens.accessToken, accountId: this.#codexTokens.accountId }; + return { accessToken: tokens.accessToken, accountId: tokens.accountId }; } - subscriptionAuthStatus(provider: "xai") { - const pending = this.#subscriptionPending[provider]; + subscriptionAuthStatus(provider: "xai", actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + const pending = actorId + ? this.#personalSubscriptionPending[actorId]?.[provider] + : this.#subscriptionPending[provider]; + const tokens = actorId + ? this.#personalSubscriptionTokens[actorId]?.[provider] + : this.#subscriptionTokens[provider]; return { - connected: Boolean(this.#subscriptionTokens[provider]), + connected: Boolean(tokens), pending: pending ? { userCode: pending.userCode, @@ -1448,72 +1634,108 @@ export class OpenGuiHost { : null, }; } - async beginSubscriptionAuth(provider: "xai") { - this.#subscriptionPending[provider] = await beginDeviceOAuth({ - ...XAI_OAUTH, - fetchImpl: this.#fetch, - }); - return this.subscriptionAuthStatus(provider); - } - async pollSubscriptionAuth(provider: "xai") { - const pending = this.#subscriptionPending[provider]; + async beginSubscriptionAuth(provider: "xai", actor?: DurableActor) { + const pending = await beginDeviceOAuth({ ...XAI_OAUTH, fetchImpl: this.#fetch }); + const actorId = this.#personalActorId(actor); + if (actorId) (this.#personalSubscriptionPending[actorId] ??= {})[provider] = pending; + else this.#subscriptionPending[provider] = pending; + return this.subscriptionAuthStatus(provider, actor); + } + async pollSubscriptionAuth(provider: "xai", actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + const pending = actorId + ? this.#personalSubscriptionPending[actorId]?.[provider] + : this.#subscriptionPending[provider]; if (!pending) throw new Error("No sign-in is pending"); if (pending.expiresAt <= Date.now()) { - delete this.#subscriptionPending[provider]; + if (actorId) delete this.#personalSubscriptionPending[actorId]?.[provider]; + else delete this.#subscriptionPending[provider]; throw new Error("The device code expired. Start sign-in again."); } const result = await pollDeviceOAuth({ ...XAI_OAUTH, fetchImpl: this.#fetch }, pending); - if (result && this.#subscriptionPending[provider] === pending) { - this.#subscriptionTokens[provider] = result; - delete this.#subscriptionPending[provider]; + const current = actorId + ? this.#personalSubscriptionPending[actorId]?.[provider] + : this.#subscriptionPending[provider]; + if (result && current === pending) { + if (actorId) { + (this.#personalSubscriptionTokens[actorId] ??= {})[provider] = { + connectionId: `personal-${randomUUID()}`, + tokens: result, + }; + delete this.#personalSubscriptionPending[actorId]?.[provider]; + } else { + this.#subscriptionTokens[provider] = result; + delete this.#subscriptionPending[provider]; + } this.#refreshTransport(); await this.#saveSecrets(); } - return this.subscriptionAuthStatus(provider); - } - async cancelSubscriptionAuth(provider: "xai") { - delete this.#subscriptionPending[provider]; - return this.subscriptionAuthStatus(provider); - } - async disconnectSubscription(provider: "xai") { - delete this.#subscriptionTokens[provider]; - delete this.#subscriptionPending[provider]; + return this.subscriptionAuthStatus(provider, actor); + } + async cancelSubscriptionAuth(provider: "xai", actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + if (actorId) delete this.#personalSubscriptionPending[actorId]?.[provider]; + else delete this.#subscriptionPending[provider]; + return this.subscriptionAuthStatus(provider, actor); + } + async disconnectSubscription(provider: "xai", actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + if (actorId) { + delete this.#personalSubscriptionTokens[actorId]?.[provider]; + delete this.#personalSubscriptionPending[actorId]?.[provider]; + } else { + delete this.#subscriptionTokens[provider]; + delete this.#subscriptionPending[provider]; + } this.#refreshTransport(); await this.#saveSecrets(); } - async #subscriptionCredential(provider: "xai", forceRefresh = false) { - let current = this.#subscriptionTokens[provider]; + async #subscriptionCredential(provider: "xai", forceRefresh = false, actor?: DurableActor) { + const actorId = this.#personalActorId(actor); + let current = actorId + ? this.#personalSubscriptionTokens[actorId]?.[provider]?.tokens + : this.#subscriptionTokens[provider]; if (!current) throw new Error("Sign in to this provider in Settings before using it"); if (forceRefresh || current.expiresAt <= Date.now() + 60_000) { - const expected = current; - try { - this.#xaiRefresh ??= refreshDeviceOAuth( - { ...XAI_OAUTH, fetchImpl: this.#fetch }, - current, - ).finally(() => { - this.#xaiRefresh = null; + const refreshKey = `${provider}:${actorId ?? "global"}`; + let refresh = this.#subscriptionRefreshes.get(refreshKey); + if (!refresh) { + const expected = current; + refresh = (async () => { + try { + const refreshed = await refreshDeviceOAuth( + { ...XAI_OAUTH, fetchImpl: this.#fetch }, + expected, + ); + const latest = actorId + ? this.#personalSubscriptionTokens[actorId]?.[provider]?.tokens + : this.#subscriptionTokens[provider]; + if (latest !== expected) throw new Error("Provider sign-in changed"); + if (actorId) this.#personalSubscriptionTokens[actorId]![provider]!.tokens = refreshed; + else this.#subscriptionTokens[provider] = refreshed; + this.#refreshTransport(); + await this.#saveSecrets(); + return refreshed; + } catch { + const latest = actorId + ? this.#personalSubscriptionTokens[actorId]?.[provider]?.tokens + : this.#subscriptionTokens[provider]; + if (latest === expected) { + if (actorId) delete this.#personalSubscriptionTokens[actorId]?.[provider]; + else delete this.#subscriptionTokens[provider]; + this.#refreshTransport(); + await this.#saveSecrets(); + } + throw new Error("Provider sign-in expired or was revoked. Sign in again in Settings."); + } + })(); + this.#subscriptionRefreshes.set(refreshKey, refresh); + void refresh.finally(() => { + if (this.#subscriptionRefreshes.get(refreshKey) === refresh) + this.#subscriptionRefreshes.delete(refreshKey); }); - current = await this.#xaiRefresh; - const latest = this.#subscriptionTokens[provider]; - if (latest !== expected) { - if ( - latest?.accessToken === current.accessToken && - latest.refreshToken === current.refreshToken - ) - return { accessToken: latest.accessToken, accountId: "" }; - throw new Error("Provider sign-in changed"); - } - this.#subscriptionTokens[provider] = current; - this.#refreshTransport(); - await this.#saveSecrets(); - } catch { - if (this.#subscriptionTokens[provider] === expected) { - delete this.#subscriptionTokens[provider]; - this.#refreshTransport(); - await this.#saveSecrets(); - } - throw new Error("Provider sign-in expired or was revoked. Sign in again in Settings."); } + current = await refresh; } return { accessToken: current.accessToken, accountId: "" }; } @@ -2049,7 +2271,7 @@ export async function createOpenGuiHost( resolveModelOffering?: ( offeringId: string, actor?: DurableActor, - ) => Promise<{ connectionId: string; modelId: string }>; + ) => Promise<{ connectionId: string; modelId: string; executionScope: "host" }>; usePiAiTransport?: boolean; usePiAiCodexTransport?: boolean; codexPiTransport?: "auto" | "websocket" | "websocket-cached" | "sse"; diff --git a/packages/backend/src/identity/identity.ts b/packages/backend/src/identity/identity.ts index 2c2970b..0715b08 100644 --- a/packages/backend/src/identity/identity.ts +++ b/packages/backend/src/identity/identity.ts @@ -2261,6 +2261,18 @@ export class IdentityService { ); } + async authorizePersonalSubscription(actor: Actor) { + await this.ready; + if (actor.type !== "user") return; + if (!this.modelCredentialAllowed(actor, "byos")) { + throw new IdentityError( + "MODEL_CREDENTIAL_POLICY_DENIED", + 403, + "Personal subscriptions are disabled by Host policy", + ); + } + } + private modelCredentialAllowed(actor: Actor, kind: ModelCredentialKind) { if (!this.configFlag(kind === "byok" ? "allow_byok" : "allow_byos")) return false; const policy = this.teams.policy(actor); diff --git a/packages/backend/src/identity/personal-subscriptions.integration.test.ts b/packages/backend/src/identity/personal-subscriptions.integration.test.ts new file mode 100644 index 0000000..3c24a4f --- /dev/null +++ b/packages/backend/src/identity/personal-subscriptions.integration.test.ts @@ -0,0 +1,431 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { createBackendHost, type BackendHost } from "../create-backend-host.ts"; +import type { Actor } from "./types.ts"; + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + vi.unstubAllGlobals(); + while (cleanups.length) await cleanups.pop()!(); +}); + +function headers(token: string, json = false) { + return { + authorization: `Bearer ${token}`, + ...(json ? { "content-type": "application/json" } : {}), + }; +} + +async function value(response: Response) { + const body = (await response.json()) as { value: T }; + return body.value; +} + +async function setupOwner(backend: BackendHost) { + return value<{ token: string; actor: Actor }>( + await backend.app.request("http://localhost/api/identity/setup", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username: "owner", + email: "owner@example.com", + password: "owner password is sufficiently long", + }), + }), + ); +} + +async function register(backend: BackendHost, ownerToken: string, username: string) { + await backend.app.request("http://localhost/api/identity/host-policy", { + method: "PUT", + headers: headers(ownerToken, true), + body: JSON.stringify({ registrationMode: "open" }), + }); + return value<{ token: string; actor: Actor }>( + await backend.app.request("http://localhost/api/identity/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username, + email: `${username}@example.com`, + password: `${username} password is sufficiently long`, + }), + }), + ); +} + +describe("personal provider subscriptions", () => { + test("keeps two members' xAI subscriptions independently visible and disconnectable", async () => { + const root = await mkdtemp(join(tmpdir(), "opengui-personal-subscriptions-")); + const project = join(root, "project"); + await mkdir(project); + let device = 0; + const providerAuthorizations: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = + input instanceof Request ? input.url : input instanceof URL ? input.href : input; + if (url === "https://auth.x.ai/oauth2/device/code") { + device += 1; + return Response.json({ + device_code: `device-${device}`, + user_code: `code-${device}`, + verification_uri: "https://x.ai/device", + expires_in: 900, + interval: 1, + }); + } + if (url === "https://auth.x.ai/oauth2/token") { + const body = init?.body instanceof URLSearchParams ? init.body.toString() : ""; + const match = body.match(/device_code=device-(\d+)/); + const suffix = match?.[1] ?? "unknown"; + return Response.json({ + access_token: `access-${suffix}`, + refresh_token: `refresh-${suffix}`, + expires_in: 3600, + }); + } + if (url.startsWith("https://pi.dev/api/models/providers/")) { + return new Response("not modified", { status: 304 }); + } + if (url === "https://cli-chat-proxy.grok.com/v1/responses") { + const requestHeaders = new Headers(init?.headers); + providerAuthorizations.push(requestHeaders.get("authorization") ?? ""); + return new Response('data: {"type":"response.completed","response":{"output":[]}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + const backend = createBackendHost({ + dataDirectory: join(root, "data"), + env: { + port: 0, + hostname: "127.0.0.1", + isProduction: true, + serverMode: "api-only", + servesFrontend: false, + authToken: "", + allowedCorsOrigin: "*", + allowedRoots: [root], + uploadMaxFileBytes: 1024, + uploadMaxBatchBytes: 2048, + identityMode: "remote", + pathGrantsMode: "enforced", + }, + identityDatabase: new DatabaseSync(":memory:"), + identitySecret: "personal-subscription-secret-at-least-32-characters", + identityBaseURL: "http://localhost", + }); + await backend.ready; + cleanups.push(async () => { + await (await backend.hostReady).close(); + backend.identity!.database.close(); + await rm(root, { recursive: true, force: true }); + }); + + const owner = await setupOwner(backend); + const alice = await register(backend, owner.token, "alice"); + const bob = await register(backend, owner.token, "bob"); + await backend.app.request("http://localhost/api/identity/model-policy", { + method: "PUT", + headers: headers(owner.token, true), + body: JSON.stringify({ + host: { allowByok: true, allowByos: true }, + team: { allowByok: true, allowByos: true }, + }), + }); + + for (const member of [alice, bob]) { + expect( + ( + await backend.app.request("http://localhost/api/host/auth/xai", { + method: "POST", + headers: headers(member.token), + }) + ).status, + ).toBe(200); + expect( + ( + await backend.app.request("http://localhost/api/host/auth/xai/poll", { + method: "POST", + headers: headers(member.token), + }) + ).status, + ).toBe(200); + } + + const modelsFor = async (token: string) => + value>( + await backend.app.request("http://localhost/api/host/models", { + headers: headers(token), + }), + ); + const [aliceModels, bobModels] = await Promise.all([ + modelsFor(alice.token), + modelsFor(bob.token), + ]); + expect(aliceModels).toHaveLength(1); + expect(bobModels).toHaveLength(1); + expect(aliceModels[0]).toMatchObject({ plane: "user" }); + expect(bobModels[0]).toMatchObject({ plane: "user" }); + expect(aliceModels[0]!.id).not.toBe(bobModels[0]!.id); + expect(aliceModels[0]!.id).not.toContain(alice.actor.id); + expect(bobModels[0]!.id).not.toContain(bob.actor.id); + expect(aliceModels[0]).not.toHaveProperty("ownerId"); + expect(bobModels[0]).not.toHaveProperty("ownerId"); + + for (const [member, model] of [ + [alice, aliceModels[0]!], + [bob, bobModels[0]!], + ] as const) { + await backend.app.request(`/api/identity/members/${member.actor.id}/path-grants`, { + method: "PUT", + headers: headers(owner.token, true), + body: JSON.stringify({ grants: [{ root: project, access: "write" }] }), + }); + const sessionResponse = await backend.app.request("/api/host/sessions", { + method: "POST", + headers: headers(member.token, true), + body: JSON.stringify({ + directory: project, + model: { connectionId: model.id, modelId: "grok-code-fast-1" }, + reasoning: "none", + }), + }); + expect(sessionResponse.status).toBe(200); + const session = await value<{ id: string }>(sessionResponse); + expect( + ( + await backend.app.request(`/api/host/sessions/${session.id}/prompt`, { + method: "POST", + headers: headers(member.token, true), + body: JSON.stringify({ text: `run as ${member.actor.id}` }), + }) + ).status, + ).toBe(200); + await (await backend.hostReady).waitForIdle(session.id, member.actor); + } + expect(providerAuthorizations).toEqual(["Bearer access-1", "Bearer access-2"]); + + const crossUserSession = await backend.app.request("/api/host/sessions", { + method: "POST", + headers: headers(bob.token, true), + body: JSON.stringify({ + directory: project, + model: { connectionId: aliceModels[0]!.id, modelId: "grok-code-fast-1" }, + reasoning: "none", + }), + }); + expect(crossUserSession.status).toBe(403); + expect(providerAuthorizations).toHaveLength(2); + + expect( + ( + await backend.app.request("http://localhost/api/host/auth/xai", { + method: "DELETE", + headers: headers(alice.token), + }) + ).status, + ).toBe(200); + expect(await modelsFor(alice.token)).toEqual([]); + expect((await modelsFor(bob.token)).map((model) => model.id)).toEqual([bobModels[0]!.id]); + expect( + (await backend.identity!.listModelConnectionAccess(alice.actor)).map( + (connection) => connection.id, + ), + ).not.toContain(aliceModels[0]!.id); + expect( + (await backend.identity!.listModelConnectionAccess(bob.actor)).map( + (connection) => connection.id, + ), + ).toContain(bobModels[0]!.id); + }); + + test("uses the Host Codex credential for an entitled offering when the member also has personal Codex", async () => { + const root = await mkdtemp(join(tmpdir(), "opengui-shared-and-personal-codex-")); + const project = join(root, "project"); + const dataDirectory = join(root, "data"); + await mkdir(project); + await mkdir(dataDirectory); + const jwt = (accountId: string, label: string) => { + const payload = Buffer.from( + JSON.stringify({ label, "https://api.openai.com/auth": { chatgpt_account_id: accountId } }), + ).toString("base64url"); + return `header.${payload}.signature`; + }; + await writeFile( + join(dataDirectory, "opengui-host-secrets.json"), + JSON.stringify({ + codex: { + accessToken: jwt("host-account", "host-access"), + refreshToken: "host-refresh", + expiresAt: Date.now() + 3_600_000, + accountId: "host-account", + }, + }), + { mode: 0o600 }, + ); + const providerCredentials: Array<{ authorization: string; accountId: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = + input instanceof Request ? input.url : input instanceof URL ? input.href : input; + if (url.startsWith("https://pi.dev/api/models/providers/")) { + return new Response("not modified", { status: 304 }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + return Response.json({ + device_auth_id: "member-device", + user_code: "member-code", + verification_uri: "https://auth.openai.com/codex/device", + expires_in: 900, + interval: 1, + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + return Response.json({ authorization_code: "member-auth", code_verifier: "verifier" }); + } + if (url === "https://auth.openai.com/oauth/token") { + return Response.json({ + id_token: jwt("member-account", "member-id"), + access_token: jwt("member-account", "member-access"), + refresh_token: "member-refresh", + expires_in: 3600, + }); + } + if (url === "https://chatgpt.com/backend-api/codex/responses") { + const requestHeaders = new Headers(init?.headers); + providerCredentials.push({ + authorization: requestHeaders.get("authorization") ?? "", + accountId: requestHeaders.get("chatgpt-account-id") ?? "", + }); + return new Response( + 'data: {"type":"response.completed","response":{"id":"response","status":"completed","output":[]}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + const backend = createBackendHost({ + dataDirectory, + env: { + port: 0, + hostname: "127.0.0.1", + isProduction: true, + serverMode: "api-only", + servesFrontend: false, + authToken: "", + allowedCorsOrigin: "*", + allowedRoots: [root], + uploadMaxFileBytes: 1024, + uploadMaxBatchBytes: 2048, + identityMode: "remote", + pathGrantsMode: "enforced", + }, + identityDatabase: new DatabaseSync(":memory:"), + identitySecret: "mixed-codex-credential-secret-at-least-32-characters", + identityBaseURL: "http://localhost", + }); + await backend.ready; + cleanups.push(async () => { + await (await backend.hostReady).close(); + backend.identity!.database.close(); + await rm(root, { recursive: true, force: true }); + }); + + const owner = await setupOwner(backend); + const member = await register(backend, owner.token, "member_with_codex"); + await backend.app.request("/api/identity/model-policy", { + method: "PUT", + headers: headers(owner.token, true), + body: JSON.stringify({ + host: { allowByok: true, allowByos: true }, + team: { allowByok: true, allowByos: true }, + }), + }); + await backend.app.request(`/api/identity/members/${member.actor.id}/path-grants`, { + method: "PUT", + headers: headers(owner.token, true), + body: JSON.stringify({ grants: [{ root: project, access: "write" }] }), + }); + expect( + ( + await backend.app.request("/api/host/auth/codex", { + method: "POST", + headers: headers(member.token), + }) + ).status, + ).toBe(200); + expect( + ( + await backend.app.request("/api/host/auth/codex/poll", { + method: "POST", + headers: headers(member.token), + }) + ).status, + ).toBe(200); + const memberModels = await value>( + await backend.app.request("/api/host/models", { headers: headers(member.token) }), + ); + const personalConnectionId = memberModels[0]!.id; + + await backend.app.request("/api/identity/model-offerings", { + method: "POST", + headers: headers(owner.token, true), + body: JSON.stringify({ + id: "gpt-5-4", + displayName: "gpt-5.4", + backendId: "chatgpt-codex", + upstreamModelId: "gpt-5.4", + }), + }); + await backend.app.request("/api/identity/model-offerings/gpt-5-4/entitlements", { + method: "PUT", + headers: headers(owner.token, true), + body: JSON.stringify({ entitlements: [{ subjectType: "user", subjectId: member.actor.id }] }), + }); + + const run = async (connectionId: string, modelId: string, text: string) => { + const response = await backend.app.request("/api/host/sessions", { + method: "POST", + headers: headers(member.token, true), + body: JSON.stringify({ + directory: project, + model: { connectionId, modelId }, + reasoning: "none", + }), + }); + expect(response.status).toBe(200); + const session = await value<{ id: string }>(response); + expect( + ( + await backend.app.request(`/api/host/sessions/${session.id}/prompt`, { + method: "POST", + headers: headers(member.token, true), + body: JSON.stringify({ text }), + }) + ).status, + ).toBe(200); + await (await backend.hostReady).waitForIdle(session.id, member.actor); + }; + + await run("opengui-offering", "gpt-5-4", "use the shared offering"); + await run(personalConnectionId, "gpt-5.4", "use my personal connection"); + expect(providerCredentials).toEqual([ + { authorization: `Bearer ${jwt("host-account", "host-access")}`, accountId: "host-account" }, + { + authorization: `Bearer ${jwt("member-account", "member-access")}`, + accountId: "member-account", + }, + ]); + }); +}); diff --git a/packages/backend/src/routes/host-product.actor.test.ts b/packages/backend/src/routes/host-product.actor.test.ts index 69f9bc7..4ab1d38 100644 --- a/packages/backend/src/routes/host-product.actor.test.ts +++ b/packages/backend/src/routes/host-product.actor.test.ts @@ -5,6 +5,7 @@ import type { OpenGuiHost } from "../host/opengui-host.ts"; import { HostSessionNotFoundError } from "../host/opengui-host.ts"; import type { BackendRequestEnv } from "../http/request-context.ts"; import type { Actor } from "../identity/types.ts"; +import { IdentityError, type IdentityService } from "../identity/identity.ts"; import { registerHostProductRoutes } from "./host-product.ts"; describe("Host product actor attribution", () => { @@ -353,3 +354,185 @@ describe("Host product actor attribution", () => { }); }); }); + +describe("personal subscription authorization", () => { + const member: Actor = { + type: "user", + id: "member-1", + displayName: "Member", + role: "member", + }; + + function subscriptionApp(input: { + actor?: Actor; + authorize?: () => Promise; + status?: ReturnType; + begin?: ReturnType; + disconnect?: ReturnType; + poll?: ReturnType; + personalConnection?: ReturnType; + resolveActor?: ReturnType; + recordConnection?: ReturnType; + subscriptionBegin?: ReturnType; + subscriptionPoll?: ReturnType; + }) { + const actor = input.actor ?? member; + const status = input.status ?? vi.fn(() => ({ connected: false, pending: null })); + const begin = input.begin ?? vi.fn(async () => ({ connected: false, pending: null })); + const disconnect = input.disconnect ?? vi.fn(async () => undefined); + const poll = input.poll ?? vi.fn(async () => ({ connected: false, pending: null })); + const personalConnection = input.personalConnection ?? vi.fn(() => undefined); + const resolveActor = input.resolveActor ?? vi.fn(async () => ({ id: actor.id })); + const recordConnection = input.recordConnection ?? vi.fn(async () => undefined); + const subscriptionBegin = + input.subscriptionBegin ?? vi.fn(async () => ({ connected: false, pending: null })); + const subscriptionPoll = + input.subscriptionPoll ?? vi.fn(async () => ({ connected: false, pending: null })); + const app = new Hono(); + app.use("/api/host/*", async (c, next) => { + c.set("actor", actor); + await next(); + }); + registerHostProductRoutes(app, { + getHost: async () => + ({ + codexAuthStatus: status, + beginCodexAuth: begin, + pollCodexAuth: poll, + disconnectCodex: disconnect, + personalSubscriptionConnection: personalConnection, + beginSubscriptionAuth: subscriptionBegin, + pollSubscriptionAuth: subscriptionPoll, + }) as unknown as OpenGuiHost, + resolveSafeDirectory: async (path) => path ?? "/tmp", + identity: { + authorizePersonalSubscription: input.authorize ?? vi.fn(async () => undefined), + resolveDurableActor: resolveActor, + recordModelConnection: recordConnection, + } as unknown as IdentityService, + }); + return { + app, + status, + begin, + poll, + disconnect, + personalConnection, + recordConnection, + subscriptionBegin, + subscriptionPoll, + }; + } + + test("an allowed member reads and starts only their own subscription sign-in", async () => { + const authorize = vi.fn(async () => undefined); + const { app, status, begin } = subscriptionApp({ authorize }); + + expect((await app.request("http://localhost/api/host/auth/codex")).status).toBe(200); + expect( + ( + await app.request("http://localhost/api/host/auth/codex", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }) + ).status, + ).toBe(200); + + expect(authorize).toHaveBeenCalledTimes(2); + expect(authorize).toHaveBeenCalledWith(member); + expect(status).toHaveBeenCalledWith({ type: "user", id: "member-1", displayName: "Member" }); + expect(begin).toHaveBeenCalledWith({ type: "user", id: "member-1", displayName: "Member" }); + }); + + test("rolls back a newly connected personal subscription when metadata persistence fails", async () => { + const disconnect = vi.fn(async () => undefined); + const connection = { id: "personal-opaque", modelIds: ["gpt-5"] }; + const { app } = subscriptionApp({ + poll: vi.fn(async () => ({ connected: true, pending: null })), + personalConnection: vi.fn(() => connection), + recordConnection: vi.fn(async () => { + throw new Error("metadata unavailable"); + }), + disconnect, + }); + + const response = await app.request("http://localhost/api/host/auth/codex/poll", { + method: "POST", + }); + + expect(response.status).toBe(400); + expect(disconnect).toHaveBeenCalledWith({ + type: "user", + id: "member-1", + displayName: "Member", + }); + }); + + test("a member denied by BYOS policy receives 403 from xAI start and poll", async () => { + const { app, subscriptionBegin, subscriptionPoll } = subscriptionApp({ + authorize: async () => { + throw new IdentityError( + "MODEL_CREDENTIAL_POLICY_DENIED", + 403, + "This credential type is disabled by Host policy", + ); + }, + }); + + expect( + (await app.request("http://localhost/api/host/auth/xai", { method: "POST" })).status, + ).toBe(403); + expect( + (await app.request("http://localhost/api/host/auth/xai/poll", { method: "POST" })).status, + ).toBe(403); + expect(subscriptionBegin).not.toHaveBeenCalled(); + expect(subscriptionPoll).not.toHaveBeenCalled(); + }); + + test("a member denied by BYOS policy receives JSON 403 from disconnect", async () => { + const { app, disconnect } = subscriptionApp({ + authorize: async () => { + throw new IdentityError( + "MODEL_CREDENTIAL_POLICY_DENIED", + 403, + "This credential type is disabled by Host policy", + ); + }, + }); + + const response = await app.request("http://localhost/api/host/auth/codex", { + method: "DELETE", + }); + + expect(response.status).toBe(403); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toMatchObject({ ok: false }); + expect(disconnect).not.toHaveBeenCalled(); + }); + + test("a member denied by BYOS policy receives 403 without touching Host auth", async () => { + const { app, status, begin } = subscriptionApp({ + authorize: async () => { + throw new IdentityError( + "MODEL_CREDENTIAL_POLICY_DENIED", + 403, + "This credential type is disabled by Host policy", + ); + }, + }); + + expect((await app.request("http://localhost/api/host/auth/codex")).status).toBe(403); + expect( + ( + await app.request("http://localhost/api/host/auth/codex", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }) + ).status, + ).toBe(403); + expect(status).not.toHaveBeenCalled(); + expect(begin).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/routes/host-product.ts b/packages/backend/src/routes/host-product.ts index 779d21b..e24b606 100644 --- a/packages/backend/src/routes/host-product.ts +++ b/packages/backend/src/routes/host-product.ts @@ -5,7 +5,9 @@ import { } from "../host/opengui-host.ts"; import { resolve } from "node:path"; import { OPENCODE_GO_PRESET, OPENCODE_ZEN_PRESET } from "@opengui/protocol"; -import type { BackendApp } from "../http/request-context.ts"; +import type { DurableActor } from "@opengui/harness"; +import type { Context } from "hono"; +import type { BackendApp, BackendRequestEnv } from "../http/request-context.ts"; import { isPlainObject, jsonError } from "../http/json.ts"; import { durableActor, type Actor, type IdentityState } from "../identity/types.ts"; import { @@ -109,15 +111,20 @@ export function registerHostProductRoutes( app.get("/api/host/models", async (c) => { const host = await input.getHost(); await host.refreshModelCatalogs(); - const all = host.listModelConnections(); + const actor = c.get("actor") as Actor; + const durable = + actor.type === "user" && actor.role !== "owner" && actor.role !== "admin" + ? durableActor(actor) + : undefined; + const globalConnections = host.listModelConnections(); + const all = host.listModelConnections(durable); if (!input.identity) { return Response.json({ ok: true, value: all.map((connection) => ({ ...connection, plane: "host" as const })), }); } - await input.identity.migrateLegacyModelOfferings(all); - const actor = c.get("actor") as Actor; + await input.identity.migrateLegacyModelOfferings(globalConnections); const access = await input.identity.listModelConnectionAccess(actor); const byId = new Map(access.map((item) => [item.id, item])); const visible = await Promise.all( @@ -144,7 +151,8 @@ export function registerHostProductRoutes( actor.type === "user" && (actor.role === "owner" || actor.role === "admin" || metadata.plane === "user") ) { - return { ...connection, ...metadata, modelIds, defaultModelId, modelCapabilities }; + const { ownerId: _ownerId, ...publicMetadata } = metadata; + return { ...connection, ...publicMetadata, modelIds, defaultModelId, modelCapabilities }; } return { ...metadata, @@ -170,63 +178,161 @@ export function registerHostProductRoutes( value: await input.identity.listModelOfferings(c.get("actor") as Actor), }); }); - app.get("/api/host/auth/codex", async () => - Response.json({ ok: true, value: (await input.getHost()).codexAuthStatus() }), - ); - app.post("/api/host/auth/codex", async () => { + async function subscriptionActor(c: Context) { + const actor = c.get("actor") as Actor; + await input.identity?.authorizePersonalSubscription(actor); + return actor.type === "user" && actor.role !== "owner" && actor.role !== "admin" + ? durableActor(actor) + : undefined; + } + + async function personalSubscriptionMetadata( + actor: DurableActor | undefined, + provider: "codex" | "xai", + ) { + if (!actor || actor.type !== "user" || !input.identity) return undefined; + const resolved = await input.identity.resolveDurableActor(actor); + if (!resolved) throw new IdentityError("FORBIDDEN", 403, "User access required"); + const connection = (await input.getHost()).personalSubscriptionConnection(provider, actor); + return connection ? { resolved, connection } : undefined; + } + + async function recordPersonalSubscription( + actor: DurableActor | undefined, + provider: "codex" | "xai", + ) { + const metadata = await personalSubscriptionMetadata(actor, provider); + if (!metadata || !input.identity) return; + try { + await input.identity.recordModelConnection(metadata.resolved, { + id: metadata.connection.id, + plane: "user", + credentialKind: "byos", + }); + } catch (error) { + const host = await input.getHost(); + if (provider === "codex") await host.disconnectCodex(actor); + else await host.disconnectSubscription(provider, actor); + throw error; + } + } + + app.get("/api/host/auth/codex", async (c) => { try { - return Response.json({ ok: true, value: await (await input.getHost()).beginCodexAuth() }); + const actor = await subscriptionActor(c); + return Response.json({ ok: true, value: (await input.getHost()).codexAuthStatus(actor) }); } catch (error) { return sessionError(error); } }); - app.post("/api/host/auth/codex/poll", async () => { + app.post("/api/host/auth/codex", async (c) => { try { - return Response.json({ ok: true, value: await (await input.getHost()).pollCodexAuth() }); + const actor = await subscriptionActor(c); + return Response.json({ + ok: true, + value: await (await input.getHost()).beginCodexAuth(actor), + }); } catch (error) { return sessionError(error); } }); - app.post("/api/host/auth/codex/cancel", async () => - Response.json({ ok: true, value: await (await input.getHost()).cancelCodexAuth() }), - ); - app.delete("/api/host/auth/codex", async () => { - await (await input.getHost()).disconnectCodex(); - return Response.json({ ok: true, value: true }); + app.post("/api/host/auth/codex/poll", async (c) => { + try { + const actor = await subscriptionActor(c); + const value = await (await input.getHost()).pollCodexAuth(actor); + if (value.connected) await recordPersonalSubscription(actor, "codex"); + return Response.json({ + ok: true, + value, + }); + } catch (error) { + return sessionError(error); + } + }); + app.post("/api/host/auth/codex/cancel", async (c) => { + try { + return Response.json({ + ok: true, + value: await (await input.getHost()).cancelCodexAuth(await subscriptionActor(c)), + }); + } catch (error) { + return sessionError(error); + } + }); + app.delete("/api/host/auth/codex", async (c) => { + try { + const actor = await subscriptionActor(c); + const metadata = await personalSubscriptionMetadata(actor, "codex"); + await (await input.getHost()).disconnectCodex(actor); + if (metadata && input.identity) + await input.identity.removeModelConnection(metadata.resolved, metadata.connection.id); + return Response.json({ ok: true, value: true }); + } catch (error) { + return sessionError(error); + } }); for (const provider of ["xai"] as const) { - app.get(`/api/host/auth/${provider}`, async () => - Response.json({ ok: true, value: (await input.getHost()).subscriptionAuthStatus(provider) }), - ); - app.post(`/api/host/auth/${provider}`, async () => { + app.get(`/api/host/auth/${provider}`, async (c) => { try { return Response.json({ ok: true, - value: await (await input.getHost()).beginSubscriptionAuth(provider), + value: (await input.getHost()).subscriptionAuthStatus( + provider, + await subscriptionActor(c), + ), }); } catch (error) { - return jsonError(error, 400); + return sessionError(error); } }); - app.post(`/api/host/auth/${provider}/poll`, async () => { + app.post(`/api/host/auth/${provider}`, async (c) => { try { return Response.json({ ok: true, - value: await (await input.getHost()).pollSubscriptionAuth(provider), + value: await ( + await input.getHost() + ).beginSubscriptionAuth(provider, await subscriptionActor(c)), }); } catch (error) { - return jsonError(error, 400); + return sessionError(error); } }); - app.post(`/api/host/auth/${provider}/cancel`, async () => - Response.json({ - ok: true, - value: await (await input.getHost()).cancelSubscriptionAuth(provider), - }), - ); - app.delete(`/api/host/auth/${provider}`, async () => { - await (await input.getHost()).disconnectSubscription(provider); - return Response.json({ ok: true, value: true }); + app.post(`/api/host/auth/${provider}/poll`, async (c) => { + try { + const actor = await subscriptionActor(c); + const value = await (await input.getHost()).pollSubscriptionAuth(provider, actor); + if (value.connected) await recordPersonalSubscription(actor, provider); + return Response.json({ + ok: true, + value, + }); + } catch (error) { + return sessionError(error); + } + }); + app.post(`/api/host/auth/${provider}/cancel`, async (c) => { + try { + return Response.json({ + ok: true, + value: await ( + await input.getHost() + ).cancelSubscriptionAuth(provider, await subscriptionActor(c)), + }); + } catch (error) { + return sessionError(error); + } + }); + app.delete(`/api/host/auth/${provider}`, async (c) => { + try { + const actor = await subscriptionActor(c); + const metadata = await personalSubscriptionMetadata(actor, provider); + await (await input.getHost()).disconnectSubscription(provider, actor); + if (metadata && input.identity) + await input.identity.removeModelConnection(metadata.resolved, metadata.connection.id); + return Response.json({ ok: true, value: true }); + } catch (error) { + return sessionError(error); + } }); } diff --git a/packages/harness/src/models/transport.ts b/packages/harness/src/models/transport.ts index ca9f426..95d55f0 100644 --- a/packages/harness/src/models/transport.ts +++ b/packages/harness/src/models/transport.ts @@ -126,6 +126,8 @@ export interface ModelRequest { projectDirectory: string; /** Actor whose current authorization must be used for Host-side model resolution. */ actor?: import("../harness.ts").DurableActor; + /** Credential namespace selected after authorization. Shared offerings execute as the Host. */ + executionScope?: "actor" | "host"; context: ModelContextItem[]; /** Full system prompt for this turn (identity, env, skills catalog). */ systemPrompt: string; diff --git a/src/components/SettingsProviders.render.test.tsx b/src/components/SettingsProviders.render.test.tsx index be75fc3..7e71bc9 100644 --- a/src/components/SettingsProviders.render.test.tsx +++ b/src/components/SettingsProviders.render.test.tsx @@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/tes const fixture = vi.hoisted(() => ({ actor: { type: "user", id: "member-1", role: "member" } as any, connections: [] as any[], + identityEnabled: true, + allowByos: false, + modelPolicy: vi.fn(), refresh: vi.fn().mockResolvedValue(undefined), list: vi.fn(), upsert: vi.fn().mockResolvedValue(undefined), @@ -32,8 +35,13 @@ vi.mock("@/hooks/use-agent-state", () => ({ vi.mock("@/features/identity/identity-actor-context", () => ({ useIdentityActor: () => fixture.actor, })); -vi.mock("@/features/identity/workspace-identity", () => ({ getIdentityWorkspace: () => null })); -vi.mock("@/features/identity/identity-client", () => ({ createIdentityClient: vi.fn() })); +vi.mock("@/features/identity/workspace-identity", () => ({ + getIdentityWorkspace: () => + fixture.identityEnabled ? { serverUrl: "https://host.example", authToken: "token" } : null, +})); +vi.mock("@/features/identity/identity-client", () => ({ + createIdentityClient: () => ({ modelPolicy: fixture.modelPolicy }), +})); vi.mock("@/protocol/host-client", () => ({ createHostClient: () => ({ listModelConnections: fixture.list, @@ -59,6 +67,13 @@ describe("SettingsProviders", () => { vi.clearAllMocks(); fixture.actor = { type: "user", id: "member-1", role: "member" }; fixture.connections = []; + fixture.identityEnabled = true; + fixture.allowByos = false; + fixture.modelPolicy.mockImplementation(async () => ({ + host: { allowByok: true, allowByos: true }, + team: { allowByok: true, allowByos: fixture.allowByos }, + effective: { allowByok: true, allowByos: fixture.allowByos }, + })); fixture.list.mockImplementation(async () => fixture.connections); fixture.beginCodex.mockResolvedValue({ connected: false, @@ -91,7 +106,17 @@ describe("SettingsProviders", () => { expect(fixture.refresh).toHaveBeenCalled(); }); - test("does not expose raw shared connections or Host OAuth to a member", async () => { + test("shows personal subscription sign-in to a member allowed by BYOS policy", async () => { + fixture.allowByos = true; + render(); + + expect(await screen.findByRole("button", { name: "providers.codex.signIn" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "providers.xai.signIn" })).toBeTruthy(); + await waitFor(() => expect(fixture.codexStatus).toHaveBeenCalledTimes(1)); + expect(fixture.subscriptionStatus).toHaveBeenCalledWith("xai"); + }); + + test("does not expose raw shared connections or subscriptions to a member denied by BYOS policy", async () => { fixture.connections = [ { id: "host-model", @@ -106,10 +131,12 @@ describe("SettingsProviders", () => { expect(screen.queryByText("Shared model")).toBeNull(); expect(screen.queryByRole("button", { name: "providers.codex.signIn" })).toBeNull(); expect(fixture.codexStatus).not.toHaveBeenCalled(); + expect(fixture.subscriptionStatus).not.toHaveBeenCalled(); }); test("opens the device authorization dialog returned by the Host", async () => { fixture.actor = { type: "user", id: "owner-1", role: "owner" }; + fixture.identityEnabled = false; fixture.beginCodex.mockResolvedValue({ connected: false, pending: { diff --git a/src/components/SettingsProviders.tsx b/src/components/SettingsProviders.tsx index 305a8f2..3173aa7 100644 --- a/src/components/SettingsProviders.tsx +++ b/src/components/SettingsProviders.tsx @@ -111,6 +111,13 @@ export function SettingsProviders({ modelPolicy?.effective?.allowByok ?? (modelPolicy?.host.allowByok && modelPolicy.team.allowByok), ); + const personalByosAllowed = + !identity || + canManageShared || + Boolean( + modelPolicy?.effective?.allowByos ?? + (modelPolicy?.host.allowByos && modelPolicy.team.allowByos), + ); const activeDevicePending = activeDeviceAuth?.kind === "codex" ? codex.pending @@ -160,25 +167,26 @@ export function SettingsProviders({ }; useEffect(() => { void reload().catch(notifyUnknownError); - if (canManageShared) { + }, []); + useEffect(() => { + if (!personalByosAllowed) return; + void host + .codexAuthStatus() + .then((status) => { + setCodex(status); + if (status.pending) setActiveDeviceAuth({ kind: "codex" }); + }) + .catch(notifyUnknownError); + for (const provider of ["xai"] as const) { void host - .codexAuthStatus() + .subscriptionAuthStatus(provider) .then((status) => { - setCodex(status); - if (status.pending) setActiveDeviceAuth({ kind: "codex" }); + setSubscriptions((current) => ({ ...current, [provider]: status })); + if (status.pending) setActiveDeviceAuth({ kind: "subscription", provider }); }) .catch(notifyUnknownError); - for (const provider of ["xai"] as const) { - void host - .subscriptionAuthStatus(provider) - .then((status) => { - setSubscriptions((current) => ({ ...current, [provider]: status })); - if (status.pending) setActiveDeviceAuth({ kind: "subscription", provider }); - }) - .catch(notifyUnknownError); - } } - }, []); + }, [personalByosAllowed]); async function saveConnection() { if (!backendDraft) return; @@ -603,7 +611,7 @@ export function SettingsProviders({ )} -