From 16830eff742aa8c2e12da7206c1001aa4861a271 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 18:20:43 +0800 Subject: [PATCH 1/4] feat(runtime-host): bind OAuth login to Connection entities Make interactive OAuth enrollment create or reauthenticate one exact Connection, recover credential and catalog publication durably, and keep Desktop account actions entity-scoped. Generated-by: Codex --- .../runtime-host-oauth-ipc-main.test.ts | 442 +++++++++++++++++- .../src/main/oauth-connection-identities.ts | 26 -- .../main/runtime-host-account-connection.ts | 37 +- apps/desktop/src/main/runtime-host-client.ts | 4 +- .../src/main/runtime-host-oauth-ipc-main.ts | 206 ++++++-- apps/desktop/src/preload/bridge-contract.d.ts | 16 +- apps/desktop/src/preload/preload.ts | 32 +- .../settings/runtime-host-settings-bridge.ts | 7 +- .../settings/use-connection-detail.ts | 9 +- packages/core/src/llm-connections.ts | 29 ++ .../connection-effect-coordinator.test.ts | 5 +- .../execution-model-composition.test.ts | 5 +- .../src/__tests__/oauth-coordinator.test.ts | 259 +++++++--- .../src/__tests__/oauth-protocol.test.ts | 122 ++++- .../__tests__/oauth-two-client-uds.test.ts | 77 +-- .../src/__tests__/protocol.test.ts | 6 + packages/runtime-host/src/protocol/index.ts | 6 +- packages/runtime-host/src/protocol/oauth.ts | 89 +++- .../src/server/oauth-coordinator.ts | 154 ++++-- .../__tests__/runtime-policy-stores.test.ts | 353 +++++++++++++- packages/storage/src/runtime-policy-stores.ts | 9 +- .../connection-catalog-document.ts | 51 ++ .../storage/src/runtime-policy/coordinator.ts | 360 +++++++++++--- .../storage/src/runtime-policy/document-io.ts | 2 +- .../oauth-login-receipt-document.test.ts | 101 ++++ .../oauth-login-receipt-document.ts | 271 +++++++++++ .../runtime-policy/onboarding-transaction.ts | 185 +++++++- .../storage/src/runtime-policy/operations.ts | 37 +- 28 files changed, 2547 insertions(+), 353 deletions(-) delete mode 100644 apps/desktop/src/main/oauth-connection-identities.ts create mode 100644 packages/storage/src/runtime-policy/oauth-login-receipt-document.test.ts create mode 100644 packages/storage/src/runtime-policy/oauth-login-receipt-document.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index d09bb0a58b..05afc26289 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -85,8 +85,12 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { updateConnection: async () => { throw new Error('Enabled OAuth Connection must not be rewritten'); }, - startOAuthLogin: async (nextAttemptId, connectionId) => { + startOAuthLogin: async (nextAttemptId, target) => { attemptId = nextAttemptId; + assert.deepEqual(target, { + kind: 'existing', + connectionId: catalog.connections[0]?.connectionId, + }); // Codex device login presents through `open_external`: the browser // carries the authorization and the Host writes the credential back. void presentation @@ -98,7 +102,11 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { .then(() => { phase = 'authenticated'; }); - return oauthProjection(nextAttemptId, connectionId, 'awaiting_authorization'); + return oauthProjection( + nextAttemptId, + target.kind === 'existing' ? target.connectionId : '', + 'awaiting_authorization', + ); }, queryOAuthLogin: async (nextAttemptId) => oauthProjection( @@ -188,7 +196,11 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { assert.equal(handlers.has(`${prefix}:get-account-state`), true); assert.equal(handlers.has(`${prefix}:logout`), true); } - const authorization = await invoke(handlers, 'openai-codex:get-auth-url'); + const authorization = await invoke( + handlers, + 'openai-codex:get-auth-url', + catalog.connections[0]?.connectionId, + ); assert.deepEqual(authorization, { authRequestId: attemptId, stateHint: 'STATE-HINT' }); assert.deepEqual(opened, ['https://codex.example/authorize']); assert.deepEqual( @@ -213,9 +225,360 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { }); }); +test('provider-scoped OAuth IPC rejects a Connection ID owned by another provider', async () => { + const xaiConnection = { + connectionId: '00000000-0000-4000-8000-000000000009', + revision: 1, + slug: 'xai-oauth', + name: 'xAI Grok', + providerType: 'xai-oauth' as const, + enabled: true, + enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], + models: [], + }; + const handlers = new Map< + string, + Parameters[1] + >(); + let starts = 0; + let mutations = 0; + const forbiddenMutation = async () => { + mutations += 1; + throw new Error('Cross-provider IPC must not mutate a Connection'); + }; + const client = { + loadConnectionCatalog: async () => ({ + revision: 1, + defaultTarget: null, + connections: [xaiConnection], + }), + createConnection: forbiddenMutation, + updateConnection: forbiddenMutation, + deleteCredential: forbiddenMutation, + fetchConnectionModels: forbiddenMutation, + setDefaultConnectionTarget: forbiddenMutation, + queryCredential: async () => { + throw new Error('Cross-provider IPC must not inspect another credential'); + }, + startOAuthLogin: async () => { + starts += 1; + throw new Error('Cross-provider IPC must not start Host OAuth'); + }, + queryOAuthLogin: async () => oauthProjection('unused', xaiConnection.connectionId, 'cancelled'), + cancelOAuthLogin: async () => oauthProjection('unused', xaiConnection.connectionId, 'cancelled'), + } satisfies RuntimeHostOAuthIpcDeps['client']; + registerRuntimeHostOAuthIpc({ + ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, + client, + presentation: new RuntimeHostOAuthPresentation(async () => undefined), + emitConnectionListChanged: () => { + mutations += 1; + }, + isProviderEnabled: () => true, + }); + + assert.deepEqual( + await invoke(handlers, 'openai-codex:get-auth-url', xaiConnection.connectionId), + { + ok: false, + reason: 'unknown', + message: 'OAuth account does not match this provider', + }, + ); + assert.deepEqual( + await invoke(handlers, 'openai-codex:get-account-state', xaiConnection.connectionId), + { provider: 'openai-codex', runtimeState: 'not_logged_in' }, + ); + assert.deepEqual( + await invoke(handlers, 'openai-codex:refresh-tokens', xaiConnection.connectionId), + { + ok: false, + reason: 'refresh_failed', + message: 'OAuth account is not connected', + }, + ); + assert.deepEqual(await invoke(handlers, 'openai-codex:logout', xaiConnection.connectionId), { + ok: false, + reason: 'unknown', + message: 'OAuth account does not match this provider', + }); + assert.equal(starts, 0); + assert.equal(mutations, 0); +}); + +test('malformed OAuth Connection IDs fail closed before catalog or credential access', async () => { + const handlers = new Map< + string, + Parameters[1] + >(); + let reads = 0; + let mutations = 0; + const forbiddenRead = async () => { + reads += 1; + throw new Error('Malformed identity must not reach Runtime Host storage'); + }; + const forbiddenMutation = async () => { + mutations += 1; + throw new Error('Malformed identity must not mutate Runtime Host state'); + }; + const client = { + loadConnectionCatalog: forbiddenRead, + queryCredential: forbiddenRead, + createConnection: forbiddenMutation, + updateConnection: forbiddenMutation, + deleteCredential: forbiddenMutation, + fetchConnectionModels: forbiddenMutation, + setDefaultConnectionTarget: forbiddenMutation, + startOAuthLogin: forbiddenMutation, + queryOAuthLogin: forbiddenRead, + cancelOAuthLogin: forbiddenMutation, + } satisfies RuntimeHostOAuthIpcDeps['client']; + registerRuntimeHostOAuthIpc({ + ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, + client, + presentation: new RuntimeHostOAuthPresentation(async () => undefined), + emitConnectionListChanged: () => { + mutations += 1; + }, + isProviderEnabled: () => true, + }); + + for (const malformed of [null, 7, {}]) { + assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', malformed), { + ok: false, + reason: 'unknown', + message: 'Invalid OAuth Connection identity', + }); + assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state', malformed), { + ok: false, + reason: 'unknown', + message: 'Invalid OAuth Connection identity', + }); + assert.deepEqual(await invoke(handlers, 'openai-codex:refresh-tokens', malformed), { + ok: false, + reason: 'refresh_failed', + message: 'Invalid OAuth Connection identity', + }); + assert.deepEqual(await invoke(handlers, 'openai-codex:logout', malformed), { + ok: false, + reason: 'unknown', + message: 'Invalid OAuth Connection identity', + }); + } + assert.equal(reads, 0); + assert.equal(mutations, 0); +}); + +test('a second OAuth start surfaces Host conflict without cancelling the active attempt', async () => { + const provider = 'openai-codex' as const; + const connectionId = '00000000-0000-4000-8000-000000000011'; + const configuredConnections = [ + { + connectionId: '00000000-0000-4000-8000-000000000012', + revision: 1, + slug: 'codex-subscription-2', + name: 'OpenAI Codex 2', + providerType: provider, + enabled: true, + enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + models: [], + }, + { + connectionId: '00000000-0000-4000-8000-000000000013', + revision: 1, + slug: 'codex-subscription-3', + name: 'OpenAI Codex 3', + providerType: provider, + enabled: true, + enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + models: [], + }, + ]; + const foreignConnection = { + connectionId: '00000000-0000-4000-8000-000000000014', + revision: 1, + slug: 'xai-oauth', + name: 'xAI Grok', + providerType: 'xai-oauth' as const, + enabled: true, + enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], + models: [], + }; + const handlers = new Map< + string, + Parameters[1] + >(); + const presentation = new RuntimeHostOAuthPresentation(async () => undefined); + let starts = 0; + let cancels = 0; + let firstAttemptId = ''; + const client = { + loadConnectionCatalog: async () => ({ + revision: 1, + defaultTarget: null, + connections: [...configuredConnections, foreignConnection], + }), + createConnection: async () => { + throw new Error('not used'); + }, + updateConnection: async (expected) => ({ + kind: 'committed' as const, + catalogRevision: 2, + connection: { connectionId: expected.connectionId, revision: expected.revision + 1 }, + }), + deleteCredential: async ({ expected }) => ({ + kind: 'committed' as const, + vaultRevision: 2, + status: { + locator: expected.locator, + configured: false as const, + credentialId: null, + revision: null, + updatedAt: null, + }, + }), + fetchConnectionModels: async () => { + throw new Error('model discovery unavailable'); + }, + setDefaultConnectionTarget: async () => { + throw new Error('not used'); + }, + queryCredential: async (locator) => ({ + locator, + configured: true as const, + credentialId: '00000000-0000-4000-8000-000000000015', + revision: 1, + updatedAt: 1, + }), + startOAuthLogin: async (attemptId: string) => { + starts += 1; + if (starts === 2) throw new Error('Another OAuth login is already in progress'); + firstAttemptId = attemptId; + await presentation.openExternal( + 'https://auth.example/device', + 'FIRST', + new AbortController().signal, + ); + return oauthProjection(attemptId, connectionId, 'awaiting_authorization'); + }, + queryOAuthLogin: async (attemptId: string) => + oauthProjection(attemptId, connectionId, 'authenticated'), + cancelOAuthLogin: async (attemptId: string) => { + cancels += 1; + return oauthProjection(attemptId, connectionId, 'cancelled'); + }, + } satisfies RuntimeHostOAuthIpcDeps['client']; + registerRuntimeHostOAuthIpc({ + ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, + client, + presentation, + emitConnectionListChanged: () => undefined, + isProviderEnabled: () => true, + }); + + assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { + authRequestId: firstAttemptId, + stateHint: 'FIRST', + }); + assert.deepEqual( + await invoke(handlers, 'openai-codex:logout', foreignConnection.connectionId), + { + ok: false, + reason: 'unknown', + message: 'OAuth account does not match this provider', + }, + ); + assert.deepEqual(await invoke(handlers, 'openai-codex:logout'), { + ok: false, + reason: 'unknown', + message: 'Select a specific OAuth account to log out', + }); + assert.equal(cancels, 0); + assert.deepEqual( + await invoke(handlers, 'openai-codex:logout', configuredConnections[0]?.connectionId), + { ok: true }, + ); + assert.equal(cancels, 0); + assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { + ok: false, + reason: 'unknown', + message: 'Another OAuth login is already in progress', + }); + assert.equal(cancels, 0); + assert.deepEqual( + await invoke(handlers, 'openai-codex:complete-authorization', firstAttemptId), + { ok: true }, + ); + assert.equal(cancels, 0); +}); + +test('completion rejects a terminal projection that changes Connection identity', async () => { + const handlers = new Map< + string, + Parameters[1] + >(); + const presentation = new RuntimeHostOAuthPresentation(async () => undefined); + const startedId = '00000000-0000-4000-8000-000000000021'; + const changedId = '00000000-0000-4000-8000-000000000022'; + let attemptId = ''; + let synchronized = 0; + let emitted = 0; + const client = { + loadConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [] }), + createConnection: async () => { + throw new Error('not used'); + }, + updateConnection: async () => { + throw new Error('not used'); + }, + deleteCredential: async () => { + throw new Error('not used'); + }, + fetchConnectionModels: async () => { + synchronized += 1; + throw new Error('must not synchronize a changed identity'); + }, + setDefaultConnectionTarget: async () => { + throw new Error('not used'); + }, + queryCredential: async () => null, + startOAuthLogin: async (nextAttemptId: string) => { + attemptId = nextAttemptId; + await presentation.openExternal( + 'https://auth.example/device', + 'IDENTITY', + new AbortController().signal, + ); + return oauthProjection(nextAttemptId, startedId, 'awaiting_authorization'); + }, + queryOAuthLogin: async (nextAttemptId: string) => + oauthProjection(nextAttemptId, changedId, 'authenticated'), + cancelOAuthLogin: async (nextAttemptId: string) => + oauthProjection(nextAttemptId, startedId, 'cancelled'), + } satisfies RuntimeHostOAuthIpcDeps['client']; + registerRuntimeHostOAuthIpc({ + ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, + client, + presentation, + emitConnectionListChanged: () => { + emitted += 1; + }, + isProviderEnabled: () => true, + }); + + await invoke(handlers, 'openai-codex:get-auth-url'); + assert.deepEqual(await invoke(handlers, 'openai-codex:complete-authorization', attemptId), { + ok: false, + reason: 'unknown', + message: 'OAuth authorization changed Connection identity', + }); + assert.equal(synchronized, 0); + assert.equal(emitted, 0); +}); + test('keeps a committed OAuth login successful when model discovery fails', async () => { const provider = 'openai-codex' as const; - const connection = { + const existing = { connectionId: '00000000-0000-4000-8000-000000000002', revision: 1, slug: 'codex-subscription', @@ -225,10 +588,15 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], models: [], }; - const catalog: ConnectionCatalogSnapshot = { + const created = { + ...existing, + connectionId: '00000000-0000-4000-8000-000000000003', + slug: 'codex-subscription-2', + }; + let catalog: ConnectionCatalogSnapshot = { revision: 1, defaultTarget: null, - connections: [connection], + connections: [existing], }; const handlers = new Map< string, @@ -237,6 +605,7 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn const presentation = new RuntimeHostOAuthPresentation(async () => undefined); let attemptId = ''; let changed = 0; + const fetchedConnectionIds: string[] = []; const client = { loadConnectionCatalog: async () => catalog, createConnection: async () => { @@ -245,8 +614,9 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn updateConnection: async () => { throw new Error('Enabled OAuth Connection must not be rewritten'); }, - startOAuthLogin: async (nextAttemptId: string) => { + startOAuthLogin: async (nextAttemptId: string, target) => { attemptId = nextAttemptId; + assert.deepEqual(target, { kind: 'create', providerType: provider }); await presentation.openExternal( 'https://auth.example/device', 'DEVICE-CODE', @@ -254,30 +624,52 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn ); return { attemptId: nextAttemptId, - connectionId: connection.connectionId, - provider, + connection: { + connectionId: created.connectionId, + slug: created.slug, + providerType: provider, + }, phase: 'awaiting_authorization' as const, }; }, - queryOAuthLogin: async (nextAttemptId: string) => ({ - attemptId: nextAttemptId, - connectionId: connection.connectionId, - provider, - phase: 'authenticated' as const, - }), + queryOAuthLogin: async (nextAttemptId: string) => { + catalog = { ...catalog, revision: 2, connections: [existing, created] }; + return { + attemptId: nextAttemptId, + connection: { + connectionId: created.connectionId, + slug: created.slug, + providerType: provider, + }, + phase: 'authenticated' as const, + }; + }, cancelOAuthLogin: async (nextAttemptId: string) => ({ attemptId: nextAttemptId, - connectionId: connection.connectionId, - provider, + connection: { + connectionId: created.connectionId, + slug: created.slug, + providerType: provider, + }, phase: 'cancelled' as const, }), - fetchConnectionModels: async () => { + fetchConnectionModels: async (connectionId: string) => { + fetchedConnectionIds.push(connectionId); throw new Error('provider temporarily unavailable'); }, setDefaultConnectionTarget: async () => { throw new Error('Default selection must not run after failed discovery'); }, - queryCredential: async () => null, + queryCredential: async (locator) => + locator.scope === 'connection' && locator.connectionId === created.connectionId + ? { + locator, + configured: true as const, + credentialId: '00000000-0000-4000-8000-000000000004', + revision: 1, + updatedAt: 1, + } + : null, deleteCredential: async () => { throw new Error('Credential deletion must not run'); }, @@ -302,6 +694,11 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn { ok: true }, ); assert.equal(changed, 1); + assert.deepEqual(fetchedConnectionIds, [created.connectionId]); + assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), { + provider, + runtimeState: 'authenticated', + }); }); function oauthProjection( @@ -311,8 +708,11 @@ function oauthProjection( ) { return { attemptId, - connectionId, - provider: 'openai-codex' as const, + connection: { + connectionId, + slug: 'codex-subscription', + providerType: 'openai-codex' as const, + }, phase, }; } diff --git a/apps/desktop/src/main/oauth-connection-identities.ts b/apps/desktop/src/main/oauth-connection-identities.ts deleted file mode 100644 index a23f73a92c..0000000000 --- a/apps/desktop/src/main/oauth-connection-identities.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { OAuthLoginProvider } from '@maka/runtime-host/protocol'; - -/** Stable Desktop connection identities for Host-supported interactive OAuth providers. */ -export const INTERACTIVE_OAUTH_CONNECTION_SLUGS = { - 'openai-codex': 'codex-subscription', - 'xai-oauth': 'xai-oauth', -} as const satisfies Readonly>; diff --git a/apps/desktop/src/main/runtime-host-account-connection.ts b/apps/desktop/src/main/runtime-host-account-connection.ts index ddb1d428f8..f561dee5a9 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -109,6 +109,18 @@ export async function synchronizeRuntimeHostAccountConnection( providerType, ); if (!connection) throw new Error('Account Connection is missing'); + return synchronizeRuntimeHostAccountConnectionById(client, connection.connectionId); +} + +export async function synchronizeRuntimeHostAccountConnectionById( + client: RuntimeHostAccountConnectionClient, + connectionId: string, +): Promise { + const connection = findRuntimeHostAccountConnectionById( + await client.loadConnectionCatalog(), + connectionId, + ); + if (!connection) throw new Error('Account Connection is missing'); // Discovery is best effort. Selecting a default must not depend on it: a // connection whose inventory came from the curated fallback still has usable // models, and leaving `defaultTarget` empty makes every later operation that @@ -117,7 +129,7 @@ export async function synchronizeRuntimeHostAccountConnection( await client.fetchConnectionModels(connection.connectionId).catch(() => undefined); const catalog = await client.loadConnectionCatalog(); if (catalog.defaultTarget !== null) return; - const updated = findRuntimeHostAccountConnection(catalog, providerType); + const updated = findRuntimeHostAccountConnectionById(catalog, connectionId); const modelId = updated?.enabledModelIds[0]; if (!updated || !modelId) return; const selected = await client.setDefaultConnectionTarget(catalog.revision, { @@ -158,6 +170,18 @@ export async function disableRuntimeHostAccountConnection( providerType, ); if (!connection) return; + return disableRuntimeHostAccountConnectionById(client, connection.connectionId); +} + +export async function disableRuntimeHostAccountConnectionById( + client: RuntimeHostAccountConnectionClient, + connectionId: string, +): Promise { + const connection = findRuntimeHostAccountConnectionById( + await client.loadConnectionCatalog(), + connectionId, + ); + if (!connection) return; const credential = await client.queryCredential(runtimeHostAccountCredential(connection)); if (credential?.configured) { const removed = await client.deleteCredential({ @@ -171,9 +195,9 @@ export async function disableRuntimeHostAccountConnection( throw new Error(`Unable to remove account credential: ${removed.kind}`); } } - const latest = findRuntimeHostAccountConnection( + const latest = findRuntimeHostAccountConnectionById( await client.loadConnectionCatalog(), - providerType, + connectionId, ); if (!latest?.enabled) return; const disabled = await client.updateConnection( @@ -192,6 +216,13 @@ export function findRuntimeHostAccountConnection( return catalog.connections.find((connection) => connection.providerType === providerType); } +export function findRuntimeHostAccountConnectionById( + catalog: ConnectionCatalogSnapshot, + connectionId: string, +): ConnectionCatalogEntry | undefined { + return catalog.connections.find((connection) => connection.connectionId === connectionId); +} + export function runtimeHostAccountCredential( connection: ConnectionCatalogEntry, ): CredentialLocator { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index e772116784..e2ffc31762 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -479,9 +479,9 @@ export class DesktopRuntimeHostClient { startOAuthLogin( attemptId: string, - connectionId: string, + target: OperationInput<"oauth.login.start">["target"], ): Promise> { - return this.request("oauth.login.start", { attemptId, connectionId }); + return this.request("oauth.login.start", { attemptId, target }); } queryOAuthLogin( diff --git a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts index 4a2f7d755b..bbbca1ad48 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -18,19 +18,22 @@ */ import { randomUUID } from 'node:crypto'; +import { + decodeRuntimePolicyEntityId, + type ConnectionCatalogEntry, +} from '@maka/core/runtime-policy'; import { isOAuthEnrollmentProviderEnabled } from '@maka/runtime/oauth-provider-contracts'; import { OAUTH_LOGIN_PROVIDERS, + type OAuthConnectionIdentity, type OAuthLoginProjection, type OAuthLoginProvider, } from '@maka/runtime-host/protocol'; -import { INTERACTIVE_OAUTH_CONNECTION_SLUGS } from './oauth-connection-identities.js'; import { - disableRuntimeHostAccountConnection, - ensureRuntimeHostAccountConnection, - findRuntimeHostAccountConnection, + disableRuntimeHostAccountConnectionById, + findRuntimeHostAccountConnectionById, runtimeHostAccountCredential, - synchronizeRuntimeHostAccountConnection, + synchronizeRuntimeHostAccountConnectionById, type RuntimeHostAccountConnectionClient, } from './runtime-host-account-connection.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; @@ -77,6 +80,7 @@ export interface RuntimeHostOAuthIpcDeps { interface ActiveOAuthAttempt { readonly provider: OAuthLoginProvider; + readonly connection: OAuthConnectionIdentity; } /** Adapts the existing Desktop OAuth UI to the Host's provider-neutral OAuth operations. */ @@ -89,28 +93,46 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (provider !== 'xai-oauth') { deps.ipcMain.handle(channel('is-experimental-enabled'), () => providerEnabled(provider)); } - deps.ipcMain.handle(channel('get-auth-url'), async () => { + deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawConnectionId: unknown) => { if (!providerEnabled(provider)) return providerDisabled(); - // Drop any Desktop-tracked attempt for this provider so a re-click does - // not race a stale completeAuthorization waiter against a new start. - await cancelProviderAttempts(deps, activeAttempts, provider); - const connection = await ensureRuntimeHostAccountConnection(deps.client, { - providerType: provider, - slug: INTERACTIVE_OAUTH_CONNECTION_SLUGS[provider], - }); + const selection = decodeOAuthConnectionSelection(rawConnectionId); + if (selection.kind === 'invalid') return invalidConnectionIdentity(); + const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + if (connectionId) { + const existing = findRuntimeHostAccountConnectionById( + await deps.client.loadConnectionCatalog(), + connectionId, + ); + if (existing?.providerType !== provider) { + return actionFailure('OAuth account does not match this provider'); + } + } const attemptId = randomUUID(); const expectation = deps.presentation.expect(attemptId); + let startedOnHost = false; try { - const started = await deps.client.startOAuthLogin(attemptId, connection.connectionId); + const started = await deps.client.startOAuthLogin( + attemptId, + connectionId + ? { kind: 'existing', connectionId } + : { kind: 'create', providerType: provider }, + ); + startedOnHost = true; + if (started.connection.providerType !== provider) { + throw new Error('OAuth Connection does not match this provider'); + } if (isTerminal(started)) throw new Error(describeTerminal(started)); const presented = await waitForPresentation(deps.client, attemptId, expectation.presented); activeAttempts.set(attemptId, { provider, + connection: started.connection, }); return { authRequestId: attemptId, stateHint: presented.stateHint }; } catch (error) { expectation.cancel(error); - await deps.client.cancelOAuthLogin(attemptId).catch(() => undefined); + if (startedOnHost) { + await deps.client.cancelOAuthLogin(attemptId).catch(() => undefined); + } // Prefer the host's message when present so "already in progress" is not // flattened into a generic 鉴权失败 for the toast classifier. const detail = @@ -131,7 +153,8 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (typeof attemptId !== 'string') { return actionFailure('OAuth authorization is not active', 'authorization_pending'); } - if (!providerAttempt(activeAttempts, attemptId, provider)) { + const activeAttempt = providerAttempt(activeAttempts, attemptId, provider); + if (!activeAttempt) { return actionFailure('OAuth authorization is not active', 'authorization_pending'); } try { @@ -140,12 +163,16 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (terminal.phase !== 'authenticated') { return actionFailure(describeTerminal(terminal), terminalFailureReason(terminal)); } + if (!sameOAuthConnectionIdentity(activeAttempt.connection, terminal.connection)) { + return actionFailure('OAuth authorization changed Connection identity'); + } // Authentication is authoritative once the Host commits the credential. // Catalog discovery is useful follow-up work, but a transient discovery // failure must not turn a committed login into a false UI failure. - await synchronizeRuntimeHostAccountConnection(deps.client, provider).catch( - () => undefined, - ); + await synchronizeRuntimeHostAccountConnectionById( + deps.client, + terminal.connection.connectionId, + ).catch(() => undefined); deps.emitConnectionListChanged(); return { ok: true as const }; } catch { @@ -162,44 +189,81 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void } return { ok: true as const }; }); - handleReconnectableRead(deps.ipcMain, channel('get-account-state'), async () => { - const connection = findRuntimeHostAccountConnection( + handleReconnectableRead(deps.ipcMain, channel('get-account-state'), async (_event, rawConnectionId: unknown) => { + const selection = decodeOAuthConnectionSelection(rawConnectionId); + if (selection.kind === 'invalid') return invalidConnectionIdentity(); + const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const candidates = oauthAccountCandidates( await deps.client.loadConnectionCatalog(), provider, + connectionId, ); - if (!connection) return accountState(provider, 'not_logged_in'); - const credential = await deps.client.queryCredential( - runtimeHostAccountCredential(connection), + const authorizing = [...activeAttempts.values()].some( + (attempt) => + attempt.provider === provider && + (connectionId === undefined || attempt.connection.connectionId === connectionId), ); - if (credential?.configured) { + if (candidates.length === 0) { + return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in'); + } + if ((await configuredOAuthAccountConnections(deps.client, candidates)).length > 0) { return accountState(provider, 'authenticated'); } - const authorizing = [...activeAttempts.values()].some( - (attempt) => attempt.provider === provider, - ); return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in'); }); - deps.ipcMain.handle(channel('refresh-tokens'), async () => { - const connection = findRuntimeHostAccountConnection( - await deps.client.loadConnectionCatalog(), - provider, - ); - if (!connection) return actionFailure('OAuth account is not connected', 'refresh_failed'); - const credential = await deps.client.queryCredential( - runtimeHostAccountCredential(connection), + deps.ipcMain.handle(channel('refresh-tokens'), async (_event, rawConnectionId: unknown) => { + const selection = decodeOAuthConnectionSelection(rawConnectionId); + if (selection.kind === 'invalid') return invalidConnectionIdentity('refresh_failed'); + const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const connections = await configuredOAuthAccountConnections( + deps.client, + oauthAccountCandidates( + await deps.client.loadConnectionCatalog(), + provider, + connectionId, + ), ); - if (!credential?.configured) { + if (connections.length === 0) { return actionFailure('OAuth account is not connected', 'refresh_failed'); } + if (connectionId === undefined && connections.length > 1) { + return actionFailure('Select a specific OAuth account to refresh', 'refresh_failed'); + } + const connection = connections[0]!; const refreshed = await deps.client.fetchConnectionModels(connection.connectionId); return refreshed.kind === 'committed' ? { ok: true as const } : actionFailure('Unable to refresh OAuth account', 'refresh_failed'); }); - deps.ipcMain.handle(channel('logout'), async () => { - await cancelProviderAttempts(deps, activeAttempts, provider); + deps.ipcMain.handle(channel('logout'), async (_event, rawConnectionId: unknown) => { + const selection = decodeOAuthConnectionSelection(rawConnectionId); + if (selection.kind === 'invalid') return invalidConnectionIdentity(); + const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; try { - await disableRuntimeHostAccountConnection(deps.client, provider); + const candidates = oauthAccountCandidates( + await deps.client.loadConnectionCatalog(), + provider, + connectionId, + ); + if (connectionId !== undefined && candidates.length === 0) { + return actionFailure('OAuth account does not match this provider'); + } + const connections = + connectionId !== undefined + ? candidates + : await configuredOAuthAccountConnections(deps.client, candidates); + if (connectionId === undefined && connections.length > 1) { + return actionFailure('Select a specific OAuth account to log out'); + } + const connection = connections[0]; + await cancelProviderAttempts( + deps, + activeAttempts, + provider, + connection?.connectionId, + ); + if (connection) + await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId); } catch { return actionFailure('Unable to remove OAuth account'); } @@ -237,9 +301,14 @@ async function cancelProviderAttempts( deps: RuntimeHostOAuthIpcDeps, activeAttempts: Map, provider: OAuthLoginProvider, + connectionId: string | undefined, ): Promise { const attemptIds = [...activeAttempts] - .filter(([, attempt]) => attempt.provider === provider) + .filter( + ([, attempt]) => + attempt.provider === provider && + (connectionId === undefined || attempt.connection.connectionId === connectionId), + ) .map(([attemptId]) => attemptId); await Promise.all( attemptIds.map(async (attemptId) => { @@ -296,6 +365,61 @@ function accountState( return { provider, runtimeState }; } +function oauthAccountCandidates( + catalog: Awaited>, + provider: OAuthLoginProvider, + connectionId: string | undefined, +): ConnectionCatalogEntry[] { + if (connectionId !== undefined) { + const connection = findRuntimeHostAccountConnectionById(catalog, connectionId); + return connection?.providerType === provider ? [connection] : []; + } + return catalog.connections.filter((connection) => connection.providerType === provider); +} + +type OAuthConnectionSelection = + | { readonly kind: 'aggregate' } + | { readonly kind: 'exact'; readonly connectionId: string } + | { readonly kind: 'invalid' }; + +function decodeOAuthConnectionSelection(value: unknown): OAuthConnectionSelection { + if (value === undefined) return { kind: 'aggregate' }; + if (typeof value !== 'string') return { kind: 'invalid' }; + try { + return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(value) }; + } catch { + return { kind: 'invalid' }; + } +} + +function sameOAuthConnectionIdentity( + left: OAuthConnectionIdentity, + right: OAuthConnectionIdentity, +): boolean { + return ( + left.connectionId === right.connectionId && + left.slug === right.slug && + left.providerType === right.providerType + ); +} + +function invalidConnectionIdentity(reason: 'refresh_failed' | 'unknown' = 'unknown') { + return actionFailure('Invalid OAuth Connection identity', reason); +} + +async function configuredOAuthAccountConnections( + client: OAuthClient, + candidates: readonly ConnectionCatalogEntry[], +): Promise { + const configured = await Promise.all( + candidates.map(async (connection) => ({ + connection, + status: await client.queryCredential(runtimeHostAccountCredential(connection)), + })), + ); + return configured.filter(({ status }) => status?.configured).map(({ connection }) => connection); +} + function providerDisabled() { return actionFailure('OAuth enrollment is disabled for this provider', 'experimental_disabled'); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 0eb9473594..10fc8cf377 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1438,11 +1438,11 @@ export interface MakaBridge { }; openAiCodex: { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise; - getAuthUrl(host?: DesktopRuntimeHostRef): Promise; + getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ provider: 'openai-codex'; runtimeState: | 'not_logged_in' @@ -1456,15 +1456,15 @@ export interface MakaBridge { picture?: string; errorMessage?: string; }>; - refreshTokens(host?: DesktopRuntimeHostRef): Promise; - logout(host?: DesktopRuntimeHostRef): Promise; + refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; }; xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef): Promise; + getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ provider: 'xai-oauth'; runtimeState: | 'not_logged_in' @@ -1475,8 +1475,8 @@ export interface MakaBridge { | 'storage_failed'; errorMessage?: string; }>; - refreshTokens(host?: DesktopRuntimeHostRef): Promise; - logout(host?: DesktopRuntimeHostRef): Promise; + refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; }; githubCopilotSubscription: { connectExistingLogin(host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5552ff34fd..157a758e6e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2763,8 +2763,8 @@ const makaBridge = { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled'); }, - getAuthUrl(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url'); + getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', connectionId); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:open-auth-url', authRequestId); @@ -2775,7 +2775,7 @@ const makaBridge = { cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'openai-codex:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ provider: 'openai-codex'; runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed'; accountId?: string; @@ -2784,18 +2784,18 @@ const makaBridge = { picture?: string; errorMessage?: string; }> { - return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state'); + return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens'); + refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:logout'); + logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + return invokeSelectedRuntimeHost(host, 'openai-codex:logout', connectionId); }, }, xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url'); + getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', connectionId); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:open-auth-url', authRequestId); @@ -2806,7 +2806,7 @@ const makaBridge = { cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'xai-oauth:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ provider: 'xai-oauth'; runtimeState: | 'not_logged_in' @@ -2817,13 +2817,13 @@ const makaBridge = { | 'storage_failed'; errorMessage?: string; }> { - return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state'); + return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens'); + refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:logout'); + logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + return invokeSelectedRuntimeHost(host, 'xai-oauth:logout', connectionId); }, }, githubCopilotSubscription: { diff --git a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts index 1424605504..a4a4aa1b20 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts +++ b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts @@ -49,16 +49,17 @@ export function runtimeHostConnectionsBridge( export function runtimeHostOAuthLoginBridge( bridge: typeof window.maka.openAiCodex | typeof window.maka.xaiOAuth, host: DesktopRuntimeHostRef, + connectionId?: string, ): OAuthLoginFlowBridge { return { getAuthUrl: () => - bridge.getAuthUrl(host) as ReturnType, + bridge.getAuthUrl(host, connectionId) as ReturnType, openAuthUrl: (authRequestId) => bridge.openAuthUrl(authRequestId, host), completeAuthorization: (authRequestId) => bridge.completeAuthorization(authRequestId, host), cancelAuthorization: (authRequestId) => bridge.cancelAuthorization(authRequestId, host), - getAccountState: () => bridge.getAccountState(host), - logout: () => bridge.logout(host), + getAccountState: () => bridge.getAccountState(host, connectionId), + logout: () => bridge.logout(host, connectionId), }; } diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index f6ed6cd76d..e9e9859a04 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -82,17 +82,18 @@ export interface OAuthLoginService { export function oauthLoginServiceFor( providerType: ProviderType, host: import('../../preload/bridge-contract.js').DesktopRuntimeHostRef, + connectionId: string, ): OAuthLoginService | null { switch (providerType) { case 'openai-codex': return { - bridge: runtimeHostOAuthLoginBridge(window.maka.openAiCodex, host), + bridge: runtimeHostOAuthLoginBridge(window.maka.openAiCodex, host, connectionId), display: { name: 'OpenAI Codex', shortName: 'Codex' }, showsDeviceCode: true, }; case 'xai-oauth': return { - bridge: runtimeHostOAuthLoginBridge(window.maka.xaiOAuth, host), + bridge: runtimeHostOAuthLoginBridge(window.maka.xaiOAuth, host, connectionId), display: { name: 'xAI Grok', shortName: 'SuperGrok / X Premium' }, showsDeviceCode: false, }; @@ -158,8 +159,8 @@ export function useConnectionDetail(props: ConnectionDetailProps) { // connections" — an instruction whose only destination is the retirement // notice itself. const retired = isRetiredProvider(connection.providerType); - const oauthLoginService = needsOAuth && !retired - ? oauthLoginServiceFor(connection.providerType, host) + const oauthLoginService = needsOAuth && !retired && connection.connectionId + ? oauthLoginServiceFor(connection.providerType, host, connection.connectionId) : null; const usesGitHubCopilotLogin = connection.providerType === 'github-copilot'; const supportsRemoteDiscovery = providerSupportsModelDiscovery(connection.providerType); diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 472a90b508..a7445bd091 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -154,6 +154,8 @@ export interface RuntimeExecutionConnection { } export interface LlmConnection extends RuntimeExecutionConnection { + /** Immutable Runtime Host entity identity. Legacy non-Host projections may omit it. */ + connectionId?: string; name: string; enabled: boolean; /** Model ids shown in model pickers. Legacy connections omit this and enable only their default model. */ @@ -561,6 +563,33 @@ export function deriveConnectionSlug( } } +export type InteractiveOAuthProviderType = Extract; + +/** Stable human-facing slug base for one interactive OAuth Connection. */ +export function interactiveOAuthConnectionSlugBase( + providerType: InteractiveOAuthProviderType, +): string { + switch (providerType) { + case 'openai-codex': + return 'codex-subscription'; + case 'xai-oauth': + return 'xai-oauth'; + } +} + +/** Derive an unused OAuth Connection slug without moving allocation into a surface. */ +export function deriveInteractiveOAuthConnectionSlug( + providerType: InteractiveOAuthProviderType, + existingSlugs: readonly string[] = [], +): string { + const base = interactiveOAuthConnectionSlugBase(providerType); + if (!existingSlugs.includes(base)) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!existingSlugs.includes(candidate)) return candidate; + } +} + /** * PR-UI-IPC-1 (@kenji msg 35260e29 + 2e495eb7): connection `baseUrl` * scheme allowlist gate. diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index cd7444ddc4..5049f4348e 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -1198,7 +1198,10 @@ test('OAuth connection effects resolve the canonical access token instead of sen refresh_token: 'oauth-refresh-token-must-not-escape', expires_at: Date.now() + 60 * 60_000, }); - const enrollment = await stores.operations.beginInteractiveOAuthLogin(connection.connectionId); + const enrollment = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'connection-effect-oauth', + target: { kind: 'existing', connectionId: connection.connectionId }, + }); assert.equal(enrollment.kind, 'ready'); if (enrollment.kind !== 'ready') throw new Error('OAuth enrollment did not start'); const credential = await stores.operations.completeInteractiveOAuthLogin( diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 6662d2fc61..e7a74b52a6 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -827,7 +827,10 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its expires_at: 0, account_id: 'oauth-account-v1', }; - const login = await policy.operations.beginInteractiveOAuthLogin(connection.connectionId); + const login = await policy.operations.beginInteractiveOAuthLogin({ + attemptId: 'execution-model-oauth', + target: { kind: 'existing', connectionId: connection.connectionId }, + }); assert.equal(login.kind, 'ready'); if (login.kind !== 'ready') return; const storedToken = await policy.operations.completeInteractiveOAuthLogin( diff --git a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts index 1584eb3c18..308b45f9e0 100644 --- a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -79,7 +79,7 @@ test('xAI enrollment keeps device polling and credential material in the Host', }); const started = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-xai', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-xai', fixture.connection.connectionId), operationContext('client-xai', fixture.acquireResidency), ); assert.equal(started.ok, true); @@ -93,7 +93,128 @@ test('xAI enrollment keeps device polling and credential material in the Host', }); }); -test('a new OAuth start supersedes an in-progress login instead of failing with operation_conflict', async () => { +test('authenticated OAuth attempts reconcile by attemptId after Host restart', async () => { + await withFixture('openai-codex', async (fixture) => { + const client = await attachPresentation(fixture.capabilities, 'client-oauth-restart', []); + const createCoordinator = () => + new HostOAuthCoordinator({ + runtimePolicy: fixture.stores, + activation: fixture.activation, + clientCapabilities: fixture.capabilities, + isProviderEnabled: () => true, + acquireResidency: fixture.acquireResidency, + invalidateBackends: async () => { + fixture.invalidations += 1; + }, + onFatal: (error) => { + throw error; + }, + now: () => NOW, + startCodexAuthorization: async () => ({ + deviceAuthId: 'deviceauth-restart', + userCode: 'CODE-RESTART', + verificationUrl: 'https://auth.openai.com/codex/device', + expiresAt: NOW + 60_000, + intervalMs: 1_000, + }), + pollCodexAuthorization: async () => ({ + authorizationCode: 'restart-code', + codeVerifier: 'restart-verifier', + }), + exchangeCodexCode: async () => tokenFixture('restart-access'), + }); + + const first = createCoordinator(); + const input = oauthStart('attempt-restart', fixture.connection.connectionId); + const started = await first.handlers['oauth.login.start']( + input, + operationContext('client-oauth-restart', fixture.acquireResidency), + ); + assert.equal(started.ok, true); + const authenticated = await waitForTerminal(first, input.attemptId); + assert.equal(authenticated.phase, 'authenticated'); + await first.close(); + + const successor = createCoordinator(); + for (const operation of ['oauth.login.query', 'oauth.login.cancel'] as const) { + const outcome = await successor.handlers[operation]( + { attemptId: input.attemptId }, + operationContext('client-oauth-restart', fixture.acquireResidency), + ); + assert.equal(outcome.ok, true); + if (outcome.ok) assert.deepEqual(outcome.result, authenticated); + } + const replay = await successor.handlers['oauth.login.start']( + input, + operationContext('client-oauth-restart', fixture.acquireResidency), + ); + assert.equal(replay.ok, true); + if (replay.ok) assert.deepEqual(replay.result, authenticated); + const rebound = await successor.handlers['oauth.login.start']( + { + attemptId: input.attemptId, + target: { kind: 'create', providerType: 'openai-codex' }, + }, + operationContext('client-oauth-restart', fixture.acquireResidency), + ); + assert.deepEqual(rebound, { + ok: false, + error: { + code: 'invalid_request', + message: 'OAuth attemptId is already bound to another connection', + }, + }); + await successor.close(); + client.close(); + }); +}); + +test('durable OAuth receipt failures stay bounded on start, query, and cancel', async () => { + await withFixture('openai-codex', async (fixture) => { + await writeFile( + join(fixture.root, 'runtime-policy-oauth-login-receipts.json'), + '{"invalid":true}\n', + 'utf8', + ); + const coordinator = new HostOAuthCoordinator({ + runtimePolicy: fixture.stores, + activation: fixture.activation, + clientCapabilities: fixture.capabilities, + isProviderEnabled: () => true, + acquireResidency: fixture.acquireResidency, + invalidateBackends: async () => undefined, + onFatal: (error) => { + throw error; + }, + }); + const expected = { + ok: false as const, + error: { + code: 'persistence_failed' as const, + message: 'OAuth login receipt query failed', + }, + }; + assert.deepEqual( + await coordinator.handlers['oauth.login.start']( + oauthStart('attempt-receipt-failure', fixture.connection.connectionId), + operationContext('client-receipt-failure', fixture.acquireResidency), + ), + expected, + ); + for (const operation of ['oauth.login.query', 'oauth.login.cancel'] as const) { + assert.deepEqual( + await coordinator.handlers[operation]( + { attemptId: 'attempt-receipt-failure' }, + operationContext('client-receipt-failure', fixture.acquireResidency), + ), + expected, + ); + } + await coordinator.close(); + }); +}); + +test('a new OAuth start conflicts with an in-progress login without cancelling it', async () => { await withFixture('xai-oauth', async (fixture) => { const client = await attachPresentation(fixture.capabilities, 'client-xai-supersede', []); let firstPollEntered = false; @@ -124,7 +245,7 @@ test('a new OAuth start supersedes an in-progress login instead of failing with pollXaiAuthorization: async (input) => { if (input.authorization.deviceCode === 'device-1') { firstPollEntered = true; - // Park until supersede aborts this attempt (no external deadlock). + // Park until the explicit cancel below aborts this attempt. await new Promise((_resolve, reject) => { if (input.signal.aborted) { reject(input.signal.reason ?? new DOMException('aborted', 'AbortError')); @@ -144,7 +265,10 @@ test('a new OAuth start supersedes an in-progress login instead of failing with }); const first = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-first', connectionId: fixture.connection.connectionId }, + { + attemptId: 'attempt-first', + target: { kind: 'create', providerType: 'xai-oauth' }, + }, operationContext('client-xai-supersede', fixture.acquireResidency), ); assert.equal(first.ok, true); @@ -155,18 +279,26 @@ test('a new OAuth start supersedes an in-progress login instead of failing with assert.equal(firstPollEntered, true); const second = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-second', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-second', fixture.connection.connectionId), + operationContext('client-xai-supersede', fixture.acquireResidency), + ); + assert.deepEqual(second, { + ok: false, + error: { code: 'operation_conflict', message: 'Another OAuth login is already in progress' }, + }); + await coordinator.handlers['oauth.login.cancel']( + { attemptId: 'attempt-first' }, operationContext('client-xai-supersede', fixture.acquireResidency), ); - assert.equal(second.ok, true, 'second start must supersede, not conflict'); - if (second.ok) assert.equal(second.result.attemptId, 'attempt-second'); - assert.equal((await waitForTerminal(coordinator, 'attempt-first')).phase, 'cancelled'); - assert.equal((await waitForTerminal(coordinator, 'attempt-second')).phase, 'authenticated'); - assert.equal(starts, 2); - assert.equal(fixture.invalidations, 1); - assert.equal(fixture.activeResidencies, 0); + assert.equal((await fixture.stores.connectionCatalog.getSnapshot()).connections.length, 1); + assert.deepEqual(await fixture.stores.operations.queryInteractiveOAuthLogin('attempt-first'), { + kind: 'not_found', + }); await coordinator.close(); + assert.equal(starts, 1); + assert.equal(fixture.invalidations, 0); + assert.equal(fixture.activeResidencies, 0); client.close(); }); }); @@ -215,35 +347,35 @@ test('concurrent OAuth starts serialize and never dual-open active logins', asyn const [first, second] = await Promise.all([ coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-concurrent-a', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-concurrent-a', fixture.connection.connectionId), operationContext('client-xai-concurrent', fixture.acquireResidency), ), coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-concurrent-b', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-concurrent-b', fixture.connection.connectionId), operationContext('client-xai-concurrent', fixture.acquireResidency), ), ]); - assert.equal(first.ok, true); - assert.equal(second.ok, true); + assert.notEqual(first.ok, second.ok); + const admitted = first.ok ? first : second; + const rejected = first.ok ? second : first; + assert.equal(admitted.ok, true); + assert.equal(rejected.ok, false); + if (!rejected.ok) assert.equal(rejected.error.code, 'operation_conflict'); assert.equal(maxConcurrentActive, 1, 'device authorization must not run concurrently'); - assert.equal(starts, 2); - - const phases = await Promise.all([ - waitForTerminal(coordinator, 'attempt-concurrent-a'), - waitForTerminal(coordinator, 'attempt-concurrent-b'), - ]); - // First is superseded while polling or still completing; second should authenticate. - assert.ok(phases.some((phase) => phase.phase === 'authenticated')); - assert.ok( - phases.every((phase) => phase.phase === 'authenticated' || phase.phase === 'cancelled'), - ); + assert.equal(starts, 1); + if (admitted.ok) { + assert.equal( + (await waitForTerminal(coordinator, admitted.result.attemptId)).phase, + 'authenticated', + ); + } assert.equal(fixture.activeResidencies, 0); await coordinator.close(); client.close(); }); }); -test('supersede waits for an admitted token poll instead of dropping the granted token', async () => { +test('a committing OAuth attempt keeps exclusive admission until its granted token settles', async () => { await withFixture('xai-oauth', async (fixture) => { const client = await attachPresentation( fixture.capabilities, @@ -294,33 +426,27 @@ test('supersede waits for an admitted token poll instead of dropping the granted }); const first = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-deferred-first', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-deferred-first', fixture.connection.connectionId), operationContext('client-xai-deferred-supersede', fixture.acquireResidency), ); assert.equal(first.ok, true); await pollAdmitted; - const secondPromise = coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-deferred-second', connectionId: fixture.connection.connectionId }, + const second = await coordinator.handlers['oauth.login.start']( + oauthStart('attempt-deferred-second', fixture.connection.connectionId), operationContext('client-xai-deferred-supersede', fixture.acquireResidency), ); - // Give supersede time to observe cancellationDeferred and park on settlement. - await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(second.ok, false); + if (!second.ok) assert.equal(second.error.code, 'operation_conflict'); releasePoll(); assert.equal( (await waitForTerminal(coordinator, 'attempt-deferred-first')).phase, 'authenticated', - 'admitted poll must still commit under supersede', + 'admitted poll must still commit under a rejected competing start', ); - const second = await secondPromise; - assert.equal(second.ok, true); - assert.equal( - (await waitForTerminal(coordinator, 'attempt-deferred-second')).phase, - 'authenticated', - ); - assert.equal(starts, 2); - assert.equal(fixture.invalidations, 2); + assert.equal(starts, 1); + assert.equal(fixture.invalidations, 1); assert.equal(fixture.activeResidencies, 0); await coordinator.close(); client.close(); @@ -367,7 +493,7 @@ test('xAI cancellation waits for an admitted token poll and commits its successf }); const started = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-xai-cut', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-xai-cut', fixture.connection.connectionId), operationContext('client-xai-cut', fixture.acquireResidency), ); assert.equal(started.ok, true); @@ -421,17 +547,25 @@ test('Codex device login fails when approval never arrives before expiry', async }); const started = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-codex-timeout', connectionId: fixture.connection.connectionId }, + { + attemptId: 'attempt-codex-timeout', + target: { kind: 'create', providerType: 'openai-codex' }, + }, operationContext('client-codex-timeout', fixture.acquireResidency), ); assert.equal(started.ok, true); + if (!started.ok) return; assert.deepEqual(await waitForTerminal(coordinator, 'attempt-codex-timeout'), { attemptId: 'attempt-codex-timeout', - connectionId: fixture.connection.connectionId, - provider: 'openai-codex', + connection: started.result.connection, phase: 'failed', failure: 'authorization_failed', }); + assert.equal((await fixture.stores.connectionCatalog.getSnapshot()).connections.length, 1); + assert.deepEqual( + await fixture.stores.operations.queryInteractiveOAuthLogin('attempt-codex-timeout'), + { kind: 'not_found' }, + ); assert.equal(fixture.activeResidencies, 0); await coordinator.close(); client.close(); @@ -479,7 +613,7 @@ test('Codex device login cancels between polls but commits an admitted poll resu }); const started = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-codex-cut', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-codex-cut', fixture.connection.connectionId), operationContext('client-codex-cut', fixture.acquireResidency), ); assert.equal(started.ok, true); @@ -544,7 +678,7 @@ test('Codex device login presents the one-time code and commits exchanged tokens }); const started = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-codex-device', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-codex-device', fixture.connection.connectionId), operationContext('client-codex-device', fixture.acquireResidency), ); assert.equal(started.ok, true); @@ -594,7 +728,10 @@ test('OAuth login rejects a Client without presentation before creating an effec }); assert.deepEqual( await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-missing', connectionId: fixture.connection.connectionId }, + { + attemptId: 'attempt-missing', + target: { kind: 'create', providerType: 'openai-codex' }, + }, operationContext('client-missing', fixture.acquireResidency), ), { @@ -605,6 +742,13 @@ test('OAuth login rejects a Client without presentation before creating an effec }, }, ); + assert.equal((await fixture.stores.connectionCatalog.getSnapshot()).connections.length, 1); + assert.deepEqual( + await fixture.stores.operations.queryInteractiveOAuthLogin('attempt-missing'), + { + kind: 'not_found', + }, + ); assert.equal(fixture.activeResidencies, 0); await coordinator.close(); }); @@ -635,7 +779,7 @@ test('OAuth login rejects an experimentally disabled provider before presentatio await coordinator.handlers['oauth.login.start']( { attemptId: `attempt-disabled-${provider}`, - connectionId: fixture.connection.connectionId, + target: { kind: 'create', providerType: provider }, }, operationContext(`client-disabled-${provider}`, fixture.acquireResidency), ), @@ -647,6 +791,11 @@ test('OAuth login rejects an experimentally disabled provider before presentatio }, }, ); + assert.equal((await fixture.stores.connectionCatalog.getSnapshot()).connections.length, 1); + assert.deepEqual( + await fixture.stores.operations.queryInteractiveOAuthLogin(`attempt-disabled-${provider}`), + { kind: 'not_found' }, + ); assert.deepEqual(presentationCalls, []); assert.equal(fixture.activeResidencies, 0); await coordinator.close(); @@ -698,7 +847,7 @@ test('OAuth credential commit excludes overlapping backend activations in both d }); const started = await coordinator.handlers['oauth.login.start']( - { attemptId: 'attempt-activation', connectionId: fixture.connection.connectionId }, + oauthStart('attempt-activation', fixture.connection.connectionId), operationContext('client-activation', fixture.acquireResidency), ); assert.equal(started.ok, true); @@ -841,6 +990,7 @@ function operationContext(connectionId: string, acquireResidency: () => { releas async function withFixture( providerType: 'openai-codex' | 'xai-oauth', run: (fixture: { + root: string; stores: RuntimePolicyStoresWriter; connection: ConnectionCatalogEntry; capabilities: HostClientCapabilityCoordinator; @@ -876,6 +1026,7 @@ async function withFixture( onModelToolsChanged: () => undefined, }); const fixture = { + root, stores, connection, capabilities, @@ -902,3 +1053,7 @@ async function withFixture( await rm(root, { recursive: true, force: true }); } } + +function oauthStart(attemptId: string, connectionId: string) { + return { attemptId, target: { kind: 'existing' as const, connectionId } }; +} diff --git a/packages/runtime-host/src/__tests__/oauth-protocol.test.ts b/packages/runtime-host/src/__tests__/oauth-protocol.test.ts index 579635c9d7..0981c6eade 100644 --- a/packages/runtime-host/src/__tests__/oauth-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-protocol.test.ts @@ -26,6 +26,7 @@ import { decodeOAuthLoginProjection, decodeOAuthPresentationRequest, decodeOAuthPresentationResult, + OAUTH_OPERATION_SPECS, type OAuthPresentationMethod, } from '../protocol/index.js'; @@ -34,12 +35,18 @@ test('OAuth login protocol binds attempt identity and closes terminal projection decodeClientFrame({ requestId: 'request', operation: 'oauth.login.start', - input: { attemptId: 'attempt', connectionId: 'connection' }, + input: { + attemptId: 'attempt', + target: { kind: 'existing', connectionId: 'connection' }, + }, }), { requestId: 'request', operation: 'oauth.login.start', - input: { attemptId: 'attempt', connectionId: 'connection' }, + input: { + attemptId: 'attempt', + target: { kind: 'existing', connectionId: 'connection' }, + }, }, ); assert.deepEqual( @@ -49,8 +56,11 @@ test('OAuth login protocol binds attempt identity and closes terminal projection ok: true, result: { attemptId: 'attempt', - connectionId: 'connection', - provider: 'openai-codex', + connection: { + connectionId: 'connection', + slug: 'codex-subscription', + providerType: 'openai-codex', + }, phase: 'failed', failure: 'provider_rejected', }, @@ -61,8 +71,11 @@ test('OAuth login protocol binds attempt identity and closes terminal projection ok: true, result: { attemptId: 'attempt', - connectionId: 'connection', - provider: 'openai-codex', + connection: { + connectionId: 'connection', + slug: 'codex-subscription', + providerType: 'openai-codex', + }, phase: 'failed', failure: 'provider_rejected', }, @@ -76,14 +89,36 @@ test('OAuth login protocol binds attempt identity and closes terminal projection ok: true, result: { attemptId: 'attempt', - connectionId: 'connection', - provider: 'openai-codex', + connection: { + connectionId: 'connection', + slug: 'codex-subscription', + providerType: 'openai-codex', + }, phase: 'authenticated', failure: 'internal_failure', }, }), (error: unknown) => error instanceof RuntimeHostProtocolError, ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'epoch-53-request', + operation: 'oauth.login.start', + input: { attemptId: 'attempt', connectionId: 'connection' }, + }), + RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeOAuthLoginProjection({ + attemptId: 'attempt', + connectionId: 'connection', + provider: 'openai-codex', + phase: 'authenticated', + }), + RuntimeHostProtocolError, + ); }); test('OAuth account usage is no longer an operation on the wire', () => { @@ -146,8 +181,11 @@ test('OAuth login projections refuse a retired provider on the wire', () => { () => decodeOAuthLoginProjection({ attemptId: 'attempt', - connectionId: 'connection', - provider: 'claude-subscription', + connection: { + connectionId: 'connection', + slug: 'claude-subscription', + providerType: 'claude-subscription', + }, phase: 'awaiting_authorization', }), RuntimeHostProtocolError, @@ -155,15 +193,71 @@ test('OAuth login projections refuse a retired provider on the wire', () => { assert.deepEqual( decodeOAuthLoginProjection({ attemptId: 'attempt', - connectionId: 'connection', - provider: 'openai-codex', + connection: { + connectionId: 'connection', + slug: 'codex-subscription', + providerType: 'openai-codex', + }, phase: 'awaiting_authorization', }), { attemptId: 'attempt', - connectionId: 'connection', - provider: 'openai-codex', + connection: { + connectionId: 'connection', + slug: 'codex-subscription', + providerType: 'openai-codex', + }, phase: 'awaiting_authorization', }, ); }); + +test('OAuth operations correlate attempt and Connection identity', () => { + const projection = decodeOAuthLoginProjection({ + attemptId: 'attempt-output', + connection: { + connectionId: 'connection-output', + slug: 'codex-subscription', + providerType: 'openai-codex', + }, + phase: 'authenticated', + }); + assert.throws( + () => + OAUTH_OPERATION_SPECS['oauth.login.query'].assertOutputForInput?.( + { attemptId: 'attempt-input' }, + projection, + ), + RuntimeHostProtocolError, + ); + assert.throws( + () => + OAUTH_OPERATION_SPECS['oauth.login.start'].assertOutputForInput?.( + { + attemptId: projection.attemptId, + target: { kind: 'existing', connectionId: 'another-connection' }, + }, + projection, + ), + RuntimeHostProtocolError, + ); + assert.throws( + () => + OAUTH_OPERATION_SPECS['oauth.login.start'].assertOutputForInput?.( + { + attemptId: projection.attemptId, + target: { kind: 'create', providerType: 'xai-oauth' }, + }, + projection, + ), + RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeOAuthLoginProjection({ + ...projection, + connection: { ...projection.connection, slug: 'Invalid Slug' }, + }), + RuntimeHostProtocolError, + ); +}); diff --git a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts index 395f113fbf..9e0ad0a09e 100644 --- a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts @@ -43,7 +43,7 @@ import { } from '../server/operation-dispatcher.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; -test('OAuth enrollment presents only on the initiating Client over the real endpoint', { +test('two OAuth creates bind distinct entities and present only on their initiating Clients', { timeout: 30_000, }, async () => { const base = await mkdtemp(join(tmpdir(), 'maka-oauth-two-client-')); @@ -146,28 +146,43 @@ test('OAuth enrollment presents only on the initiating Client over the real endp }), ); - const started = await second.request('oauth.login.start', { - attemptId: 'uds-attempt', - connectionId: connection.connectionId, - }); - assert.equal(started.phase, 'awaiting_authorization'); - const terminal = await waitForTerminal(second, 'uds-attempt'); - assert.equal(terminal.phase, 'authenticated'); - assert.deepEqual(presentations, ['tui']); - const resolved = await stores.operations.resolveExecutionConnection({ - kind: 'catalog_slug', - connectionSlug: connection.slug, - }); - assert.equal(resolved.kind, 'ready'); - if (resolved.kind === 'ready') { - assert.deepEqual( - parseOAuthSubscriptionTokens(resolved.secretMaterial.connection?.secret ?? ''), - { - access_token: 'host-access-token', - refresh_token: 'host-refresh-token', - expires_at: 1_900_000_000_000, - }, - ); + const firstStarted = await first.request( + 'oauth.login.start', + oauthCreateStart('uds-create-first', 'openai-codex'), + ); + assert.equal(firstStarted.phase, 'awaiting_authorization'); + const firstTerminal = await waitForTerminal(first, 'uds-create-first'); + assert.equal(firstTerminal.phase, 'authenticated'); + const secondStarted = await second.request( + 'oauth.login.start', + oauthCreateStart('uds-create-second', 'openai-codex'), + ); + assert.equal(secondStarted.phase, 'awaiting_authorization'); + const secondTerminal = await waitForTerminal(second, 'uds-create-second'); + assert.equal(secondTerminal.phase, 'authenticated'); + assert.deepEqual(presentations, ['desktop', 'tui']); + assert.notEqual(firstTerminal.connection.connectionId, secondTerminal.connection.connectionId); + assert.equal(firstTerminal.connection.slug, 'codex-subscription'); + assert.equal(secondTerminal.connection.slug, 'codex-subscription-2'); + const snapshot = await stores.connectionCatalog.getSnapshot(); + assert.equal(snapshot.connections.length, 3); + for (const terminal of [firstTerminal, secondTerminal]) { + const resolved = await stores.operations.resolveExecutionConnection({ + kind: 'bound', + connectionId: terminal.connection.connectionId, + connectionSlug: terminal.connection.slug, + }); + assert.equal(resolved.kind, 'ready'); + if (resolved.kind === 'ready') { + assert.deepEqual( + parseOAuthSubscriptionTokens(resolved.secretMaterial.connection?.secret ?? ''), + { + access_token: 'host-access-token', + refresh_token: 'host-refresh-token', + expires_at: 1_900_000_000_000, + }, + ); + } } } finally { await first?.close().catch(() => undefined); @@ -265,10 +280,10 @@ async function assertProviderDisabledOverUds( ); await assert.rejects( - client.request('oauth.login.start', { - attemptId: `uds-disabled-${provider}`, - connectionId: connection.connectionId, - }), + client.request( + 'oauth.login.start', + oauthStart(`uds-disabled-${provider}`, connection.connectionId), + ), (error: unknown) => error instanceof RuntimeHostOperationError && error.code === 'operation_unavailable', ); @@ -302,3 +317,11 @@ async function waitForTerminal(client: RuntimeHostConnection, attemptId: string) } throw new Error('OAuth login did not settle'); } + +function oauthStart(attemptId: string, connectionId: string) { + return { attemptId, target: { kind: 'existing' as const, connectionId } }; +} + +function oauthCreateStart(attemptId: string, providerType: 'openai-codex' | 'xai-oauth') { + return { attemptId, target: { kind: 'create' as const, providerType } }; +} diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 81bcfd7543..86af74820d 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -215,6 +215,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 52); }); + test('publishes a new compatibility epoch for explicit OAuth Connection targets', () => { + // Epoch 53 peers still send connectionId directly and receive provider plus + // connectionId fields instead of one canonical Connection identity. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 53); + }); + test('publishes a new compatibility epoch for queued message editing', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 45); }); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index bfaa5ee28f..c02211477c 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 77 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 78 as const; +// 78: OAuth login targets explicit create/existing Connection entities and +// returns their canonical identity. Older peers reject both closed wire shapes. // 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the // Host-resolved session name for the usage Task column). Older Clients reject // the unknown field, so a newer Host's usage logs are unreadable to them. @@ -108,6 +110,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 77 as const; // unrelated provider for an interactive Session. // 73: Transcript pages carry a Host-owned Turn range boundary. Older peers // cannot preserve both the complete edge Turn and the bounded projection. +// 72: Collaboration Turn request query results require `canRequestTurns`. +// Older peers reject the new closed result shape. // 71: Session Guests can submit durable exact Turn access requests and Owners // can decide them. Older peers do not understand this execution-authority flow. // 70: Session Guest connections receive resource-scoped shared catalog and diff --git a/packages/runtime-host/src/protocol/oauth.ts b/packages/runtime-host/src/protocol/oauth.ts index 751d33d66c..86e2ec55f4 100644 --- a/packages/runtime-host/src/protocol/oauth.ts +++ b/packages/runtime-host/src/protocol/oauth.ts @@ -17,6 +17,7 @@ * under the License. */ +import { decodeConnectionSlug, RuntimePolicyDomainDecodeError } from '@maka/core/runtime-policy'; import { requireEntityId, requireExactRecord, @@ -45,6 +46,7 @@ export const OAUTH_LOGIN_FAILURE_CODES = [ 'authorization_failed', 'provider_rejected', 'credential_changed', + 'connection_changed', 'persistence_failed', 'internal_failure', ] as const; @@ -63,7 +65,7 @@ const START_ERRORS = [ 'not_found', 'persistence_failed', ] as const; -const ATTEMPT_ERRORS = [...COMMON_ERRORS, 'not_found'] as const; +const ATTEMPT_ERRORS = [...COMMON_ERRORS, 'not_found', 'persistence_failed'] as const; export type OAuthLoginProvider = (typeof OAUTH_LOGIN_PROVIDERS)[number]; export type OAuthLoginPhase = (typeof OAUTH_LOGIN_PHASES)[number]; @@ -83,15 +85,24 @@ export type OAuthPresentationResult = { readonly kind: 'presented' }; export interface OAuthLoginProjection { readonly attemptId: string; - readonly connectionId: string; - readonly provider: OAuthLoginProvider; + readonly connection: OAuthConnectionIdentity; readonly phase: OAuthLoginPhase; readonly failure?: OAuthLoginFailureCode; } export interface OAuthLoginStartInput { readonly attemptId: string; + readonly target: OAuthLoginTarget; +} + +export type OAuthLoginTarget = + | { readonly kind: 'create'; readonly providerType: OAuthLoginProvider } + | { readonly kind: 'existing'; readonly connectionId: string }; + +export interface OAuthConnectionIdentity { readonly connectionId: string; + readonly slug: string; + readonly providerType: OAuthLoginProvider; } export interface OAuthLoginAttemptInput { @@ -109,6 +120,7 @@ export const OAUTH_OPERATION_SPECS = { errors: START_ERRORS, decodeInput: decodeOAuthLoginStartInput, decodeOutput: decodeOAuthLoginProjection, + assertOutputForInput: assertOAuthStartOutput, }), 'oauth.login.query': defineOperation< OAuthLoginAttemptInput, @@ -120,6 +132,7 @@ export const OAUTH_OPERATION_SPECS = { errors: ATTEMPT_ERRORS, decodeInput: decodeOAuthLoginAttemptInput, decodeOutput: decodeOAuthLoginProjection, + assertOutputForInput: assertOAuthAttemptOutput, }), 'oauth.login.cancel': defineOperation< OAuthLoginAttemptInput, @@ -131,14 +144,15 @@ export const OAUTH_OPERATION_SPECS = { errors: ATTEMPT_ERRORS, decodeInput: decodeOAuthLoginAttemptInput, decodeOutput: decodeOAuthLoginProjection, + assertOutputForInput: assertOAuthAttemptOutput, }), } as const; export function decodeOAuthLoginStartInput(value: unknown): OAuthLoginStartInput { - const input = requireExactRecord(value, 'OAuth login start input', ['attemptId', 'connectionId']); + const input = requireExactRecord(value, 'OAuth login start input', ['attemptId', 'target']); return { attemptId: requireEntityId(input.attemptId, 'attemptId'), - connectionId: requireEntityId(input.connectionId, 'connectionId'), + target: decodeOAuthLoginTarget(input.target), }; } @@ -154,18 +168,75 @@ export function decodeOAuthLoginProjection(value: unknown): OAuthLoginProjection projection, 'OAuth login projection', phase === 'failed' - ? ['attemptId', 'connectionId', 'provider', 'phase', 'failure'] - : ['attemptId', 'connectionId', 'provider', 'phase'], + ? ['attemptId', 'connection', 'phase', 'failure'] + : ['attemptId', 'connection', 'phase'], ); return { attemptId: requireEntityId(exact.attemptId, 'attemptId'), - connectionId: requireEntityId(exact.connectionId, 'connectionId'), - provider: oauthLoginProvider(exact.provider), + connection: decodeOAuthConnectionIdentity(exact.connection), phase, ...(phase === 'failed' ? { failure: oauthLoginFailure(exact.failure) } : {}), }; } +function decodeOAuthLoginTarget(value: unknown): OAuthLoginTarget { + const target = requireRecord(value, 'OAuth login target'); + if (target.kind === 'create') { + const exact = requireExactRecord(target, 'OAuth create target', ['kind', 'providerType']); + return { kind: 'create', providerType: oauthLoginProvider(exact.providerType) }; + } + if (target.kind === 'existing') { + const exact = requireExactRecord(target, 'OAuth existing target', ['kind', 'connectionId']); + return { kind: 'existing', connectionId: requireEntityId(exact.connectionId, 'connectionId') }; + } + throw invalidProtocolFrame('Invalid OAuth login target'); +} + +function decodeOAuthConnectionIdentity(value: unknown): OAuthConnectionIdentity { + const connection = requireExactRecord(value, 'OAuth connection identity', [ + 'connectionId', + 'slug', + 'providerType', + ]); + return { + connectionId: requireEntityId(connection.connectionId, 'connectionId'), + slug: decodeDomain(() => decodeConnectionSlug(connection.slug)), + providerType: oauthLoginProvider(connection.providerType), + }; +} + +function assertOAuthStartOutput(input: OAuthLoginStartInput, output: OAuthLoginProjection): void { + assertOAuthAttemptOutput(input, output); + if ( + (input.target.kind === 'create' && + output.connection.providerType !== input.target.providerType) || + (input.target.kind === 'existing' && + output.connection.connectionId !== input.target.connectionId) + ) { + throw invalidProtocolFrame('OAuth login start changed Connection identity'); + } +} + +function assertOAuthAttemptOutput( + input: OAuthLoginAttemptInput, + output: OAuthLoginProjection, +): void { + if (input.attemptId !== output.attemptId) { + throw invalidProtocolFrame('OAuth login changed attempt identity'); + } +} + +function decodeDomain(operation: () => T): T { + try { + return operation(); + } catch (error) { + if (error instanceof RuntimePolicyDomainDecodeError) { + throw invalidProtocolFrame(error.message); + } + throw error; + } +} + export function decodeOAuthPresentationRequest( method: unknown, value: unknown, diff --git a/packages/runtime-host/src/server/oauth-coordinator.ts b/packages/runtime-host/src/server/oauth-coordinator.ts index 24fb1cfa6c..12e5721b3a 100644 --- a/packages/runtime-host/src/server/oauth-coordinator.ts +++ b/packages/runtime-host/src/server/oauth-coordinator.ts @@ -44,6 +44,7 @@ import { type OAuthLoginFailureCode, type OAuthLoginProjection, type OAuthLoginProvider, + type OAuthLoginTarget, type OAuthPresentationRequest, type OAuthPresentationResult, type OperationOutcome, @@ -96,7 +97,8 @@ type OAuthLoginAdmission = Extract< interface ActiveLoginAttempt { readonly kind: 'active'; readonly attemptId: string; - readonly connectionId: string; + readonly target: OAuthLoginTarget; + readonly connection: OAuthLoginProjection['connection']; readonly initiatingConnectionId: string; readonly provider: OAuthLoginProvider; readonly ticket: OAuthLoginAdmission; @@ -111,6 +113,7 @@ interface ActiveLoginAttempt { interface TerminalLoginAttempt { readonly kind: 'terminal'; + readonly target: OAuthLoginTarget; readonly projection: OAuthLoginProjection; } @@ -143,7 +146,7 @@ export class HostOAuthCoordinator { #activeAttempt: ActiveLoginAttempt | undefined; /** * Serializes oauth.login.start admissions so concurrent starts cannot dual-open - * interactive logins after supersede replaced operation_conflict. + * interactive logins around the active-attempt conflict check. */ #startGate: Promise = Promise.resolve(); #admissionClosed = false; @@ -185,12 +188,12 @@ export class HostOAuthCoordinator { } async #start( - input: { readonly attemptId: string; readonly connectionId: string }, + input: { readonly attemptId: string; readonly target: OAuthLoginTarget }, initiatingConnectionId: string, ): Promise> { const existing = this.#attempts.get(input.attemptId); if (existing) { - if (projection(existing).connectionId !== input.connectionId) { + if (!sameOAuthLoginTarget(existing.target, input.target)) { return invalidRequest('OAuth attemptId is already bound to another connection'); } return { ok: true, result: projection(existing) }; @@ -206,14 +209,34 @@ export class HostOAuthCoordinator { try { const again = this.#attempts.get(input.attemptId); if (again) { - if (projection(again).connectionId !== input.connectionId) { + if (!sameOAuthLoginTarget(again.target, input.target)) { return invalidRequest('OAuth attemptId is already bound to another connection'); } return { ok: true, result: projection(again) }; } - // User re-clicked 登录 after the browser already authorized (or abandoned) - // an earlier attempt. Supersede instead of blocking until process restart. - if (this.#activeAttempt) await this.#supersedeActiveLogin(); + let durable: Awaited< + ReturnType + >; + try { + durable = await this.#runtimePolicy.operations.queryInteractiveOAuthLogin(input.attemptId); + } catch (error) { + if (error instanceof RuntimePolicyStoreError) { + return persistenceFailure('OAuth login receipt query failed'); + } + throw error; + } + if (durable.kind === 'authenticated') { + if (!sameOAuthLoginTarget(durable.target, input.target)) { + return invalidRequest('OAuth attemptId is already bound to another connection'); + } + const terminal = authenticatedAttempt(input.target, input.attemptId, durable.connection); + this.#attempts.set(input.attemptId, terminal); + this.#pruneTerminalAttempts(); + return { ok: true, result: terminal.projection }; + } + if (this.#activeAttempt) { + return operationConflict('Another OAuth login is already in progress'); + } if (this.#admissionClosed) return hostDraining(); return await this.#prepareStart(input, initiatingConnectionId); } finally { @@ -221,39 +244,15 @@ export class HostOAuthCoordinator { } } - /** - * Cancel the active interactive login and wait until its residency is released. - * Used when the user starts a new login while a prior device-code poll is still open. - */ - async #supersedeActiveLogin(): Promise { - const previous = this.#activeAttempt; - if (!previous) return; - // Align with cancel: once a token poll is admitted or credentials are - // committing, finish that path instead of aborting a browser-approved grant. - if (previous.phase === 'committing' || previous.cancellationDeferred) { - await previous.settlement.catch(() => undefined); - return; - } - const reason = new DOMException('OAuth login superseded by a new attempt', 'AbortError'); - previous.cancelRequested = true; - if (previous.phase !== 'authenticated' && previous.phase !== 'failed') { - previous.phase = 'cancelled'; - } - if (!previous.abort.signal.aborted) previous.abort.abort(reason); - await previous.settlement.catch(() => undefined); - } - async #prepareStart( - input: { readonly attemptId: string; readonly connectionId: string }, + input: { readonly attemptId: string; readonly target: OAuthLoginTarget }, initiatingConnectionId: string, ): Promise> { let admitted: Awaited< ReturnType >; try { - admitted = await this.#runtimePolicy.operations.beginInteractiveOAuthLogin( - input.connectionId, - ); + admitted = await this.#runtimePolicy.operations.beginInteractiveOAuthLogin(input); } catch (error) { if (error instanceof RuntimePolicyStoreError) { return persistenceFailure('OAuth login admission failed'); @@ -263,6 +262,18 @@ export class HostOAuthCoordinator { if (admitted.kind === 'connection_not_found') { return notFound('OAuth connection was not found'); } + if (admitted.kind === 'catalog_full') { + return operationConflict('OAuth Connection capacity is exhausted'); + } + if (admitted.kind === 'attempt_conflict') { + return invalidRequest('OAuth attemptId is already bound to another connection'); + } + if (admitted.kind === 'authenticated') { + const terminal = authenticatedAttempt(input.target, input.attemptId, admitted.connection); + this.#attempts.set(input.attemptId, terminal); + this.#pruneTerminalAttempts(); + return { ok: true, result: terminal.projection }; + } if (admitted.kind !== 'ready') { return invalidRequest('Connection cannot start an interactive OAuth login'); } @@ -288,7 +299,8 @@ export class HostOAuthCoordinator { const attempt: ActiveLoginAttempt = { kind: 'active', attemptId: input.attemptId, - connectionId: input.connectionId, + target: input.target, + connection: admitted.identity, initiatingConnectionId, provider: admitted.connection.providerType, ticket: admitted, @@ -306,20 +318,34 @@ export class HostOAuthCoordinator { return { ok: true, result: projection(attempt) }; } - #query(attemptId: string): Promise> { + async #query(attemptId: string): Promise> { const attempt = this.#attempts.get(attemptId); - return Promise.resolve( - attempt ? { ok: true, result: projection(attempt) } : notFound('OAuth login was not found'), - ); + if (attempt) return { ok: true, result: projection(attempt) }; + let durable: Awaited< + ReturnType + >; + try { + durable = await this.#runtimePolicy.operations.queryInteractiveOAuthLogin(attemptId); + } catch (error) { + if (error instanceof RuntimePolicyStoreError) { + return persistenceFailure('OAuth login receipt query failed'); + } + throw error; + } + if (durable.kind === 'not_found') return notFound('OAuth login was not found'); + const terminal = authenticatedAttempt(durable.target, attemptId, durable.connection); + this.#attempts.set(attemptId, terminal); + this.#pruneTerminalAttempts(); + return { ok: true, result: terminal.projection }; } - #cancel(attemptId: string): Promise> { + async #cancel(attemptId: string): Promise> { const attempt = this.#attempts.get(attemptId); - if (!attempt) return Promise.resolve(notFound('OAuth login was not found')); + if (!attempt) return this.#query(attemptId); if (attempt.kind === 'active') { this.#requestCancellation(attempt, new DOMException('OAuth login cancelled', 'AbortError')); } - return Promise.resolve({ ok: true, result: projection(attempt) }); + return { ok: true, result: projection(attempt) }; } #requestCancellation(attempt: ActiveLoginAttempt, reason: Error): void { @@ -350,7 +376,11 @@ export class HostOAuthCoordinator { attempt.ticket.ticket, serializeOAuthSubscriptionTokens(tokens), ); - if (completion.kind !== 'committed') throw new LoginFailure('credential_changed'); + if (completion.kind !== 'committed') { + throw new LoginFailure( + completion.changed.includes('connection') ? 'connection_changed' : 'credential_changed', + ); + } await this.#invalidateAfterCredentialMutation(); }); attempt.phase = 'authenticated'; @@ -524,15 +554,43 @@ function projection(attempt: LoginAttemptRecord): OAuthLoginProjection { if (attempt.kind === 'terminal') return attempt.projection; return { attemptId: attempt.attemptId, - connectionId: attempt.connectionId, - provider: attempt.provider, + connection: attempt.connection, phase: attempt.phase, ...(attempt.phase === 'failed' ? { failure: attempt.failure ?? 'internal_failure' } : {}), }; } function terminalAttempt(attempt: ActiveLoginAttempt): TerminalLoginAttempt { - return Object.freeze({ kind: 'terminal', projection: Object.freeze(projection(attempt)) }); + return Object.freeze({ + kind: 'terminal', + target: attempt.target, + projection: Object.freeze(projection(attempt)), + }); +} + +function authenticatedAttempt( + target: OAuthLoginTarget, + attemptId: string, + connection: OAuthLoginProjection['connection'], +): TerminalLoginAttempt { + return Object.freeze({ + kind: 'terminal', + target: structuredClone(target), + projection: Object.freeze({ + attemptId, + connection: structuredClone(connection), + phase: 'authenticated', + }), + }); +} + +function sameOAuthLoginTarget(actual: OAuthLoginTarget, expected: OAuthLoginTarget): boolean { + return ( + actual.kind === expected.kind && + (actual.kind === 'create' + ? expected.kind === 'create' && actual.providerType === expected.providerType + : expected.kind === 'existing' && actual.connectionId === expected.connectionId) + ); } function loginFailureCode(error: unknown): OAuthLoginFailureCode { @@ -569,6 +627,10 @@ function operationUnavailable(message: string) { return { ok: false, error: { code: 'operation_unavailable', message } } as const; } +function operationConflict(message: string) { + return { ok: false, error: { code: 'operation_conflict', message } } as const; +} + function hostDraining(): OperationOutcome<'oauth.login.start'> { return { ok: false, diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 65ddab5445..5918c2d858 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -51,6 +51,13 @@ import { openInteractiveRuntimePolicyStoresForWrite, RuntimePolicyStoreError, } from '../runtime-policy-stores.js'; +import { ConnectionCatalogDocumentOwner } from '../runtime-policy/connection-catalog-document.js'; +import { CredentialVaultDocumentOwner } from '../runtime-policy/credential-vault-document.js'; +import { + prepareInteractiveOAuthEnrollmentIntent, + writeConnectionOnboardingIntent, +} from '../runtime-policy/onboarding-transaction.js'; +import { upsertInteractiveOAuthLoginReceipt } from '../runtime-policy/oauth-login-receipt-document.js'; import { removeControlDirectory } from './fixtures/control-directory-hygiene.js'; const execFileAsync = promisify(execFile); @@ -1352,7 +1359,10 @@ describe('runtime policy stores', () => { 'execution-retired', '66666666-6666-4666-8666-666666666666', ); - const retiredLogin = await stores.operations.beginInteractiveOAuthLogin(retired.connectionId); + const retiredLogin = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'retired-login', + target: { kind: 'existing', connectionId: retired.connectionId }, + }); assert.equal(retiredLogin.kind, 'provider_action_unavailable'); assert.deepEqual( await stores.operations.resolveExecutionConnection(catalogSlug(retired.slug)), @@ -3646,6 +3656,302 @@ describe('runtime policy stores', () => { }); }); + test('interactive OAuth create allocates distinct entities and keeps attempt identity durable', async () => { + await withInteractiveOwner(async ({ stores }) => { + const target = { kind: 'create' as const, providerType: 'openai-codex' as const }; + const first = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-first', + target, + }); + assert.equal(first.kind, 'ready'); + if (first.kind !== 'ready') return; + assert.match(first.identity.connectionId, UUID_PATTERN); + assert.equal(first.identity.slug, 'codex-subscription'); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).connections, []); + assert.deepEqual( + await stores.credentialVault.getStatus({ + scope: 'connection', + connectionId: first.identity.connectionId, + kind: 'oauth_token', + }), + { kind: 'connection_not_found' }, + ); + + const firstCompletion = await stores.operations.completeInteractiveOAuthLogin( + first.ticket, + 'oauth-create-secret-a', + ); + assert.equal(firstCompletion.kind, 'committed'); + if (firstCompletion.kind !== 'committed') return; + assert.deepEqual(firstCompletion.connection, first.identity); + assert.deepEqual(await stores.operations.queryInteractiveOAuthLogin('oauth-create-first'), { + kind: 'authenticated', + target, + connection: first.identity, + }); + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-first', + target, + }), + { + kind: 'authenticated', + target, + connection: first.identity, + }, + ); + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-first', + target: { kind: 'create', providerType: 'xai-oauth' }, + }), + { kind: 'attempt_conflict' }, + ); + + const second = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-second', + target, + }); + assert.equal(second.kind, 'ready'); + if (second.kind !== 'ready') return; + assert.notEqual(second.identity.connectionId, first.identity.connectionId); + assert.equal(second.identity.slug, 'codex-subscription-2'); + const secondCompletion = await stores.operations.completeInteractiveOAuthLogin( + second.ticket, + 'oauth-create-secret-b', + ); + assert.equal(secondCompletion.kind, 'committed'); + + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections.map(({ connectionId, slug }) => ({ connectionId, slug })), + [first.identity, second.identity].map(({ connectionId, slug }) => ({ + connectionId, + slug, + })), + ); + assert.equal(catalog.defaultTarget, null); + for (const identity of [first.identity, second.identity]) { + assert.equal( + ( + await getCredentialStatus(stores.credentialVault, { + scope: 'connection', + connectionId: identity.connectionId, + kind: 'oauth_token', + }) + ).configured, + true, + ); + } + }); + }); + + test('interactive OAuth existing login re-enables only its frozen entity', async () => { + await withInteractiveOwner(async ({ stores }) => { + const original = await createConnection(stores, 0, { + ...connectionDraft('codex-disabled', 'openai-codex', 'Personal Codex'), + enabled: false, + enabledModelIds: ['gpt-5.1-codex-mini'], + }); + const admitted = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-existing-disabled', + target: { kind: 'existing', connectionId: original.connectionId }, + }); + assert.equal(admitted.kind, 'ready'); + if (admitted.kind !== 'ready') return; + assert.deepEqual(admitted.identity, { + connectionId: original.connectionId, + slug: original.slug, + providerType: original.providerType, + }); + assert.equal((await stores.connectionCatalog.getSnapshot()).connections[0]?.enabled, false); + assert.equal( + ( + await stores.operations.completeInteractiveOAuthLogin( + admitted.ticket, + 'oauth-disabled-secret', + ) + ).kind, + 'committed', + ); + const catalog = await stores.connectionCatalog.getSnapshot(); + const reenabled = catalog.connections[0]; + assert.ok(reenabled); + assert.equal(reenabled.connectionId, original.connectionId); + assert.equal(reenabled.slug, original.slug); + assert.equal(reenabled.name, original.name); + assert.deepEqual(reenabled.enabledModelIds, original.enabledModelIds); + assert.equal(reenabled.enabled, true); + assert.equal(catalog.defaultTarget, null); + }); + }); + + test('OAuth enrollment fails closed when its exact entity or allocated slug drifts', async () => { + await withInteractiveOwner(async ({ stores }) => { + const createAdmission = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-slug-drift', + target: { kind: 'create', providerType: 'openai-codex' }, + }); + assert.equal(createAdmission.kind, 'ready'); + if (createAdmission.kind !== 'ready') return; + await createConnection(stores, 0, { + ...connectionDraft( + createAdmission.identity.slug, + 'openai-codex', + 'Concurrent Codex entity', + ), + enabledModelIds: [...PROVIDER_DEFAULTS['openai-codex'].fallbackModels], + }); + assert.deepEqual( + await stores.operations.completeInteractiveOAuthLogin( + createAdmission.ticket, + 'must-not-fallback', + ), + { kind: 'superseded', changed: ['connection'] }, + ); + assert.equal( + await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId: createAdmission.identity.connectionId, + kind: 'oauth_token', + }), + null, + ); + + const existing = (await stores.connectionCatalog.getSnapshot()).connections[0]; + assert.ok(existing); + const existingAdmission = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-existing-deleted', + target: { kind: 'existing', connectionId: existing.connectionId }, + }); + assert.equal(existingAdmission.kind, 'ready'); + if (existingAdmission.kind !== 'ready') return; + assert.equal( + (await stores.connectionCatalog.remove({ expected: connectionBasis(existing) })).kind, + 'committed', + ); + assert.deepEqual( + await stores.operations.completeInteractiveOAuthLogin( + existingAdmission.ticket, + 'must-not-rebind', + ), + { kind: 'superseded', changed: ['connection'] }, + ); + }); + }); + + test('OAuth enrollment recovery converges after every durable commit boundary', async () => { + const stages = ['journal', 'vault', 'catalog', 'receipt'] as const; + for (const [index, stage] of stages.entries()) { + await withInteractiveRoot(async ({ root, capability }) => { + const firstOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(firstOwner); + if (!firstOwner) return; + const attemptId = `oauth-recovery-${stage}`; + const secret = `oauth-recovery-secret-${stage}`; + let ready: Extract< + Awaited>, + { kind: 'ready' } + >; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(firstOwner.lease); + const admission = await stores.operations.beginInteractiveOAuthLogin({ + attemptId, + target: { kind: 'create', providerType: 'openai-codex' }, + }); + assert.equal(admission.kind, 'ready'); + if (admission.kind !== 'ready') return; + ready = admission; + } finally { + await firstOwner.close(); + } + + const target = { kind: 'create' as const, providerType: 'openai-codex' as const }; + const intent = prepareInteractiveOAuthEnrollmentIntent({ + attemptId, + target, + connectionBefore: null, + connectionAfter: ready.connection, + credentialBasis: null, + secret, + }); + await writeConnectionOnboardingIntent(root, intent); + let precommittedCredentialId: string | undefined; + + if (index >= 1) { + const vault = new CredentialVaultDocumentOwner(); + const committed = await vault.set(root, { + locator: { + scope: 'connection', + connectionId: ready.identity.connectionId, + kind: 'oauth_token', + }, + expected: null, + secret, + }); + assert.equal(committed.kind, 'committed'); + if (committed.kind !== 'committed') return; + const status = committed.snapshot.entries.find( + ({ locator }) => + locator.scope === 'connection' && + locator.connectionId === ready.identity.connectionId && + locator.kind === 'oauth_token', + ); + assert.equal(status?.configured, true); + precommittedCredentialId = status?.configured ? status.credentialId : undefined; + } + if (index >= 2) { + const catalog = new ConnectionCatalogDocumentOwner(); + const prepared = catalog.prepareOAuthEnrollmentUpsert( + await catalog.read(root), + null, + ready.connection, + ); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + await catalog.commitPreparedOnboarding(root, prepared); + } + if (index >= 3) { + await upsertInteractiveOAuthLoginReceipt(root, { + attemptId, + target, + connection: ready.identity, + }); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + assert.deepEqual(await stores.operations.queryInteractiveOAuthLogin(attemptId), { + kind: 'authenticated', + target, + connection: ready.identity, + }); + const snapshot = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + snapshot.connections.map(({ connectionId, slug }) => ({ connectionId, slug })), + [{ connectionId: ready.identity.connectionId, slug: ready.identity.slug }], + ); + assert.equal(snapshot.defaultTarget, null); + const credential = await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId: ready.identity.connectionId, + kind: 'oauth_token', + }); + assert.equal(credential?.secret, secret); + if (precommittedCredentialId) { + assert.equal(credential?.credentialId, precommittedCredentialId); + } + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await successor.close(); + } + }); + } + }); + test('interactive OAuth login commits only against its frozen connection and credential basis', async () => { await withInteractiveOwner(async ({ root, stores }) => { const claude = await createConnection( @@ -3653,8 +3959,14 @@ describe('runtime policy stores', () => { 0, connectionDraft('codex-login', 'openai-codex', 'Codex login'), ); - const first = await stores.operations.beginInteractiveOAuthLogin(claude.connectionId); - const second = await stores.operations.beginInteractiveOAuthLogin(claude.connectionId); + const first = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-first', + target: { kind: 'existing', connectionId: claude.connectionId }, + }); + const second = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-second', + target: { kind: 'existing', connectionId: claude.connectionId }, + }); assert.equal(first.kind, 'ready'); assert.equal(second.kind, 'ready'); if (first.kind !== 'ready' || second.kind !== 'ready') return; @@ -3678,7 +3990,10 @@ describe('runtime policy stores', () => { isStoreError('invalid_credential_input'), ); - const beforeUpdate = await stores.operations.beginInteractiveOAuthLogin(claude.connectionId); + const beforeUpdate = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-before-update', + target: { kind: 'existing', connectionId: claude.connectionId }, + }); assert.equal(beforeUpdate.kind, 'ready'); if (beforeUpdate.kind !== 'ready') return; const current = (await stores.connectionCatalog.getSnapshot()).connections.find( @@ -3705,13 +4020,19 @@ describe('runtime policy stores', () => { const copilot = await createConnection( stores, - 2, + (await stores.connectionCatalog.getSnapshot()).revision, connectionDraft('copilot-import', 'github-copilot', 'Copilot import'), ); - assert.deepEqual(await stores.operations.beginInteractiveOAuthLogin(copilot.connectionId), { - kind: 'provider_action_unavailable', - availability: 'hidden', - }); + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'copilot-login', + target: { kind: 'existing', connectionId: copilot.connectionId }, + }), + { + kind: 'provider_action_unavailable', + availability: 'hidden', + }, + ); // A retired provider keeps its stored connection, so the login entry // point is reachable and has to refuse on its own. @@ -3721,10 +4042,16 @@ describe('runtime policy stores', () => { 'claude-retired', '88888888-8888-4888-8888-888888888888', ); - assert.deepEqual(await stores.operations.beginInteractiveOAuthLogin(retired.connectionId), { - kind: 'provider_action_unavailable', - availability: 'hidden', - }); + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'retired-oauth-login', + target: { kind: 'existing', connectionId: retired.connectionId }, + }), + { + kind: 'provider_action_unavailable', + availability: 'hidden', + }, + ); }); }); diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index 98b3bc5574..6251ea6c3a 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -67,7 +67,11 @@ export type { ConnectionTestTicket, InteractiveOAuthLoginCompletionResult, InteractiveOAuthLoginProvider, + InteractiveOAuthLoginInput, + InteractiveOAuthLoginTarget, + InteractiveOAuthConnectionIdentity, InteractiveOAuthLoginTicket, + QueryInteractiveOAuthLoginResult, CredentialStatusQueryResult, ModelFetchTicket, ProviderAuthKind, @@ -244,8 +248,9 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic resolveNetworkProxyExecution: (input) => coordinator.resolveNetworkProxyExecution(input), compareAndSetOAuthCredential: (input) => coordinator.compareAndSetOAuthCredential(input), importConnectionCredential: (input) => coordinator.importConnectionCredential(input), - beginInteractiveOAuthLogin: (connectionId) => - coordinator.beginInteractiveOAuthLogin(connectionId), + beginInteractiveOAuthLogin: (input) => coordinator.beginInteractiveOAuthLogin(input), + queryInteractiveOAuthLogin: (attemptId) => + coordinator.queryInteractiveOAuthLogin(attemptId), completeInteractiveOAuthLogin: (ticket, secret) => coordinator.completeInteractiveOAuthLogin(ticket, secret), beginModelFetch: (connectionId) => coordinator.beginModelFetch(connectionId), diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 3cd7e38dad..b89660c1e3 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -650,6 +650,57 @@ export class ConnectionCatalogDocumentOwner { return { kind: 'ready', document: next, changed: true }; } + prepareOAuthEnrollmentUpsert( + current: ConnectionCatalogDocument, + connectionBefore: ConnectionCatalogEntry | null, + rawConnectionAfter: ConnectionCatalogEntry, + ): + | PreparedOnboardingResult + | { readonly kind: 'connection_conflict' } + | { readonly kind: 'catalog_full' } { + const connectionAfter = decodeConnectionInput(() => + decodeCanonicalConnectionCatalogEntry(rawConnectionAfter), + ); + const idIndex = current.connections.findIndex( + (connection) => connection.connectionId === connectionAfter.connectionId, + ); + const slugIndex = current.connections.findIndex( + (connection) => connection.slug === connectionAfter.slug, + ); + if (connectionBefore === null) { + if (idIndex >= 0 || slugIndex >= 0) { + const exact = + idIndex >= 0 && + idIndex === slugIndex && + isDeepStrictEqual(current.connections[idIndex], connectionAfter); + return exact + ? { kind: 'ready', document: current, changed: false } + : { kind: 'connection_conflict' }; + } + if (current.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + return { kind: 'catalog_full' }; + } + const next = this.nextDocument(current, [...current.connections, connectionAfter]); + this.assertDocumentSize(next); + return { kind: 'ready', document: next, changed: true }; + } + if (idIndex < 0 || (slugIndex >= 0 && slugIndex !== idIndex)) { + return { kind: 'connection_conflict' }; + } + const actual = current.connections[idIndex]; + if (isDeepStrictEqual(actual, connectionAfter)) { + return { kind: 'ready', document: current, changed: false }; + } + if (!isDeepStrictEqual(actual, connectionBefore)) { + return { kind: 'connection_conflict' }; + } + const connections = [...current.connections]; + connections[idIndex] = connectionAfter; + const next = this.nextDocument(current, connections); + this.assertDocumentSize(next); + return { kind: 'ready', document: next, changed: true }; + } + async commitPreparedOnboarding( root: string, prepared: PreparedOnboardingResult, diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index ea87df952d..3934439ac9 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -59,12 +59,13 @@ import { deriveProviderAuthContract, type ProviderAuthAction } from '@maka/core/ import { isRetiredProvider } from '@maka/core/provider-registry'; import { deriveConnectionSlug, + deriveInteractiveOAuthConnectionSlug, effectiveBaseUrl, PROVIDER_DEFAULTS, providerAuthSupportsApiKey, type ProviderType, } from '@maka/core/llm-connections'; -import { deepFreeze } from './codec.js'; +import { deepFreeze, nextRevision } from './codec.js'; import { catalogSnapshot, connectionBasis, @@ -109,7 +110,9 @@ import { type ConnectionOnboardingTicket, type ConnectionTestTicket, type InteractiveOAuthLoginCompletionResult, + type InteractiveOAuthLoginInput, type InteractiveOAuthLoginProvider, + type InteractiveOAuthLoginTarget, type InteractiveOAuthLoginTicket, type ModelFetchTicket, type ExecutionConnectionRef, @@ -126,10 +129,18 @@ import { import { clearConnectionOnboardingIntent, prepareConnectionOnboardingIntent, + prepareInteractiveOAuthEnrollmentIntent, readConnectionOnboardingIntent, writeConnectionOnboardingIntent, type ConnectionOnboardingIntent, + type InteractiveOAuthEnrollmentIntent, } from './onboarding-transaction.js'; +import { + findInteractiveOAuthLoginReceipt, + readInteractiveOAuthLoginReceipts, + sameInteractiveOAuthLoginTarget, + upsertInteractiveOAuthLoginReceipt, +} from './oauth-login-receipt-document.js'; import { policySnapshot, RuntimePolicyDocumentOwner } from './policy-document.js'; import { SerializedOperationLane } from '../serialized-operation-lane.js'; @@ -227,8 +238,12 @@ interface ConnectionOnboardingTicketRecord { interface InteractiveOAuthLoginTicketRecord { readonly kind: 'interactive_oauth_login'; - readonly connectionBasis: ConnectionVersionBasis; - readonly providerType: InteractiveOAuthLoginProvider; + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; + readonly connectionBefore: ConnectionCatalogEntry | null; + readonly connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }; readonly credentialBasis: CredentialVersionBasis | null; state: TicketState; } @@ -254,6 +269,7 @@ export class RuntimePolicyCoordinator { return this.lane.run(async (root) => { await cleanupRuntimePolicyDocumentTemps(root); await this.recoverConnectionOnboarding(root); + await readInteractiveOAuthLoginReceipts(root); const catalog = await this.catalog.read(root); const vault = await this.vault.read(root); await this.vault.deleteOrphanedConnectionCredentials( @@ -477,15 +493,57 @@ export class RuntimePolicyCoordinator { }); } - beginInteractiveOAuthLogin(rawConnectionId: string): Promise { + beginInteractiveOAuthLogin(rawInput: InteractiveOAuthLoginInput): Promise { return this.inLane(async (root) => { - const connectionId = decodeConnectionInput(() => - decodeRuntimePolicyEntityId(rawConnectionId), - ); + const input = normalizeInteractiveOAuthLoginInput(rawInput); + const receipts = await readInteractiveOAuthLoginReceipts(root); + const receipt = findInteractiveOAuthLoginReceipt(receipts, input.attemptId); + if (receipt) { + return deepFreeze( + sameInteractiveOAuthLoginTarget(receipt.target, input.target) + ? { + kind: 'authenticated' as const, + target: structuredClone(receipt.target), + connection: structuredClone(receipt.connection), + } + : { kind: 'attempt_conflict' as const }, + ); + } const catalog = await this.catalog.read(root); - const connection = findConnection(catalog, { connectionId }); - if (!connection) return deepFreeze({ kind: 'connection_not_found' as const }); - if (!connection.enabled) return deepFreeze({ kind: 'connection_disabled' as const }); + let connectionBefore: ConnectionCatalogEntry | null; + let connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }; + if (input.target.kind === 'create') { + if (catalog.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + return deepFreeze({ kind: 'catalog_full' as const }); + } + connectionBefore = null; + connectionAfter = newInteractiveOAuthConnection( + randomUUID(), + deriveInteractiveOAuthConnectionSlug( + input.target.providerType, + catalog.connections.map(({ slug }) => slug), + ), + input.target.providerType, + ); + } else { + const existing = findConnection(catalog, { connectionId: input.target.connectionId }); + if (!existing) return deepFreeze({ kind: 'connection_not_found' as const }); + if (!isInteractiveOAuthLoginProvider(existing.providerType)) { + return deepFreeze({ + kind: 'provider_action_unavailable' as const, + availability: 'hidden' as const, + }); + } + connectionBefore = structuredClone(existing); + connectionAfter = reenabledInteractiveOAuthConnection( + existing as ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }, + ); + } + const connection = connectionBefore ?? connectionAfter; if (!isInteractiveOAuthLoginProvider(connection.providerType)) { return deepFreeze({ kind: 'provider_action_unavailable' as const, @@ -515,16 +573,18 @@ export class RuntimePolicyCoordinator { } const existing = findCredential(await this.vault.read(root), locator); const ticket = this.issueInteractiveOAuthLoginTicket( - connectionBasis(connection), - connection.providerType, + input.attemptId, + input.target, + connectionBefore, + connectionAfter, existing ? credentialBasis(existing) : null, ); return deepFreeze({ kind: 'ready' as const, ticket, - connection: structuredClone(connection) as ConnectionCatalogEntry & { - readonly providerType: InteractiveOAuthLoginProvider; - }, + target: structuredClone(input.target), + identity: interactiveOAuthConnectionIdentity(connectionAfter), + connection: structuredClone(connectionAfter), secretMaterial: prepared.secretMaterial.networkProxy ? { networkProxy: prepared.secretMaterial.networkProxy } : {}, @@ -533,6 +593,25 @@ export class RuntimePolicyCoordinator { }); } + queryInteractiveOAuthLogin(rawAttemptId: string) { + return this.inLane(async (root) => { + const attemptId = decodeInteractiveOAuthAttemptId(rawAttemptId, 'invalid_connection_input'); + const receipt = findInteractiveOAuthLoginReceipt( + await readInteractiveOAuthLoginReceipts(root), + attemptId, + ); + return deepFreeze( + receipt + ? { + kind: 'authenticated' as const, + target: structuredClone(receipt.target), + connection: structuredClone(receipt.connection), + } + : { kind: 'not_found' as const }, + ); + }); + } + async completeInteractiveOAuthLogin( ticket: InteractiveOAuthLoginTicket, rawSecret: string, @@ -542,19 +621,18 @@ export class RuntimePolicyCoordinator { this.inLane(async (root) => { const secret = decodeCredentialInput(() => normalizeCredentialSecret(rawSecret)); const catalog = await this.catalog.read(root); - const connection = findConnection(catalog, claimed.connectionBasis); const changed: Array<'connection' | 'credential'> = []; - if ( - !connection || - connection.revision !== claimed.connectionBasis.revision || - connection.providerType !== claimed.providerType || - !connection.enabled - ) { + const preparedCatalog = this.catalog.prepareOAuthEnrollmentUpsert( + catalog, + claimed.connectionBefore, + claimed.connectionAfter, + ); + if (preparedCatalog.kind !== 'ready') { changed.push('connection'); } const locator = { scope: 'connection', - connectionId: claimed.connectionBasis.connectionId, + connectionId: claimed.connectionAfter.connectionId, kind: 'oauth_token', } as const; const vault = await this.vault.read(root); @@ -569,43 +647,33 @@ export class RuntimePolicyCoordinator { if (changed.length > 0) { return deepFreeze({ kind: 'superseded' as const, changed }); } - const prepared = this.vault.prepareSet(vault, { - locator, - expected: claimed.credentialBasis - ? { - credentialId: claimed.credentialBasis.credentialId, - revision: claimed.credentialBasis.revision, - } - : null, + const intent = prepareInteractiveOAuthEnrollmentIntent({ + attemptId: claimed.attemptId, + target: claimed.target, + connectionBefore: claimed.connectionBefore, + connectionAfter: claimed.connectionAfter, + credentialBasis: claimed.credentialBasis, secret, }); - if (prepared.kind !== 'ready') { - return deepFreeze({ - kind: 'superseded' as const, - changed: ['credential'] as const, - }); - } - const cleared = await this.catalog.clearConnectionLastTest( - root, - catalog, - locator.connectionId, - ); try { - await this.vault.commitSet(root, prepared); + await writeConnectionOnboardingIntent(root, intent); } catch (error) { - if (cleared) { - throw commitOutcomeUnknown( - 'Connection verification was cleared before OAuth login completed', - error, - ); - } + if (isCommitOutcomeUnknown(error)) this.onboardingRecoveryRequired = true; throw error; } - return deepFreeze({ - kind: 'committed' as const, - credentialId: prepared.entry.credentialId, - revision: prepared.entry.revision, - }); + try { + const result = await this.applyInteractiveOAuthEnrollment(root, intent); + await clearConnectionOnboardingIntent(root); + this.onboardingRecoveryRequired = false; + return deepFreeze({ kind: 'committed' as const, ...result }); + } catch (error) { + this.onboardingRecoveryRequired = true; + if (isCommitOutcomeUnknown(error)) throw error; + throw commitOutcomeUnknown( + 'OAuth enrollment has a durable intent and must recover before retrying', + error, + ); + } }), ); } @@ -1576,15 +1644,21 @@ export class RuntimePolicyCoordinator { } private issueInteractiveOAuthLoginTicket( - connectionBasisValue: ConnectionVersionBasis, - providerType: InteractiveOAuthLoginProvider, + attemptId: string, + target: InteractiveOAuthLoginTarget, + connectionBefore: ConnectionCatalogEntry | null, + connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }, credentialBasisValue: CredentialVersionBasis | null, ): InteractiveOAuthLoginTicket { const ticket = Object.freeze(Object.create(null)) as object; this.tickets.set(ticket, { kind: 'interactive_oauth_login', - connectionBasis: connectionBasisValue, - providerType, + attemptId, + target: structuredClone(target), + connectionBefore: connectionBefore ? structuredClone(connectionBefore) : null, + connectionAfter: structuredClone(connectionAfter), credentialBasis: credentialBasisValue, state: 'available', }); @@ -1636,7 +1710,11 @@ export class RuntimePolicyCoordinator { } this.onboardingRecoveryRequired = true; try { - await this.applyConnectionOnboarding(root, intent); + if (intent.schemaVersion === 3) { + await this.applyInteractiveOAuthEnrollment(root, intent); + } else { + await this.applyConnectionOnboarding(root, intent); + } await clearConnectionOnboardingIntent(root); this.onboardingRecoveryRequired = false; } catch (error) { @@ -1650,6 +1728,94 @@ export class RuntimePolicyCoordinator { } } + private async applyInteractiveOAuthEnrollment( + root: string, + intent: InteractiveOAuthEnrollmentIntent, + ): Promise<{ + readonly credentialId: string; + readonly revision: number; + readonly connection: ReturnType; + }> { + const existingReceipt = findInteractiveOAuthLoginReceipt( + await readInteractiveOAuthLoginReceipts(root), + intent.attemptId, + ); + const intendedIdentity = interactiveOAuthConnectionIdentity(intent.connectionAfter); + if ( + existingReceipt && + (!sameInteractiveOAuthLoginTarget(existingReceipt.target, intent.target) || + existingReceipt.connection.connectionId !== intendedIdentity.connectionId || + existingReceipt.connection.slug !== intendedIdentity.slug || + existingReceipt.connection.providerType !== intendedIdentity.providerType) + ) { + throw codecError('invalid_document', 'OAuth login receipt conflicts with the enrollment intent'); + } + const catalog = await this.catalog.read(root); + // Validate the complete catalog transition before the vault-first write. + // A damaged intent must never rotate a real account and discover its + // identity collision only afterwards. + const catalogPrepared = this.catalog.prepareOAuthEnrollmentUpsert( + catalog, + intent.connectionBefore, + intent.connectionAfter, + ); + if (catalogPrepared.kind !== 'ready') { + throw codecError( + 'invalid_document', + `OAuth enrollment catalog preflight returned ${catalogPrepared.kind}`, + ); + } + const locator = { + scope: 'connection', + connectionId: intent.connectionAfter.connectionId, + kind: 'oauth_token', + } as const; + const vault = await this.vault.read(root); + let credential = findCredential(vault, locator); + if (credential?.secret !== intent.secret) { + if ( + intent.credentialBasis + ? !sameCredentialBasis(credential, intent.credentialBasis) + : credential !== undefined + ) { + throw codecError('invalid_document', 'OAuth enrollment credential basis changed'); + } + const prepared = this.vault.prepareSet(vault, { + locator, + expected: intent.credentialBasis + ? { + credentialId: intent.credentialBasis.credentialId, + revision: intent.credentialBasis.revision, + } + : null, + secret: intent.secret, + }); + if (prepared.kind !== 'ready') { + throw codecError( + 'invalid_document', + `OAuth enrollment credential write returned ${prepared.kind}`, + ); + } + await this.vault.commitSet(root, prepared); + credential = prepared.entry; + } + if (!credential) { + throw codecError('invalid_document', 'OAuth enrollment did not produce a credential'); + } + await this.catalog.commitPreparedOnboarding(root, catalogPrepared); + const connection = intendedIdentity; + await upsertInteractiveOAuthLoginReceipt(root, { + attemptId: intent.attemptId, + target: intent.target, + connection, + }); + return { + credentialId: credential.credentialId, + revision: credential.revision, + connection, + }; + } + private async applyConnectionOnboarding( root: string, intent: ConnectionOnboardingIntent, @@ -1947,3 +2113,79 @@ function isInteractiveOAuthLoginProvider( ): providerType is InteractiveOAuthLoginProvider { return providerType === 'openai-codex' || providerType === 'xai-oauth'; } + +function normalizeInteractiveOAuthLoginInput( + input: InteractiveOAuthLoginInput, +): InteractiveOAuthLoginInput { + const attemptId = decodeInteractiveOAuthAttemptId(input?.attemptId, 'invalid_connection_input'); + const target = input?.target; + if (target?.kind === 'create') { + const providerType = decodeConnectionInput(() => decodeProviderType(target.providerType)); + if (!isInteractiveOAuthLoginProvider(providerType)) { + throw codecError('invalid_connection_input', 'OAuth create target provider is unsupported'); + } + return { attemptId, target: { kind: 'create', providerType } }; + } + if (target?.kind === 'existing') { + return { + attemptId, + target: { + kind: 'existing', + connectionId: decodeConnectionInput(() => + decodeRuntimePolicyEntityId(target.connectionId), + ), + }, + }; + } + throw codecError('invalid_connection_input', 'Unknown interactive OAuth login target'); +} + +function newInteractiveOAuthConnection( + connectionId: string, + slug: string, + providerType: InteractiveOAuthLoginProvider, +): ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider } { + const defaults = PROVIDER_DEFAULTS[providerType]; + return { + connectionId, + revision: 1, + slug, + name: defaults.label, + providerType, + enabled: true, + enabledModelIds: [...defaults.fallbackModels], + models: [], + }; +} + +function reenabledInteractiveOAuthConnection( + connection: ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider }, +): ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider } { + if (connection.enabled && connection.lastTest === undefined) return structuredClone(connection); + const { lastTest: _lastTest, ...withoutLastTest } = connection; + return { + ...withoutLastTest, + revision: nextRevision(connection.revision), + enabled: true, + }; +} + +function interactiveOAuthConnectionIdentity( + connection: ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider }, +) { + return { + connectionId: connection.connectionId, + slug: connection.slug, + providerType: connection.providerType, + } as const; +} + +function decodeInteractiveOAuthAttemptId( + value: unknown, + source: 'invalid_connection_input' | 'invalid_document', +): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw codecError(source, 'OAuth attempt id is invalid'); + } + return value; +} diff --git a/packages/storage/src/runtime-policy/document-io.ts b/packages/storage/src/runtime-policy/document-io.ts index df7d3bc137..673e9da054 100644 --- a/packages/storage/src/runtime-policy/document-io.ts +++ b/packages/storage/src/runtime-policy/document-io.ts @@ -35,7 +35,7 @@ export const VAULT_DOCUMENT_MAX_BYTES = 2 * 1024 * 1024; const READ_CHUNK_BYTES = 64 * 1024; const RUNTIME_POLICY_TEMP_PATTERN = - /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; + /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding|runtime-policy-oauth-login-receipts)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; export async function cleanupRuntimePolicyDocumentTemps(root: string): Promise { let failure: unknown; diff --git a/packages/storage/src/runtime-policy/oauth-login-receipt-document.test.ts b/packages/storage/src/runtime-policy/oauth-login-receipt-document.test.ts new file mode 100644 index 0000000000..bd83bda721 --- /dev/null +++ b/packages/storage/src/runtime-policy/oauth-login-receipt-document.test.ts @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + findInteractiveOAuthLoginReceipt, + MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS, + readInteractiveOAuthLoginReceipts, + upsertInteractiveOAuthLoginReceipt, +} from './oauth-login-receipt-document.js'; + +test('OAuth receipt retention has one explicit 256-attempt idempotency window', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-oauth-receipts-')); + try { + for (let index = 0; index <= MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS; index += 1) { + await upsertInteractiveOAuthLoginReceipt(root, receipt(index)); + } + const retained = await readInteractiveOAuthLoginReceipts(root); + assert.equal(retained.receipts.length, MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS); + assert.equal(findInteractiveOAuthLoginReceipt(retained, 'attempt-0'), undefined); + assert.equal(findInteractiveOAuthLoginReceipt(retained, 'attempt-1')?.completionOrder, 2); + assert.equal( + findInteractiveOAuthLoginReceipt(retained, `attempt-${MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS}`) + ?.completionOrder, + MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS + 1, + ); + + // Eviction intentionally ends the old idempotency claim: the same key can + // name a later attempt and receives a new completion order. + const reused = await upsertInteractiveOAuthLoginReceipt(root, receipt(0)); + assert.equal(reused.completionOrder, MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS + 2); + assert.equal( + findInteractiveOAuthLoginReceipt(await readInteractiveOAuthLoginReceipts(root), 'attempt-0') + ?.completionOrder, + reused.completionOrder, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a retained OAuth attempt cannot be rebound to another target or entity', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-oauth-receipts-')); + try { + const original = await upsertInteractiveOAuthLoginReceipt(root, receipt(7)); + assert.deepEqual(await upsertInteractiveOAuthLoginReceipt(root, receipt(7)), original); + await assert.rejects( + upsertInteractiveOAuthLoginReceipt(root, { + ...receipt(7), + target: { kind: 'create', providerType: 'xai-oauth' }, + connection: { + ...receipt(7).connection, + slug: 'xai-oauth', + providerType: 'xai-oauth', + }, + }), + /receipt conflicts/u, + ); + await assert.rejects( + upsertInteractiveOAuthLoginReceipt(root, { + ...receipt(7), + connection: { ...receipt(8).connection }, + }), + /receipt conflicts/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function receipt(index: number) { + return { + attemptId: `attempt-${index}`, + target: { kind: 'create' as const, providerType: 'openai-codex' as const }, + connection: { + connectionId: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + slug: index === 0 ? 'codex-subscription' : `codex-subscription-${index + 1}`, + providerType: 'openai-codex' as const, + }, + }; +} diff --git a/packages/storage/src/runtime-policy/oauth-login-receipt-document.ts b/packages/storage/src/runtime-policy/oauth-login-receipt-document.ts new file mode 100644 index 0000000000..5a5d12974b --- /dev/null +++ b/packages/storage/src/runtime-policy/oauth-login-receipt-document.ts @@ -0,0 +1,271 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + decodeConnectionSlug, + decodeProviderType, + decodeRuntimePolicyEntityId, +} from '@maka/core/runtime-policy'; +import { integer, record } from './codec.js'; +import { codecError, decodePersistedDomain } from './errors.js'; +import { readBoundedJsonDocument, writeJsonDocument } from './document-io.js'; +import type { + InteractiveOAuthConnectionIdentity, + InteractiveOAuthLoginProvider, + InteractiveOAuthLoginTarget, +} from './operations.js'; + +const FILE = 'runtime-policy-oauth-login-receipts.json'; +const SCHEMA_VERSION = 1 as const; +const MAX_BYTES = 256 * 1024; +export const MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS = 256; + +export interface InteractiveOAuthLoginReceipt { + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; + readonly connection: InteractiveOAuthConnectionIdentity; + readonly phase: 'authenticated'; + readonly completionOrder: number; +} + +interface ReceiptDocument { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly nextCompletionOrder: number; + readonly receipts: readonly InteractiveOAuthLoginReceipt[]; +} + +const EMPTY: ReceiptDocument = { + schemaVersion: SCHEMA_VERSION, + nextCompletionOrder: 1, + receipts: [], +}; + +export async function readInteractiveOAuthLoginReceipts(root: string): Promise { + const value = await readBoundedJsonDocument(root, FILE, MAX_BYTES); + if (value === undefined) return EMPTY; + const document = record(value, FILE, 'invalid_document', [ + 'schemaVersion', + 'nextCompletionOrder', + 'receipts', + ]); + if (document.schemaVersion !== SCHEMA_VERSION) { + throw codecError('invalid_document', `${FILE} has an unsupported schema version`); + } + if (!Array.isArray(document.receipts)) { + throw codecError('invalid_document', `${FILE}.receipts must be an array`); + } + const nextCompletionOrder = integer( + document.nextCompletionOrder, + `${FILE}.nextCompletionOrder`, + 1, + Number.MAX_SAFE_INTEGER, + 'invalid_document', + ); + const receipts = document.receipts.map((item, index) => decodeReceipt(item, index)); + if (receipts.length > MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS) { + throw codecError('invalid_document', `${FILE} exceeds its receipt limit`); + } + const attemptIds = new Set(); + let previousOrder = 0; + for (const receipt of receipts) { + if (attemptIds.has(receipt.attemptId)) { + throw codecError('invalid_document', `${FILE} repeats an attempt id`); + } + attemptIds.add(receipt.attemptId); + if (receipt.completionOrder <= previousOrder) { + throw codecError('invalid_document', `${FILE} receipts are not in completion order`); + } + previousOrder = receipt.completionOrder; + } + if (previousOrder >= nextCompletionOrder) { + throw codecError('invalid_document', `${FILE} completion order is invalid`); + } + return { schemaVersion: SCHEMA_VERSION, nextCompletionOrder, receipts }; +} + +export function findInteractiveOAuthLoginReceipt( + document: ReceiptDocument, + attemptId: string, +): InteractiveOAuthLoginReceipt | undefined { + return document.receipts.find((receipt) => receipt.attemptId === attemptId); +} + +export async function upsertInteractiveOAuthLoginReceipt( + root: string, + input: Omit, +): Promise { + if (!targetMatchesIdentity(input.target, input.connection)) { + throw codecError('invalid_document', 'OAuth login receipt identity is inconsistent'); + } + const document = await readInteractiveOAuthLoginReceipts(root); + const existing = findInteractiveOAuthLoginReceipt(document, input.attemptId); + if (existing) { + if ( + !sameTarget(existing.target, input.target) || + !sameIdentity(existing.connection, input.connection) + ) { + throw codecError( + 'invalid_document', + 'OAuth login receipt conflicts with the enrollment intent', + ); + } + return existing; + } + if (document.nextCompletionOrder >= Number.MAX_SAFE_INTEGER) { + throw codecError('invalid_document', 'OAuth login receipt order is exhausted'); + } + const receipt: InteractiveOAuthLoginReceipt = { + ...input, + phase: 'authenticated', + completionOrder: document.nextCompletionOrder, + }; + const receipts = [...document.receipts, receipt].slice(-MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS); + await writeJsonDocument( + root, + FILE, + { + schemaVersion: SCHEMA_VERSION, + nextCompletionOrder: document.nextCompletionOrder + 1, + receipts, + } satisfies ReceiptDocument, + MAX_BYTES, + ); + return receipt; +} + +export function sameInteractiveOAuthLoginTarget( + actual: InteractiveOAuthLoginTarget, + expected: InteractiveOAuthLoginTarget, +): boolean { + return sameTarget(actual, expected); +} + +function decodeReceipt(value: unknown, index: number): InteractiveOAuthLoginReceipt { + const item = record(value, `${FILE}.receipts[${index}]`, 'invalid_document', [ + 'attemptId', + 'target', + 'connection', + 'phase', + 'completionOrder', + ]); + if (item.phase !== 'authenticated') { + throw codecError('invalid_document', 'OAuth login receipt phase is invalid'); + } + const target = decodeTarget(item.target); + const connection = decodeIdentity(item.connection); + if (!targetMatchesIdentity(target, connection)) { + throw codecError('invalid_document', 'OAuth login receipt identity is inconsistent'); + } + return { + attemptId: decodeAttemptId(item.attemptId), + target, + connection, + phase: 'authenticated', + completionOrder: integer( + item.completionOrder, + `${FILE}.receipts[${index}].completionOrder`, + 1, + Number.MAX_SAFE_INTEGER, + 'invalid_document', + ), + }; +} + +function decodeTarget(value: unknown): InteractiveOAuthLoginTarget { + const base = record( + value, + 'OAuth login receipt target', + 'invalid_document', + ['kind', 'providerType', 'connectionId'], + ['kind'], + ); + if (base.kind === 'create') { + const item = record(value, 'OAuth create target', 'invalid_document', ['kind', 'providerType']); + return { kind: 'create', providerType: decodeOAuthProvider(item.providerType) }; + } + if (base.kind === 'existing') { + const item = record(value, 'OAuth existing target', 'invalid_document', [ + 'kind', + 'connectionId', + ]); + return { kind: 'existing', connectionId: decodeId(item.connectionId) }; + } + throw codecError('invalid_document', 'OAuth login receipt target kind is invalid'); +} + +function decodeIdentity(value: unknown): InteractiveOAuthConnectionIdentity { + const item = record(value, 'OAuth login receipt connection', 'invalid_document', [ + 'connectionId', + 'slug', + 'providerType', + ]); + return { + connectionId: decodeId(item.connectionId), + slug: decodePersistedDomain(() => decodeConnectionSlug(item.slug)), + providerType: decodeOAuthProvider(item.providerType), + }; +} + +function decodeOAuthProvider(value: unknown): InteractiveOAuthLoginProvider { + const providerType = decodePersistedDomain(() => decodeProviderType(value)); + if (providerType !== 'openai-codex' && providerType !== 'xai-oauth') { + throw codecError('invalid_document', 'OAuth login receipt provider is invalid'); + } + return providerType; +} + +function decodeId(value: unknown): string { + return decodePersistedDomain(() => decodeRuntimePolicyEntityId(value)); +} + +function decodeAttemptId(value: unknown): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw codecError('invalid_document', 'OAuth login receipt attempt id is invalid'); + } + return value; +} + +function sameTarget(actual: InteractiveOAuthLoginTarget, expected: InteractiveOAuthLoginTarget) { + return ( + actual.kind === expected.kind && + (actual.kind === 'create' + ? expected.kind === 'create' && actual.providerType === expected.providerType + : expected.kind === 'existing' && actual.connectionId === expected.connectionId) + ); +} + +function sameIdentity( + actual: InteractiveOAuthConnectionIdentity, + expected: InteractiveOAuthConnectionIdentity, +) { + return ( + actual.connectionId === expected.connectionId && + actual.slug === expected.slug && + actual.providerType === expected.providerType + ); +} + +function targetMatchesIdentity( + target: InteractiveOAuthLoginTarget, + connection: InteractiveOAuthConnectionIdentity, +): boolean { + return target.kind === 'create' + ? target.providerType === connection.providerType + : target.connectionId === connection.connectionId; +} diff --git a/packages/storage/src/runtime-policy/onboarding-transaction.ts b/packages/storage/src/runtime-policy/onboarding-transaction.ts index 1694d044c0..7d3786f864 100644 --- a/packages/storage/src/runtime-policy/onboarding-transaction.ts +++ b/packages/storage/src/runtime-policy/onboarding-transaction.ts @@ -21,6 +21,8 @@ import { unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { decodeProviderType, + decodeCanonicalConnectionCatalogEntry, + decodeCredentialVersionBasis, decodeConnectionSlug, decodeRuntimePolicyEntityId, normalizeCatalogConnectionBaseUrl, @@ -28,6 +30,8 @@ import { normalizeConnectionModelDiscoveryResult, normalizeCredentialSecret, type ConnectionModelDiscoveryResult, + type ConnectionCatalogEntry, + type CredentialVersionBasis, } from '@maka/core/runtime-policy'; import { deriveConnectionSlug, @@ -45,9 +49,11 @@ import { decodePersistedDomain, ioFailed, } from './errors.js'; +import type { InteractiveOAuthLoginTarget, InteractiveOAuthLoginProvider } from './operations.js'; import { readBoundedJsonDocument, writeJsonDocument } from './document-io.js'; const FILE = 'runtime-policy-onboarding.json'; const SCHEMA_VERSION = 2 as const; +const OAUTH_SCHEMA_VERSION = 3 as const; const MAX_BYTES = 5 * 1024 * 1024; export interface ConnectionOnboardingTransactionInput { @@ -74,6 +80,23 @@ export interface ConnectionOnboardingIntent { readonly invalidateLastTest: boolean; } +export interface InteractiveOAuthEnrollmentIntent { + readonly schemaVersion: typeof OAUTH_SCHEMA_VERSION; + readonly kind: 'oauth_enrollment'; + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; + readonly connectionBefore: ConnectionCatalogEntry | null; + readonly connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }; + readonly credentialBasis: CredentialVersionBasis | null; + readonly secret: string; +} + +export type RuntimePolicyOnboardingIntent = + | ConnectionOnboardingIntent + | InteractiveOAuthEnrollmentIntent; + export type CurrentConnectionOnboardingIntent = ConnectionOnboardingIntent & { readonly schemaVersion: 2; readonly slug: string; @@ -157,9 +180,36 @@ export function prepareConnectionOnboardingIntent( export async function readConnectionOnboardingIntent( root: string, -): Promise { +): Promise { const value = await readBoundedJsonDocument(root, FILE, MAX_BYTES); if (value === undefined) return undefined; + const envelope = record( + value, + FILE, + 'invalid_document', + [ + 'schemaVersion', + 'kind', + 'attemptId', + 'target', + 'connectionBefore', + 'connectionAfter', + 'credentialBasis', + 'secret', + 'connectionId', + 'slug', + 'providerType', + 'suppliedSecret', + 'baseUrl', + 'enabledModelIds', + 'discovery', + 'invalidateLastTest', + ], + ['schemaVersion'], + ); + if (envelope.schemaVersion === OAUTH_SCHEMA_VERSION) { + return decodeInteractiveOAuthEnrollmentIntent(value); + } // `baseUrl` is allowed but not required for the oldest v1 journal shape. const raw = record( value, @@ -208,11 +258,142 @@ export async function readConnectionOnboardingIntent( export function writeConnectionOnboardingIntent( root: string, - intent: CurrentConnectionOnboardingIntent, + intent: CurrentConnectionOnboardingIntent | InteractiveOAuthEnrollmentIntent, ): Promise { return writeJsonDocument(root, FILE, intent, MAX_BYTES); } +export function prepareInteractiveOAuthEnrollmentIntent(input: { + readonly attemptId: unknown; + readonly target: InteractiveOAuthLoginTarget; + readonly connectionBefore: ConnectionCatalogEntry | null; + readonly connectionAfter: ConnectionCatalogEntry; + readonly credentialBasis: CredentialVersionBasis | null; + readonly secret: unknown; +}): InteractiveOAuthEnrollmentIntent { + const connectionAfter = decodeConnectionInput(() => + decodeCanonicalConnectionCatalogEntry(input.connectionAfter), + ); + if (!isOAuthProvider(connectionAfter.providerType)) { + throw codecError('invalid_connection_input', 'OAuth enrollment requires an OAuth provider'); + } + return { + schemaVersion: OAUTH_SCHEMA_VERSION, + kind: 'oauth_enrollment', + attemptId: decodeOAuthAttemptId(input.attemptId, 'invalid_connection_input'), + target: structuredClone(input.target), + connectionBefore: + input.connectionBefore === null + ? null + : decodeConnectionInput(() => + decodeCanonicalConnectionCatalogEntry(input.connectionBefore), + ), + connectionAfter: connectionAfter as InteractiveOAuthEnrollmentIntent['connectionAfter'], + credentialBasis: input.credentialBasis ? structuredClone(input.credentialBasis) : null, + secret: decodeCredentialInput(() => normalizeCredentialSecret(input.secret)), + }; +} + +function decodeInteractiveOAuthEnrollmentIntent(value: unknown): InteractiveOAuthEnrollmentIntent { + const raw = record(value, FILE, 'invalid_document', [ + 'schemaVersion', + 'kind', + 'attemptId', + 'target', + 'connectionBefore', + 'connectionAfter', + 'credentialBasis', + 'secret', + ]); + if (raw.schemaVersion !== OAUTH_SCHEMA_VERSION || raw.kind !== 'oauth_enrollment') { + throw codecError('invalid_document', `${FILE} has an invalid OAuth enrollment intent`); + } + const connectionAfter = decodePersistedDomain(() => + decodeCanonicalConnectionCatalogEntry(raw.connectionAfter), + ); + if (!isOAuthProvider(connectionAfter.providerType)) { + throw codecError('invalid_document', 'OAuth enrollment intent provider is invalid'); + } + const connectionBefore = + raw.connectionBefore === null + ? null + : decodePersistedDomain(() => decodeCanonicalConnectionCatalogEntry(raw.connectionBefore)); + const credentialBasis = + raw.credentialBasis === null + ? null + : decodePersistedDomain(() => decodeCredentialVersionBasis(raw.credentialBasis)); + const target = decodeOAuthTarget(raw.target); + if ( + (target.kind === 'create' && connectionBefore !== null) || + (target.kind === 'create' && target.providerType !== connectionAfter.providerType) || + (target.kind === 'existing' && + (connectionBefore === null || connectionBefore.connectionId !== target.connectionId)) || + connectionAfter.connectionId !== + (target.kind === 'existing' ? target.connectionId : connectionAfter.connectionId) || + (connectionBefore !== null && + (connectionBefore.connectionId !== connectionAfter.connectionId || + connectionBefore.slug !== connectionAfter.slug || + connectionBefore.providerType !== connectionAfter.providerType)) + ) { + throw codecError('invalid_document', 'OAuth enrollment intent identity is inconsistent'); + } + return { + schemaVersion: OAUTH_SCHEMA_VERSION, + kind: 'oauth_enrollment', + attemptId: decodeOAuthAttemptId(raw.attemptId, 'invalid_document'), + target, + connectionBefore, + connectionAfter: connectionAfter as InteractiveOAuthEnrollmentIntent['connectionAfter'], + credentialBasis, + secret: decodePersistedDomain(() => normalizeCredentialSecret(raw.secret)), + }; +} + +function decodeOAuthTarget(value: unknown): InteractiveOAuthLoginTarget { + const base = record( + value, + 'OAuth enrollment target', + 'invalid_document', + ['kind', 'providerType', 'connectionId'], + ['kind'], + ); + if (base.kind === 'create') { + const item = record(value, 'OAuth create target', 'invalid_document', ['kind', 'providerType']); + const providerType = decodePersistedDomain(() => decodeProviderType(item.providerType)); + if (!isOAuthProvider(providerType)) { + throw codecError('invalid_document', 'OAuth create target provider is invalid'); + } + return { kind: 'create', providerType }; + } + if (base.kind === 'existing') { + const item = record(value, 'OAuth existing target', 'invalid_document', [ + 'kind', + 'connectionId', + ]); + return { + kind: 'existing', + connectionId: decodePersistedDomain(() => decodeRuntimePolicyEntityId(item.connectionId)), + }; + } + throw codecError('invalid_document', 'OAuth enrollment target kind is invalid'); +} + +function isOAuthProvider( + providerType: ProviderType, +): providerType is InteractiveOAuthLoginProvider { + return providerType === 'openai-codex' || providerType === 'xai-oauth'; +} + +function decodeOAuthAttemptId( + value: unknown, + source: 'invalid_connection_input' | 'invalid_document', +): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw codecError(source, 'OAuth attempt id is invalid'); + } + return value; +} + function deriveLegacyIntentPlaceholderSlug(rawProviderType: unknown): string { const providerType = decodePersistedDomain(() => decodeProviderType(rawProviderType)); return deriveConnectionSlug(providerType); diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index f5f5bf7a41..6d3b157e66 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -145,9 +145,38 @@ export type InteractiveOAuthLoginProvider = Extract< 'openai-codex' | 'xai-oauth' >; +export type InteractiveOAuthLoginTarget = + | { readonly kind: 'create'; readonly providerType: InteractiveOAuthLoginProvider } + | { readonly kind: 'existing'; readonly connectionId: string }; + +export interface InteractiveOAuthLoginInput { + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; +} + +export type InteractiveOAuthConnectionIdentity = Pick< + ConnectionCatalogEntry, + 'connectionId' | 'slug' | 'providerType' +> & { readonly providerType: InteractiveOAuthLoginProvider }; + +export type QueryInteractiveOAuthLoginResult = + | { readonly kind: 'not_found' } + | { + readonly kind: 'authenticated'; + readonly target: InteractiveOAuthLoginTarget; + readonly connection: InteractiveOAuthConnectionIdentity; + }; + export type BeginInteractiveOAuthLoginResult = + | { + readonly kind: 'authenticated'; + readonly target: InteractiveOAuthLoginTarget; + readonly connection: InteractiveOAuthConnectionIdentity; + } | { readonly kind: 'connection_not_found' } | { readonly kind: 'connection_disabled' } + | { readonly kind: 'catalog_full' } + | { readonly kind: 'attempt_conflict' } | { readonly kind: 'provider_action_unavailable'; readonly availability: UnavailableProviderActionAvailability; @@ -156,6 +185,8 @@ export type BeginInteractiveOAuthLoginResult = | { readonly kind: 'ready'; readonly ticket: InteractiveOAuthLoginTicket; + readonly target: InteractiveOAuthLoginTarget; + readonly identity: InteractiveOAuthConnectionIdentity; readonly connection: ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider; }; @@ -168,6 +199,7 @@ export type InteractiveOAuthLoginCompletionResult = readonly kind: 'committed'; readonly credentialId: string; readonly revision: number; + readonly connection: InteractiveOAuthConnectionIdentity; } | { readonly kind: 'superseded'; @@ -346,7 +378,10 @@ export interface RuntimePolicyOperationCoordinator { input: CompareAndSetOAuthCredentialInput, ): Promise; importConnectionCredential(input: SetCredentialInput): Promise; - beginInteractiveOAuthLogin(connectionId: string): Promise; + beginInteractiveOAuthLogin( + input: InteractiveOAuthLoginInput, + ): Promise; + queryInteractiveOAuthLogin(attemptId: string): Promise; completeInteractiveOAuthLogin( ticket: InteractiveOAuthLoginTicket, secret: string, From 3c2dfb440f52e1a67b51167e525c107c6a875629 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 20:37:31 +0800 Subject: [PATCH 2/4] style(storage): format OAuth enrollment paths --- packages/storage/src/runtime-policy-stores.ts | 3 +-- packages/storage/src/runtime-policy/coordinator.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index 6251ea6c3a..928a009bff 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -249,8 +249,7 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic compareAndSetOAuthCredential: (input) => coordinator.compareAndSetOAuthCredential(input), importConnectionCredential: (input) => coordinator.importConnectionCredential(input), beginInteractiveOAuthLogin: (input) => coordinator.beginInteractiveOAuthLogin(input), - queryInteractiveOAuthLogin: (attemptId) => - coordinator.queryInteractiveOAuthLogin(attemptId), + queryInteractiveOAuthLogin: (attemptId) => coordinator.queryInteractiveOAuthLogin(attemptId), completeInteractiveOAuthLogin: (ticket, secret) => coordinator.completeInteractiveOAuthLogin(ticket, secret), beginModelFetch: (connectionId) => coordinator.beginModelFetch(connectionId), diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 3934439ac9..bf38c04217 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -493,7 +493,9 @@ export class RuntimePolicyCoordinator { }); } - beginInteractiveOAuthLogin(rawInput: InteractiveOAuthLoginInput): Promise { + beginInteractiveOAuthLogin( + rawInput: InteractiveOAuthLoginInput, + ): Promise { return this.inLane(async (root) => { const input = normalizeInteractiveOAuthLoginInput(rawInput); const receipts = await readInteractiveOAuthLoginReceipts(root); @@ -1748,7 +1750,10 @@ export class RuntimePolicyCoordinator { existingReceipt.connection.slug !== intendedIdentity.slug || existingReceipt.connection.providerType !== intendedIdentity.providerType) ) { - throw codecError('invalid_document', 'OAuth login receipt conflicts with the enrollment intent'); + throw codecError( + 'invalid_document', + 'OAuth login receipt conflicts with the enrollment intent', + ); } const catalog = await this.catalog.read(root); // Validate the complete catalog transition before the vault-first write. @@ -2131,9 +2136,7 @@ function normalizeInteractiveOAuthLoginInput( attemptId, target: { kind: 'existing', - connectionId: decodeConnectionInput(() => - decodeRuntimePolicyEntityId(target.connectionId), - ), + connectionId: decodeConnectionInput(() => decodeRuntimePolicyEntityId(target.connectionId)), }, }; } From 8209ea16fcbb94f361dbbfd95b03c19e9beb13bd Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 22:51:01 +0800 Subject: [PATCH 3/4] fix(desktop): preserve active OAuth presentation --- .../runtime-host-oauth-ipc-main.test.ts | 39 ++++++++++++------- .../src/main/runtime-host-oauth-ipc-main.ts | 6 ++- .../main/runtime-host-oauth-presentation.ts | 7 ++-- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index 05afc26289..07b3d5fa90 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -369,7 +369,7 @@ test('malformed OAuth Connection IDs fail closed before catalog or credential ac assert.equal(mutations, 0); }); -test('a second OAuth start surfaces Host conflict without cancelling the active attempt', async () => { +test('a second OAuth start cannot replace or cancel a pending active attempt', async () => { const provider = 'openai-codex' as const; const connectionId = '00000000-0000-4000-8000-000000000011'; const configuredConnections = [ @@ -412,6 +412,11 @@ test('a second OAuth start surfaces Host conflict without cancelling the active let starts = 0; let cancels = 0; let firstAttemptId = ''; + let phase: 'awaiting_authorization' | 'authenticated' = 'awaiting_authorization'; + let markFirstPresentationPoll!: () => void; + const firstPresentationPoll = new Promise((resolve) => { + markFirstPresentationPoll = resolve; + }); const client = { loadConnectionCatalog: async () => ({ revision: 1, @@ -454,15 +459,12 @@ test('a second OAuth start surfaces Host conflict without cancelling the active starts += 1; if (starts === 2) throw new Error('Another OAuth login is already in progress'); firstAttemptId = attemptId; - await presentation.openExternal( - 'https://auth.example/device', - 'FIRST', - new AbortController().signal, - ); return oauthProjection(attemptId, connectionId, 'awaiting_authorization'); }, - queryOAuthLogin: async (attemptId: string) => - oauthProjection(attemptId, connectionId, 'authenticated'), + queryOAuthLogin: async (attemptId: string) => { + markFirstPresentationPoll(); + return oauthProjection(attemptId, connectionId, phase); + }, cancelOAuthLogin: async (attemptId: string) => { cancels += 1; return oauthProjection(attemptId, connectionId, 'cancelled'); @@ -476,7 +478,22 @@ test('a second OAuth start surfaces Host conflict without cancelling the active isProviderEnabled: () => true, }); + const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url'); + await firstPresentationPoll; assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { + ok: false, + reason: 'unknown', + message: 'Another OAuth login is already in progress', + }); + assert.equal(starts, 1); + assert.equal(cancels, 0); + await presentation.openExternal( + 'https://auth.example/device', + 'FIRST', + new AbortController().signal, + ); + phase = 'authenticated'; + assert.deepEqual(await firstAuthorization, { authRequestId: firstAttemptId, stateHint: 'FIRST', }); @@ -499,12 +516,6 @@ test('a second OAuth start surfaces Host conflict without cancelling the active { ok: true }, ); assert.equal(cancels, 0); - assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { - ok: false, - reason: 'unknown', - message: 'Another OAuth login is already in progress', - }); - assert.equal(cancels, 0); assert.deepEqual( await invoke(handlers, 'openai-codex:complete-authorization', firstAttemptId), { ok: true }, diff --git a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts index bbbca1ad48..e151449c56 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -43,6 +43,7 @@ import { } from './ipc-reconnect-policy.js'; import type { OAuthExternalPresentation, + OAuthPresentationExpectation, RuntimeHostOAuthPresentation, } from './runtime-host-oauth-presentation.js'; @@ -108,9 +109,10 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void } } const attemptId = randomUUID(); - const expectation = deps.presentation.expect(attemptId); + let expectation: OAuthPresentationExpectation | undefined; let startedOnHost = false; try { + expectation = deps.presentation.expect(attemptId); const started = await deps.client.startOAuthLogin( attemptId, connectionId @@ -129,7 +131,7 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void }); return { authRequestId: attemptId, stateHint: presented.stateHint }; } catch (error) { - expectation.cancel(error); + expectation?.cancel(error); if (startedOnHost) { await deps.client.cancelOAuthLogin(attemptId).catch(() => undefined); } diff --git a/apps/desktop/src/main/runtime-host-oauth-presentation.ts b/apps/desktop/src/main/runtime-host-oauth-presentation.ts index 8f9743b9b6..779e8e3a0e 100644 --- a/apps/desktop/src/main/runtime-host-oauth-presentation.ts +++ b/apps/desktop/src/main/runtime-host-oauth-presentation.ts @@ -37,7 +37,7 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { constructor(private readonly openSystemBrowser: (url: string) => Promise) {} expect(attemptId: string): OAuthPresentationExpectation { - this.#pending?.reject(new Error('Another OAuth presentation replaced this attempt')); + if (this.#pending) throw new Error('Another OAuth login is already in progress'); let resolvePresented!: (presentation: OAuthExternalPresentation) => void; let rejectPresented!: (reason?: unknown) => void; let presentedSettled = false; @@ -45,9 +45,8 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { resolvePresented = accept; rejectPresented = decline; }); - // If a later expect() supersedes this one before waitForPresentation - // attaches, Node would log UnhandledPromiseRejection. Keep a no-op - // handler; real waiters still observe the same rejection. + // The timeout can fire before waitForPresentation attaches. Keep a no-op + // handler; the real waiter still observes the same rejection. void presented.catch(() => undefined); const timer = setTimeout(() => { if (this.#pending?.attemptId !== attemptId) return; From 2df8f7db7059d1d6c95555555f035bc574d01cbb Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 10:15:34 +0800 Subject: [PATCH 4/4] test(desktop): consolidate OAuth IPC fixtures --- .../runtime-host-oauth-ipc-main.test.ts | 254 +++++++----------- 1 file changed, 94 insertions(+), 160 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index 07b3d5fa90..64f824e4be 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -29,6 +29,9 @@ import { } from '../runtime-host-oauth-ipc-main.js'; import { RuntimeHostOAuthPresentation } from '../runtime-host-oauth-presentation.js'; +type OAuthClient = RuntimeHostOAuthIpcDeps['client']; +type OAuthIpcHandler = Parameters[1]; + test('presents the Host OAuth handoff without exposing the authorization URL', async () => { const opened: string[] = []; const presentation = new RuntimeHostOAuthPresentation(async (url) => { @@ -47,10 +50,6 @@ test('presents the Host OAuth handoff without exposing the authorization URL', a test('adapts every Host OAuth provider through one Desktop flow', async () => { const provider = 'openai-codex' as const; - const handlers = new Map< - string, - Parameters[1] - >(); const opened: string[] = []; const presentation = new RuntimeHostOAuthPresentation(async (url) => { opened.push(url); @@ -77,14 +76,8 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { }, ], }; - const client = { + const clientOverrides = { loadConnectionCatalog: async () => catalog, - createConnection: async () => { - throw new Error('Existing OAuth Connection must be reused'); - }, - updateConnection: async () => { - throw new Error('Enabled OAuth Connection must not be rewritten'); - }, startOAuthLogin: async (nextAttemptId, target) => { attemptId = nextAttemptId; assert.deepEqual(target, { @@ -114,14 +107,6 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { catalog.connections[0]?.connectionId ?? '', phase, ), - cancelOAuthLogin: async (nextAttemptId) => { - phase = 'cancelled'; - return oauthProjection( - nextAttemptId, - catalog.connections[0]?.connectionId ?? '', - phase, - ); - }, fetchConnectionModels: async () => { const current = catalog.connections[0]; assert.ok(current); @@ -161,26 +146,9 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { updatedAt: 1, } : null, - deleteCredential: async ({ expected }) => ({ - kind: 'committed' as const, - vaultRevision: 1, - status: { - locator: expected.locator, - configured: false as const, - credentialId: null, - revision: null, - updatedAt: null, - }, - }), - } satisfies RuntimeHostOAuthIpcDeps['client']; - - registerRuntimeHostOAuthIpc({ - ipcMain: { - handle(channel, handler) { - handlers.set(channel, handler); - }, - }, - client, + } satisfies Partial; + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides, presentation, emitConnectionListChanged: () => { changed += 1; @@ -223,6 +191,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { provider, runtimeState: 'authenticated', }); + assertNoUnexpectedClientCalls(); }); test('provider-scoped OAuth IPC rejects a Connection ID owned by another provider', async () => { @@ -236,27 +205,14 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], models: [], }; - const handlers = new Map< - string, - Parameters[1] - >(); let starts = 0; let mutations = 0; - const forbiddenMutation = async () => { - mutations += 1; - throw new Error('Cross-provider IPC must not mutate a Connection'); - }; - const client = { + const clientOverrides = { loadConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [xaiConnection], }), - createConnection: forbiddenMutation, - updateConnection: forbiddenMutation, - deleteCredential: forbiddenMutation, - fetchConnectionModels: forbiddenMutation, - setDefaultConnectionTarget: forbiddenMutation, queryCredential: async () => { throw new Error('Cross-provider IPC must not inspect another credential'); }, @@ -264,12 +220,9 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide starts += 1; throw new Error('Cross-provider IPC must not start Host OAuth'); }, - queryOAuthLogin: async () => oauthProjection('unused', xaiConnection.connectionId, 'cancelled'), - cancelOAuthLogin: async () => oauthProjection('unused', xaiConnection.connectionId, 'cancelled'), - } satisfies RuntimeHostOAuthIpcDeps['client']; - registerRuntimeHostOAuthIpc({ - ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, - client, + } satisfies Partial; + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides, presentation: new RuntimeHostOAuthPresentation(async () => undefined), emitConnectionListChanged: () => { mutations += 1; @@ -304,41 +257,16 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide }); assert.equal(starts, 0); assert.equal(mutations, 0); + assertNoUnexpectedClientCalls(); }); test('malformed OAuth Connection IDs fail closed before catalog or credential access', async () => { - const handlers = new Map< - string, - Parameters[1] - >(); - let reads = 0; - let mutations = 0; - const forbiddenRead = async () => { - reads += 1; - throw new Error('Malformed identity must not reach Runtime Host storage'); - }; - const forbiddenMutation = async () => { - mutations += 1; - throw new Error('Malformed identity must not mutate Runtime Host state'); - }; - const client = { - loadConnectionCatalog: forbiddenRead, - queryCredential: forbiddenRead, - createConnection: forbiddenMutation, - updateConnection: forbiddenMutation, - deleteCredential: forbiddenMutation, - fetchConnectionModels: forbiddenMutation, - setDefaultConnectionTarget: forbiddenMutation, - startOAuthLogin: forbiddenMutation, - queryOAuthLogin: forbiddenRead, - cancelOAuthLogin: forbiddenMutation, - } satisfies RuntimeHostOAuthIpcDeps['client']; - registerRuntimeHostOAuthIpc({ - ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, - client, + let emissions = 0; + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides: {}, presentation: new RuntimeHostOAuthPresentation(async () => undefined), emitConnectionListChanged: () => { - mutations += 1; + emissions += 1; }, isProviderEnabled: () => true, }); @@ -365,8 +293,8 @@ test('malformed OAuth Connection IDs fail closed before catalog or credential ac message: 'Invalid OAuth Connection identity', }); } - assert.equal(reads, 0); - assert.equal(mutations, 0); + assert.equal(emissions, 0); + assertNoUnexpectedClientCalls(); }); test('a second OAuth start cannot replace or cancel a pending active attempt', async () => { @@ -404,10 +332,6 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], models: [], }; - const handlers = new Map< - string, - Parameters[1] - >(); const presentation = new RuntimeHostOAuthPresentation(async () => undefined); let starts = 0; let cancels = 0; @@ -417,15 +341,12 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a const firstPresentationPoll = new Promise((resolve) => { markFirstPresentationPoll = resolve; }); - const client = { + const clientOverrides = { loadConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [...configuredConnections, foreignConnection], }), - createConnection: async () => { - throw new Error('not used'); - }, updateConnection: async (expected) => ({ kind: 'committed' as const, catalogRevision: 2, @@ -445,9 +366,6 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a fetchConnectionModels: async () => { throw new Error('model discovery unavailable'); }, - setDefaultConnectionTarget: async () => { - throw new Error('not used'); - }, queryCredential: async (locator) => ({ locator, configured: true as const, @@ -469,10 +387,9 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a cancels += 1; return oauthProjection(attemptId, connectionId, 'cancelled'); }, - } satisfies RuntimeHostOAuthIpcDeps['client']; - registerRuntimeHostOAuthIpc({ - ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, - client, + } satisfies Partial; + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides, presentation, emitConnectionListChanged: () => undefined, isProviderEnabled: () => true, @@ -521,38 +438,22 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a { ok: true }, ); assert.equal(cancels, 0); + assertNoUnexpectedClientCalls(); }); test('completion rejects a terminal projection that changes Connection identity', async () => { - const handlers = new Map< - string, - Parameters[1] - >(); const presentation = new RuntimeHostOAuthPresentation(async () => undefined); const startedId = '00000000-0000-4000-8000-000000000021'; const changedId = '00000000-0000-4000-8000-000000000022'; let attemptId = ''; let synchronized = 0; let emitted = 0; - const client = { + const clientOverrides = { loadConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [] }), - createConnection: async () => { - throw new Error('not used'); - }, - updateConnection: async () => { - throw new Error('not used'); - }, - deleteCredential: async () => { - throw new Error('not used'); - }, fetchConnectionModels: async () => { synchronized += 1; throw new Error('must not synchronize a changed identity'); }, - setDefaultConnectionTarget: async () => { - throw new Error('not used'); - }, - queryCredential: async () => null, startOAuthLogin: async (nextAttemptId: string) => { attemptId = nextAttemptId; await presentation.openExternal( @@ -564,12 +465,9 @@ test('completion rejects a terminal projection that changes Connection identity' }, queryOAuthLogin: async (nextAttemptId: string) => oauthProjection(nextAttemptId, changedId, 'authenticated'), - cancelOAuthLogin: async (nextAttemptId: string) => - oauthProjection(nextAttemptId, startedId, 'cancelled'), - } satisfies RuntimeHostOAuthIpcDeps['client']; - registerRuntimeHostOAuthIpc({ - ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, - client, + } satisfies Partial; + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides, presentation, emitConnectionListChanged: () => { emitted += 1; @@ -585,10 +483,13 @@ test('completion rejects a terminal projection that changes Connection identity' }); assert.equal(synchronized, 0); assert.equal(emitted, 0); + assertNoUnexpectedClientCalls(); }); test('keeps a committed OAuth login successful when model discovery fails', async () => { const provider = 'openai-codex' as const; + const modelId = PROVIDER_DEFAULTS[provider].fallbackModels[0]; + assert.ok(modelId); const existing = { connectionId: '00000000-0000-4000-8000-000000000002', revision: 1, @@ -609,22 +510,12 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn defaultTarget: null, connections: [existing], }; - const handlers = new Map< - string, - Parameters[1] - >(); const presentation = new RuntimeHostOAuthPresentation(async () => undefined); let attemptId = ''; let changed = 0; const fetchedConnectionIds: string[] = []; - const client = { + const clientOverrides = { loadConnectionCatalog: async () => catalog, - createConnection: async () => { - throw new Error('Existing OAuth Connection must be reused'); - }, - updateConnection: async () => { - throw new Error('Enabled OAuth Connection must not be rewritten'); - }, startOAuthLogin: async (nextAttemptId: string, target) => { attemptId = nextAttemptId; assert.deepEqual(target, { kind: 'create', providerType: provider }); @@ -655,21 +546,14 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn phase: 'authenticated' as const, }; }, - cancelOAuthLogin: async (nextAttemptId: string) => ({ - attemptId: nextAttemptId, - connection: { - connectionId: created.connectionId, - slug: created.slug, - providerType: provider, - }, - phase: 'cancelled' as const, - }), fetchConnectionModels: async (connectionId: string) => { fetchedConnectionIds.push(connectionId); throw new Error('provider temporarily unavailable'); }, - setDefaultConnectionTarget: async () => { - throw new Error('Default selection must not run after failed discovery'); + setDefaultConnectionTarget: async (expectedCatalogRevision, target) => { + assert.equal(expectedCatalogRevision, catalog.revision); + catalog = { ...catalog, revision: catalog.revision + 1, defaultTarget: target }; + return { kind: 'committed' as const, catalogRevision: catalog.revision }; }, queryCredential: async (locator) => locator.scope === 'connection' && locator.connectionId === created.connectionId @@ -681,14 +565,9 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn updatedAt: 1, } : null, - deleteCredential: async () => { - throw new Error('Credential deletion must not run'); - }, - } satisfies RuntimeHostOAuthIpcDeps['client']; - - registerRuntimeHostOAuthIpc({ - ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, - client, + } satisfies Partial; + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides, presentation, emitConnectionListChanged: () => { changed += 1; @@ -706,12 +585,67 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn ); assert.equal(changed, 1); assert.deepEqual(fetchedConnectionIds, [created.connectionId]); + assert.deepEqual(catalog.defaultTarget, { connectionId: created.connectionId, modelId }); assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), { provider, runtimeState: 'authenticated', }); + assertNoUnexpectedClientCalls(); }); +function createFailClosedOAuthClient(overrides: Partial): { + readonly client: OAuthClient; + assertNoUnexpectedClientCalls(): void; +} { + const unexpectedCalls: Array<{ readonly method: keyof OAuthClient; readonly args: unknown[] }> = []; + const unexpected = + (method: keyof OAuthClient) => + (...args: unknown[]): never => { + unexpectedCalls.push({ method, args }); + throw new Error(`Unexpected OAuth client call: ${String(method)}`); + }; + const client = { + loadConnectionCatalog: unexpected('loadConnectionCatalog'), + createConnection: unexpected('createConnection'), + updateConnection: unexpected('updateConnection'), + deleteCredential: unexpected('deleteCredential'), + fetchConnectionModels: unexpected('fetchConnectionModels'), + setDefaultConnectionTarget: unexpected('setDefaultConnectionTarget'), + queryCredential: unexpected('queryCredential'), + startOAuthLogin: unexpected('startOAuthLogin'), + queryOAuthLogin: unexpected('queryOAuthLogin'), + cancelOAuthLogin: unexpected('cancelOAuthLogin'), + ...overrides, + } satisfies OAuthClient; + return { + client, + assertNoUnexpectedClientCalls: () => assert.deepEqual(unexpectedCalls, []), + }; +} + +function registerOAuthTestHandlers(input: { + readonly clientOverrides: Partial; + readonly presentation: RuntimeHostOAuthPresentation; + readonly emitConnectionListChanged: () => void; + readonly isProviderEnabled: NonNullable; +}): { + readonly handlers: ReadonlyMap; + assertNoUnexpectedClientCalls(): void; +} { + const handlers = new Map(); + const { client, assertNoUnexpectedClientCalls } = createFailClosedOAuthClient( + input.clientOverrides, + ); + registerRuntimeHostOAuthIpc({ + ipcMain: { handle: (channel, handler) => void handlers.set(channel, handler) }, + client, + presentation: input.presentation, + emitConnectionListChanged: input.emitConnectionListChanged, + isProviderEnabled: input.isProviderEnabled, + }); + return { handlers, assertNoUnexpectedClientCalls }; +} + function oauthProjection( attemptId: string, connectionId: string,