diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 2c633b4642..e1f0427f22 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -71,6 +71,8 @@ import type { MakaOnboardingSurface, MakaPiTuiTurnActivitySurface, ModelChoice, + OnboardingOAuthInput, + OnboardingOAuthResult, OnboardingProviderEntry, OnboardingSaveInput, OnboardingSaveResult, @@ -190,6 +192,7 @@ function defaultOnboardingProviders(): OnboardingProviderEntry[] { interface FakeOnboardingOpts { providers?: OnboardingProviderEntry[]; + loginOAuth?: (input: OnboardingOAuthInput) => Promise; verify?: (input: OnboardingVerifyInput) => Promise; save?: (input: OnboardingSaveInput) => Promise; } @@ -201,6 +204,7 @@ interface FakeOnboardingOpts { function fakeOnboardingSurface(opts: FakeOnboardingOpts = {}): MakaOnboardingSurface { return { listProviders: async () => opts.providers ?? defaultOnboardingProviders(), + ...(opts.loginOAuth ? { loginOAuth: opts.loginOAuth } : {}), verify: opts.verify ?? (async () => ({ kind: 'ok', models: [{ id: 'gpt-5.5' }, { id: 'gpt-5.5-mini' }] })), @@ -208,6 +212,18 @@ function fakeOnboardingSurface(opts: FakeOnboardingOpts = {}): MakaOnboardingSur }; } +function oauthCreateProvider(enabledModelIds: readonly string[]) { + return { + providerType: 'openai-codex', + label: 'OpenAI OAuth (ChatGPT / Codex)', + requiresBaseUrl: false, + setupMethod: 'oauth', + target: { kind: 'create', providerType: 'openai-codex' }, + suggestedSlug: 'codex-subscription', + enabledModelIds, + } as const satisfies OnboardingProviderEntry; +} + function savedOnboardingResult( modelChoices: ModelChoice[] = [], connectionId = 'saved-connection-id', @@ -788,6 +804,302 @@ describe('Maka Pi TUI runner', () => { } }); + test('a new OAuth account flows from identity through authorization to model save', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const loginTargets: OnboardingProviderEntry['target'][] = []; + const verifyCalls: OnboardingVerifyInput[] = []; + const saveCalls: OnboardingSaveInput[] = []; + const authorization = deferred<{ + readonly kind: 'authenticated'; + readonly connection: { + readonly connectionId: string; + readonly slug: string; + readonly providerType: 'openai-codex'; + }; + }>(); + const models = [ + { id: 'gpt-5.6-sol' }, + { id: 'gpt-5.5' }, + { id: 'gpt-5.4' }, + { id: 'gpt-5.4-mini' }, + { id: 'gpt-5.3-codex-spark' }, + ]; + const provider = oauthCreateProvider(models.map(({ id }) => id)); + const onboarding = fakeOnboardingSurface({ + providers: [provider], + loginOAuth: async (input) => { + loginTargets.push(input.target); + input.onPresentation({ + url: 'https://auth.openai.com/codex/device', + stateHint: 'ABCD-EFGH', + }); + return authorization.promise; + }, + verify: async (input) => { + verifyCalls.push(input); + return { kind: 'ok' as const, models }; + }, + save: async (input) => { + saveCalls.push(input); + return { + kind: 'ok' as const, + connection: { + connectionId: 'codex-id', + revision: 1, + slug: 'codex-subscription', + providerType: 'openai-codex' as const, + }, + refresh: { kind: 'ok' as const, modelChoices: [], connectionIdentities: [] }, + }; + }, + }); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'bypass', + terminal, + onboarding, + }); + + try { + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /1\/4/); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4')); + for (let index = 0; index < provider.label.length; index += 1) terminal.input('\x7f'); + terminal.input('Work Codex'); + terminal.input('\r'); + for (let index = 0; index < provider.suggestedSlug.length; index += 1) { + terminal.input('\x7f'); + } + terminal.input('codex-work'); + terminal.input('\r'); + await waitFor(() => { + const screen = plainTerminalOutput(terminal.screenOutput()); + return ( + screen.includes('3/4') && + screen.includes('https://auth.openai.com/codex/device') && + screen.includes('Sign-in code: ABCD-EFGH') && + screen.includes('Waiting for browser authorization') + ); + }); + authorization.resolve({ + kind: 'authenticated', + connection: { + connectionId: 'codex-id', + slug: 'codex-work', + providerType: 'openai-codex', + }, + }); + await waitFor(() => verifyCalls.length === 1); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('4/4')); + terminal.input('\r'); + await waitFor(() => saveCalls.length === 1); + + assert.deepEqual(loginTargets, [ + { + kind: 'create', + providerType: 'openai-codex', + slug: 'codex-work', + name: 'Work Codex', + }, + ]); + assert.deepEqual(verifyCalls[0]?.target, { + kind: 'existing', + connectionId: 'codex-id', + }); + assert.deepEqual(saveCalls[0], { + target: { kind: 'existing', connectionId: 'codex-id' }, + apiKey: '', + baseUrl: '', + enabledModelIds: models.map(({ id }) => id), + }); + } finally { + process.emit('SIGTERM'); + await run; + } + }); + + test('an OAuth slug collision returns a new account to the identity step', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const provider = oauthCreateProvider(['gpt-5.5']); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'bypass', + terminal, + onboarding: fakeOnboardingSurface({ + providers: [provider], + loginOAuth: async () => ({ kind: 'failed', reason: 'slug_taken' }), + }), + }); + + try { + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4')); + terminal.input('\r'); + terminal.input('\r'); + await waitFor(() => { + const screen = plainTerminalOutput(terminal.screenOutput()); + return screen.includes('2/4') && screen.includes('That slug is already taken'); + }); + } finally { + process.emit('SIGTERM'); + await run; + } + }); + + test('an existing OAuth account skips identity and keeps its Connection identity', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const authorization = deferred(); + const loginTargets: OnboardingProviderEntry['target'][] = []; + const verifyCalls: OnboardingVerifyInput[] = []; + const provider: OnboardingProviderEntry = { + providerType: 'openai-codex', + label: 'Work Codex · codex-work', + requiresBaseUrl: false, + setupMethod: 'oauth', + target: { kind: 'existing', connectionId: 'codex-work-id' }, + connectionSlug: 'codex-work', + enabledModelIds: ['gpt-5.5'], + }; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'bypass', + terminal, + onboarding: fakeOnboardingSurface({ + providers: [provider], + loginOAuth: async (input) => { + loginTargets.push(input.target); + input.onPresentation({ + url: 'https://auth.openai.com/codex/device', + stateHint: 'WXYZ-1234', + }); + return authorization.promise; + }, + verify: async (input) => { + verifyCalls.push(input); + return { kind: 'ok', models: [{ id: 'gpt-5.5' }] }; + }, + }), + }); + + try { + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('1/3')); + terminal.input('\r'); + await waitFor(() => { + const screen = plainTerminalOutput(terminal.screenOutput()); + return screen.includes('2/3') && screen.includes('WXYZ-1234'); + }); + authorization.resolve({ + kind: 'authenticated', + connection: { + connectionId: 'codex-work-id', + slug: 'codex-work', + providerType: 'openai-codex', + }, + }); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + + const target = { kind: 'existing' as const, connectionId: 'codex-work-id' }; + assert.deepEqual(loginTargets, [target]); + assert.deepEqual(verifyCalls[0]?.target, target); + } finally { + process.emit('SIGTERM'); + await run; + } + }); + + test('retries model discovery after OAuth without signing in again', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + let loginCalls = 0; + let verifyCalls = 0; + const provider: OnboardingProviderEntry = { + providerType: 'openai-codex', + label: 'Work Codex · codex-work', + requiresBaseUrl: false, + setupMethod: 'oauth', + target: { kind: 'existing', connectionId: 'codex-work-id' }, + connectionSlug: 'codex-work', + enabledModelIds: ['gpt-5.5'], + }; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'bypass', + terminal, + onboarding: fakeOnboardingSurface({ + providers: [provider], + loginOAuth: async () => { + loginCalls += 1; + return { + kind: 'authenticated', + connection: { + connectionId: 'codex-work-id', + slug: 'codex-work', + providerType: 'openai-codex', + }, + }; + }, + verify: async () => { + verifyCalls += 1; + return verifyCalls === 1 + ? { kind: 'failed', errorClass: 'network' } + : { kind: 'ok', models: [{ id: 'gpt-5.5' }] }; + }, + }), + }); + + try { + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('1/3')); + terminal.input('\r'); + await waitFor(() => { + const screen = plainTerminalOutput(terminal.screenOutput()); + return screen.includes('Signed in') && screen.includes('Enter retries model loading'); + }); + assert.equal(loginCalls, 1); + assert.equal(verifyCalls, 1); + + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + assert.equal(loginCalls, 1); + assert.equal(verifyCalls, 2); + } finally { + process.emit('SIGTERM'); + await run; + } + }); + test('verify failure re-arms the key prompt so the key can be retried', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -1129,7 +1441,7 @@ describe('Maka Pi TUI runner', () => { assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /storage read failed/); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /No configurable API key providers are available/, + /No configurable providers are available/, ); process.emit('SIGTERM'); @@ -1203,7 +1515,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('\x1b'); // identity name -> search await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), '1/3') !== null; + return latestPlainLineContaining(terminal.writes.join(''), '1/4') !== null; } catch { return false; } @@ -1381,7 +1693,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('\x1b'); // key -> identity (slug field) terminal.input('\x1b'); // identity slug -> name field terminal.input('\x1b'); // identity name -> provider search - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('1/3')); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('1/4')); terminal.input('\r'); // reselect the same add-account row as a new intent terminal.input('\r'); // accept default name -> slug field terminal.input('\r'); // accept derived slug -> key phase @@ -1705,6 +2017,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', label: 'OpenAI', requiresBaseUrl: false, + setupMethod: 'api_key', target: { kind: 'create', providerType: 'openai' }, suggestedSlug: 'openai', enabledModelIds: [], @@ -1778,6 +2091,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', label: 'OpenAI', requiresBaseUrl: false, + setupMethod: 'api_key', target: { kind: 'create', providerType: 'openai' }, suggestedSlug: 'openai', enabledModelIds: [], @@ -1834,6 +2148,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', label: 'OpenAI', requiresBaseUrl: false, + setupMethod: 'api_key', target: { kind: 'create', providerType: 'openai' }, suggestedSlug: 'openai', enabledModelIds: [], @@ -8437,7 +8752,7 @@ describe('Maka Pi TUI runner', () => { const output = plainTerminalOutput(terminal.screenOutput()); assert.match(output, /one-time account confirmation/); assert.match(output, /Run \/model/); - assert.match(output, /run \/setup for API-key connections/); + assert.match(output, /run \/setup to add one/); exitMaka(terminal); await run; @@ -8486,7 +8801,7 @@ describe('Maka Pi TUI runner', () => { ); const recoveryNotice = plainTerminalOutput(terminal.screenOutput()); assert.match(recoveryNotice, /Run \/model/); - assert.match(recoveryNotice, /run \/setup for API-key connections/); + assert.match(recoveryNotice, /run \/setup to add one/); terminal.input('/model'); terminal.input('\r'); await waitFor(() => terminal.output().includes('Replacement')); diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index 78b34d254b..864c9a77ca 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -19,14 +19,21 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; +import { deferred } from '@maka/core/test-only/async-primitives'; import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; -import type { RuntimeHostConnection } from '@maka/runtime-host/client'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, + type ClientCapabilityProvider, + type RuntimeHostConnection, +} from '@maka/runtime-host/client'; import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; import { createRuntimeHostOnboardingSurface, projectProviders, projectRuntimeHostModelChoices, } from '../runtime-host-onboarding.js'; +import type { OnboardingOAuthInput } from '../pi-tui-contracts.js'; type StoredConnection = Omit; @@ -61,7 +68,431 @@ const live = { models: [{ id: 'gpt-5-mini', displayName: 'GPT-5 Mini' }], } as const; +const oauthConnectionIdentity = { + connectionId: 'codex-id', + slug: 'codex-subscription', + providerType: 'openai-codex', +} as const; + +function oauthProjection( + attemptId: string, + phase: 'awaiting_authorization' | 'authenticated' | 'committing' | 'cancelled', +) { + return { attemptId, connection: oauthConnectionIdentity, phase }; +} + +function interruptedOAuthRequest(operation: 'oauth.login.start' | 'oauth.login.cancel') { + return new RuntimeHostRequestInterruptedError( + operation, + operation === 'oauth.login.start' ? 'command' : 'control', + 'dispatched', + 'connection_lost', + ); +} + +function oauthTestSurface( + attemptId: string, + request: (operation: string, input: unknown) => unknown | Promise, + options: { readonly pollIntervalMs?: number; readonly onClose?: () => void } = {}, +) { + const requests: string[] = []; + const connection = { + replaceClientCapabilities: async () => ({ + registrationId: 'oauth-registration', + revision: 1, + }), + request: async (operation: string, input: unknown) => { + requests.push(operation); + return request(operation, input); + }, + } as unknown as RuntimeHostConnection; + const surface = createRuntimeHostOnboardingSurface({} as RuntimeHostConnection, { + connectOAuth: async () => ({ + connection, + close: async () => options.onClose?.(), + }), + ...(options.pollIntervalMs === undefined ? {} : { pollIntervalMs: options.pollIntervalMs }), + createAttemptId: () => attemptId, + }); + return { requests, surface }; +} + +function loginWithOAuth( + surface: ReturnType['surface'], + options: { + readonly signal?: AbortSignal; + readonly target?: OnboardingOAuthInput['target']; + } = {}, +) { + return surface.loginOAuth!({ + target: options.target ?? { kind: 'create', providerType: 'openai-codex' }, + signal: options.signal ?? new AbortController().signal, + onPresentation: () => undefined, + }); +} + describe('createRuntimeHostOnboardingSurface', () => { + test('asks the Host enrollment gate before offering Codex OAuth', async () => { + const operations: string[] = []; + const connection = { + request: async (operation: string) => { + operations.push(operation); + if (operation === 'connection.catalog.query') { + return { + kind: 'page', + revision: 1, + defaultTarget: null, + connectionCount: 0, + items: [], + nextCursor: null, + }; + } + if (operation === 'oauth.enrollment.query') { + return { provider: 'openai-codex', enabled: true }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + } as unknown as RuntimeHostConnection; + + const providers = await createRuntimeHostOnboardingSurface(connection).listProviders(); + + assert.deepEqual(operations, ['connection.catalog.query', 'oauth.enrollment.query']); + assert.equal( + providers.some( + ({ providerType, target }) => providerType === 'openai-codex' && target.kind === 'create', + ), + true, + ); + }); + + test('runs presentation and polling behind one OAuth login call', async () => { + const requests: string[] = []; + const presentations: Array<{ readonly url: string; readonly stateHint?: string }> = []; + let provider: ClientCapabilityProvider | undefined; + let closeCalls = 0; + const oauthConnection = { + replaceClientCapabilities: async (next: ClientCapabilityProvider) => { + provider = next; + return { registrationId: 'oauth-registration', revision: 1 }; + }, + request: async (operation: string) => { + requests.push(operation); + const connection = { + connectionId: 'codex-id', + slug: 'codex-subscription', + providerType: 'openai-codex' as const, + }; + if (operation === 'oauth.login.start') { + assert.ok(provider?.callService); + await provider.callService( + { + kind: 'client.capability.service_call', + invocationId: 'presentation-1', + registrationId: 'oauth-registration', + serviceId: 'oauth_presentation', + version: '1', + method: 'open_external', + input: { + url: 'https://auth.openai.com/codex/device', + stateHint: 'ABCD-EFGH', + }, + }, + { + signal: new AbortController().signal, + accept: async () => undefined, + }, + ); + return { + attemptId: 'setup-oauth-1', + connection, + phase: 'awaiting_authorization', + }; + } + if (operation === 'oauth.login.query') { + return { attemptId: 'setup-oauth-1', connection, phase: 'authenticated' }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + } as unknown as RuntimeHostConnection; + const surface = createRuntimeHostOnboardingSurface({} as RuntimeHostConnection, { + connectOAuth: async () => ({ + connection: oauthConnection, + close: async () => { + closeCalls += 1; + }, + }), + pollIntervalMs: 0, + createAttemptId: () => 'setup-oauth-1', + }); + + const result = await surface.loginOAuth?.({ + target: { kind: 'create', providerType: 'openai-codex' }, + signal: new AbortController().signal, + onPresentation: (presentation) => presentations.push(presentation), + }); + + assert.deepEqual(result, { + kind: 'authenticated', + connection: { + connectionId: 'codex-id', + slug: 'codex-subscription', + providerType: 'openai-codex', + }, + }); + assert.deepEqual(presentations, [ + { url: 'https://auth.openai.com/codex/device', stateHint: 'ABCD-EFGH' }, + ]); + assert.deepEqual(requests, ['oauth.login.start', 'oauth.login.query']); + assert.equal(closeCalls, 1); + }); + + test('reconciles a dispatched OAuth start after its response is lost', async () => { + const attemptId = 'setup-oauth-dispatched-start'; + const { requests, surface } = oauthTestSurface(attemptId, (operation) => { + if (operation === 'oauth.login.start') throw interruptedOAuthRequest(operation); + if (operation === 'oauth.login.query') return oauthProjection(attemptId, 'authenticated'); + throw new Error(`Unexpected operation ${operation}`); + }); + + const result = await loginWithOAuth(surface); + + assert.deepEqual(result, { + kind: 'authenticated', + connection: oauthConnectionIdentity, + }); + assert.deepEqual(requests, ['oauth.login.start', 'oauth.login.query']); + }); + + test('retries the same OAuth start when reconciliation proves it was not admitted', async () => { + const attemptId = 'setup-oauth-retried-start'; + let startCalls = 0; + const { requests, surface } = oauthTestSurface(attemptId, (operation) => { + if (operation === 'oauth.login.start') { + startCalls += 1; + if (startCalls === 1) { + throw interruptedOAuthRequest(operation); + } + return oauthProjection(attemptId, 'authenticated'); + } + if (operation === 'oauth.login.query') { + throw new RuntimeHostOperationError( + 'oauth.login.query', + 'not_found', + 'OAuth login was not found', + ); + } + throw new Error(`Unexpected operation ${operation}`); + }); + + assert.equal((await loginWithOAuth(surface)).kind, 'authenticated'); + assert.deepEqual(requests, ['oauth.login.start', 'oauth.login.query', 'oauth.login.start']); + }); + + test('reconciles a dispatched OAuth cancellation instead of trusting the local signal', async () => { + const attemptId = 'setup-oauth-dispatched-cancel'; + const started = deferred(); + const controller = new AbortController(); + const { requests, surface } = oauthTestSurface( + attemptId, + (operation) => { + if (operation === 'oauth.login.start') { + started.resolve(); + return oauthProjection(attemptId, 'awaiting_authorization'); + } + if (operation === 'oauth.login.cancel') throw interruptedOAuthRequest(operation); + if (operation === 'oauth.login.query') return oauthProjection(attemptId, 'authenticated'); + throw new Error(`Unexpected operation ${operation}`); + }, + { pollIntervalMs: 60_000 }, + ); + + const login = loginWithOAuth(surface, { signal: controller.signal }); + await started.promise; + controller.abort(); + + assert.deepEqual(await login, { + kind: 'authenticated', + connection: oauthConnectionIdentity, + }); + assert.deepEqual(requests, ['oauth.login.start', 'oauth.login.cancel', 'oauth.login.query']); + }); + + test('retries cancellation when reconciliation still finds an active OAuth attempt', async () => { + const attemptId = 'setup-oauth-retried-cancel'; + const started = deferred(); + const controller = new AbortController(); + let cancelCalls = 0; + const { requests, surface } = oauthTestSurface( + attemptId, + (operation) => { + if (operation === 'oauth.login.start') { + started.resolve(); + return oauthProjection(attemptId, 'awaiting_authorization'); + } + if (operation === 'oauth.login.cancel') { + cancelCalls += 1; + if (cancelCalls === 1) throw interruptedOAuthRequest(operation); + return oauthProjection(attemptId, 'cancelled'); + } + if (operation === 'oauth.login.query') { + return oauthProjection(attemptId, 'awaiting_authorization'); + } + throw new Error(`Unexpected operation ${operation}`); + }, + { pollIntervalMs: 60_000 }, + ); + + const login = loginWithOAuth(surface, { signal: controller.signal }); + await started.promise; + controller.abort(); + + assert.deepEqual(await login, { kind: 'cancelled' }); + assert.deepEqual(requests, [ + 'oauth.login.start', + 'oauth.login.cancel', + 'oauth.login.query', + 'oauth.login.cancel', + ]); + }); + + test('does not mistake a Host cancellation failure for local cancellation', async () => { + const attemptId = 'setup-oauth-cancel-failed'; + const started = deferred(); + const controller = new AbortController(); + const { surface } = oauthTestSurface( + attemptId, + (operation) => { + if (operation === 'oauth.login.start') { + started.resolve(); + return oauthProjection(attemptId, 'awaiting_authorization'); + } + throw new RuntimeHostOperationError( + 'oauth.login.cancel', + 'persistence_failed', + 'OAuth cancellation could not be reconciled', + ); + }, + { pollIntervalMs: 60_000 }, + ); + + const login = loginWithOAuth(surface, { signal: controller.signal }); + await started.promise; + controller.abort(); + + assert.deepEqual(await login, { kind: 'failed', reason: 'persistence_failed' }); + }); + + test('preserves a Host slug_taken error as an OAuth failure reason', async () => { + const { surface } = oauthTestSurface('setup-oauth-slug-taken', () => { + throw new RuntimeHostOperationError( + 'oauth.login.start', + 'slug_taken', + 'Connection slug is already in use', + ); + }); + + assert.deepEqual( + await surface.loginOAuth!({ + target: { + kind: 'create', + providerType: 'openai-codex', + slug: 'codex-work', + name: 'Work Codex', + }, + signal: new AbortController().signal, + onPresentation: () => undefined, + }), + { kind: 'failed', reason: 'slug_taken' }, + ); + }); + + test('forwards the requested Connection identity to OAuth start', async () => { + const attemptId = 'setup-oauth-custom'; + const target = { + kind: 'create', + providerType: 'openai-codex', + slug: 'codex-work', + name: 'Work Codex', + } as const; + const starts: unknown[] = []; + const { surface } = oauthTestSurface(attemptId, (operation, input) => { + assert.equal(operation, 'oauth.login.start'); + starts.push(input); + return { + ...oauthProjection(attemptId, 'authenticated'), + connection: { ...oauthConnectionIdentity, slug: 'codex-work' }, + }; + }); + + await loginWithOAuth(surface, { target }); + + assert.deepEqual(starts, [{ attemptId, target }]); + }); + + test('closing the onboarding surface cancels and settles its active OAuth attempt', async () => { + const attemptId = 'setup-oauth-close'; + const started = deferred(); + let closeCalls = 0; + const { requests, surface } = oauthTestSurface( + attemptId, + (operation) => { + if (operation === 'oauth.login.start') { + started.resolve(); + return oauthProjection(attemptId, 'awaiting_authorization'); + } + if (operation === 'oauth.login.cancel') return oauthProjection(attemptId, 'cancelled'); + throw new Error(`Unexpected operation ${operation}`); + }, + { + pollIntervalMs: 60_000, + onClose: () => { + closeCalls += 1; + }, + }, + ); + const login = loginWithOAuth(surface); + await started.promise; + + await surface.close(); + + assert.deepEqual(await login, { kind: 'cancelled' }); + assert.deepEqual(requests, ['oauth.login.start', 'oauth.login.cancel']); + assert.equal(closeCalls, 1); + }); + + test('accepts authentication when cancellation loses the Host commit race', async () => { + const attemptId = 'setup-oauth-race'; + const started = deferred(); + const abort = new AbortController(); + const { requests, surface } = oauthTestSurface( + attemptId, + (operation) => { + if (operation === 'oauth.login.start') { + started.resolve(); + return oauthProjection(attemptId, 'awaiting_authorization'); + } + if (operation === 'oauth.login.cancel') return oauthProjection(attemptId, 'committing'); + if (operation === 'oauth.login.query') return oauthProjection(attemptId, 'authenticated'); + throw new Error(`Unexpected operation ${operation}`); + }, + { pollIntervalMs: 20 }, + ); + const login = loginWithOAuth(surface, { signal: abort.signal }); + await started.promise; + + abort.abort(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(requests, ['oauth.login.start', 'oauth.login.cancel']); + + assert.deepEqual(await login, { + kind: 'authenticated', + connection: oauthConnectionIdentity, + }); + assert.deepEqual(requests, ['oauth.login.start', 'oauth.login.cancel', 'oauth.login.query']); + await surface.close(); + }); + test('preserves Host failure codes without projecting backend text', async () => { const connection = { request: async (operation: string) => { @@ -231,6 +662,50 @@ describe('projectProviders', () => { models: [{ id: 'relay/model' }], } as const; + test('enabled Codex OAuth projects existing accounts and one add-account row', () => { + const codex = { + connectionId: 'codex-id', + revision: 1, + slug: 'codex-subscription', + name: 'Work Codex', + providerType: 'openai-codex', + enabled: true, + enabledModelIds: ['gpt-5.5'], + models: [], + } as const; + + const entries = projectProviders(catalog([codex]), true).filter( + ({ providerType }) => providerType === 'openai-codex', + ); + + assert.deepEqual(entries, [ + { + providerType: 'openai-codex', + label: 'Work Codex · codex-subscription', + requiresBaseUrl: false, + setupMethod: 'oauth', + target: { kind: 'existing', connectionId: 'codex-id' }, + connectionSlug: 'codex-subscription', + enabledModelIds: ['gpt-5.5'], + }, + { + providerType: 'openai-codex', + label: 'OpenAI OAuth (ChatGPT / Codex)', + requiresBaseUrl: false, + setupMethod: 'oauth', + target: { kind: 'create', providerType: 'openai-codex' }, + suggestedSlug: 'codex-subscription-2', + enabledModelIds: [ + 'gpt-5.6-sol', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex-spark', + ], + }, + ]); + }); + test('a Desktop-created relay and add-account action are both explicit', () => { const entries = projectProviders(catalog([relay])).filter( ({ providerType }) => providerType === 'openai-compatible', diff --git a/packages/cli/src/onboarding-catalog.ts b/packages/cli/src/onboarding-catalog.ts index eb8e3b7daf..d9f30f6e5a 100644 --- a/packages/cli/src/onboarding-catalog.ts +++ b/packages/cli/src/onboarding-catalog.ts @@ -42,6 +42,7 @@ export function listApiKeyOnboardableProviders(): OnboardableProvider[] { providerType, label: definition.label, requiresBaseUrl: !definition.baseUrl, + setupMethod: 'api_key' as const, }; }); } diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 8e4677eb36..b71dac3be1 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -25,6 +25,8 @@ import type { ConnectionEffectFailureClass, ConnectionOnboardingSaveResult as RuntimeHostOnboardingSaveResult, ConnectionOnboardingVerifyResult as RuntimeHostOnboardingVerifyResult, + OAuthConnectionIdentity, + OAuthLoginFailureCode, } from '@maka/runtime-host/protocol'; import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js'; @@ -62,6 +64,7 @@ export interface OnboardableProvider { providerType: ProviderType; label: string; requiresBaseUrl: boolean; + setupMethod: 'api_key' | 'oauth'; } export type OnboardingIdentityChoice = { @@ -154,8 +157,33 @@ export type OnboardingSaveResult = } | OnboardingFailure; +export interface OnboardingOAuthPresentation { + readonly url: string; + readonly stateHint?: string; +} + +export interface OnboardingOAuthInput { + readonly target: ConnectionOnboardingTarget; + readonly signal: AbortSignal; + readonly onPresentation: (presentation: OnboardingOAuthPresentation) => void; +} + +export type OnboardingOAuthFailureReason = + | OAuthLoginFailureCode + | 'connection_not_found' + | 'operation_conflict' + | 'unavailable'; + +export type OnboardingOAuthResult = + | { readonly kind: 'authenticated'; readonly connection: OAuthConnectionIdentity } + | { readonly kind: 'cancelled' } + | { readonly kind: 'failed'; readonly reason: OnboardingOAuthFailureReason }; + export interface MakaOnboardingSurface { listProviders(): Promise; + /** One complete Host-owned OAuth attempt. Polling, cancellation convergence, + * and presentation transport stay behind this interface. */ + loginOAuth?(input: OnboardingOAuthInput): Promise; verify(input: OnboardingVerifyInput): Promise; save(input: OnboardingSaveInput): Promise; } diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index cf086d6c4a..0db62881f4 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -58,6 +58,8 @@ import type { OnboardingFailure, OnboardingFailureClass, OnboardingIdentityChoice, + OnboardingOAuthFailureReason, + OnboardingOAuthPresentation, OnboardingProviderEntry, OnboardingRejectionReason, } from './pi-tui-contracts.js'; @@ -90,6 +92,7 @@ interface TuiPickerCopy { readonly onboardingRequestFailed: string; readonly onboardingRejections: Readonly>; readonly onboardingFailures: Readonly>; + readonly oauthFailures: Readonly>; readonly accountSavedRefreshFailed: string; readonly listProvidersFailed: string; readonly noConfigurableProviders: string; @@ -110,6 +113,15 @@ interface TuiPickerCopy { >; readonly submitAction: string; readonly verifyingKey: string; + readonly oauthHint: string; + readonly oauthCodeLabel: string; + readonly oauthStarting: string; + readonly oauthWaiting: string; + readonly oauthCancelling: string; + readonly oauthLoadingModels: string; + readonly oauthContinueToModels: string; + readonly oauthRetryAction: string; + readonly oauthRetryModelsAction: string; readonly modelSelectionHint: string; readonly selectedModels: string; readonly selectedModelsAndSave: string; @@ -136,6 +148,13 @@ export function onboardingFailureMessage(failure: OnboardingFailure, locale: UiL } } +export function onboardingOAuthFailureMessage( + reason: OnboardingOAuthFailureReason, + locale: UiLocale, +): string { + return TUI_PICKER_COPY[locale].oauthFailures[reason]; +} + export class MakaAutocompleteProvider implements AutocompleteProvider { private readonly fileProvider: CombinedAutocompleteProvider | undefined; private readonly slashCommands: readonly MakaSlashCommandMetadata[]; @@ -1083,6 +1102,7 @@ export type OnboardingWizardPhase = | 'identity' | 'baseUrl' | 'key' + | 'oauth' | 'models' | 'success'; @@ -1103,9 +1123,9 @@ export type OnboardingWizardStatus = export interface OnboardingWizardInput { locale: UiLocale; providers: readonly OnboardingProviderEntry[]; - /** search→key: the user picked a provider. The runner records it — and the - * existing connection's identity, when the catalog resolved one — for - * verify/save, so saving edits that connection in place. */ + /** The user picked a provider. The runner records it — and the existing + * connection's identity, when the catalog resolved one — for the remaining + * setup steps, so saving edits that connection in place. */ onPickProvider: (provider: OnboardingProviderEntry) => void; /** identity submit (create targets only). `null` halves mean "keep the * Host-derived default", so a user who accepts the prefills sends a target @@ -1118,11 +1138,19 @@ export interface OnboardingWizardInput { /** key submit. The value may be empty — an existing connection reuses the stored * secret, while a new required-key provider is rejected by verify. */ onSubmitKey: (apiKey: string) => void; + /** Entering the OAuth phase starts one complete Host-owned login attempt. */ + onStartOAuth: () => void; + /** Esc during an active OAuth attempt requests cancellation and waits for the + * Host's authoritative terminal result before moving back. */ + onCancelOAuth: () => void; + /** Models may return to an already-authenticated OAuth step; Enter retries + * model discovery without authenticating or creating again. */ + onContinueOAuth: () => void; /** models submit: save the curated enabled set (≥1 model). */ onSubmitModels: (enabledModelIds: readonly string[]) => void; /** search Esc / Ctrl+C: close (first-run closes the TUI). */ onCancel: () => void; - /** key Esc → search; models Esc → key. The runner invalidates in-flight work. */ + /** Moving back invalidates in-flight verification or model discovery. */ onBack: () => void; /** success Enter/Esc: close. */ onClose: () => void; @@ -1131,13 +1159,10 @@ export interface OnboardingWizardInput { const ONBOARDING_MODELS_MAX_VISIBLE = 10; /** - * One input field, four phases. The same overlay is the provider search, the - * API-key field, the searchable model multi-select, and the in-frame success — - * so onboarding never pushes its prompt/verifying/failure/saving/success notices - * into the transcript. Status lives in a single status line beside the field - * instead of the top entry flow (#1098 UX). `Esc` always moves back exactly one - * level (models → key → provider → close); late async results are ignored after - * back/close/retry because the runner bumps its attempt id on every transition. + * One overlay owns the provider, credentials or OAuth, models, and success + * phases, so onboarding never pushes progress or failures into the transcript. + * Late async results are ignored after back, close, or retry because the runner + * bumps its attempt id on every transition. */ export class OnboardingWizard implements Component { private phase: OnboardingWizardPhase = 'search'; @@ -1163,6 +1188,17 @@ export class OnboardingWizard implements Component { private modelScroll = 0; private successCount = 0; private successWarning: string | undefined; + private oauthPresentation: OnboardingOAuthPresentation | undefined; + private oauthStatus: + | { readonly kind: 'starting' } + | { readonly kind: 'waiting' } + | { readonly kind: 'cancelling' } + | { + readonly kind: 'authenticated'; + readonly loadingModels: boolean; + readonly modelError?: string; + } + | { readonly kind: 'error'; readonly text: string } = { kind: 'starting' }; private readonly copy: TuiPickerCopy; constructor( @@ -1220,12 +1256,12 @@ export class OnboardingWizard implements Component { (candidate) => onboardingProviderKey(candidate) === item.value, ); if (!provider) return; - this.enterKeyPhase(provider); + this.enterProvider(provider); }; return list; } - private enterKeyPhase(provider: OnboardingProviderEntry): void { + private enterProvider(provider: OnboardingProviderEntry): void { this.picked = provider; // A create target stops at the identity step first: name and slug come // prefilled with the Host-derived defaults, and accepting them verbatim @@ -1233,7 +1269,13 @@ export class OnboardingWizard implements Component { // A relay has no registry endpoint, so the wizard must still collect one // before the key — the deferred phase-2 step from #1254 (#3405). const create = isCreateEntry(provider); - this.phase = create ? 'identity' : provider.requiresBaseUrl ? 'baseUrl' : 'key'; + this.phase = create + ? 'identity' + : provider.setupMethod === 'oauth' + ? 'oauth' + : provider.requiresBaseUrl + ? 'baseUrl' + : 'key'; if (create) { this.identityFocus = 'name'; this.nameEditor.setText(provider.label); @@ -1254,6 +1296,7 @@ export class OnboardingWizard implements Component { this.modelScroll = 0; this.modelsSearchEditor.setText(''); this.input.onPickProvider(provider); + if (!create && provider.setupMethod === 'oauth') this.startOAuth(); } /** @@ -1288,7 +1331,9 @@ export class OnboardingWizard implements Component { name: name.length > 0 && name !== defaultName ? name : null, }); this.status = { kind: 'prompt' }; - this.phase = picked.requiresBaseUrl ? 'baseUrl' : 'key'; + this.phase = + picked.setupMethod === 'oauth' ? 'oauth' : picked.requiresBaseUrl ? 'baseUrl' : 'key'; + if (picked.setupMethod === 'oauth') this.startOAuth(); } private submitBaseUrl(value: string): void { @@ -1382,12 +1427,57 @@ export class OnboardingWizard implements Component { this.keyEditor.setText(''); } + private startOAuth(): void { + this.oauthPresentation = undefined; + this.oauthStatus = { kind: 'starting' }; + this.input.onStartOAuth(); + } + + setOAuthPresentation(presentation: OnboardingOAuthPresentation): void { + if (this.phase !== 'oauth') return; + this.oauthPresentation = presentation; + this.oauthStatus = { kind: 'waiting' }; + } + + setOAuthCancelling(): void { + if (this.phase !== 'oauth') return; + this.oauthStatus = { kind: 'cancelling' }; + } + + setOAuthAuthenticated(loadingModels = true): void { + if (this.phase !== 'oauth') return; + this.oauthStatus = { kind: 'authenticated', loadingModels }; + } + + setOAuthError(text: string): void { + if (this.phase !== 'oauth') return; + this.oauthStatus = { kind: 'error', text }; + } + + setOAuthModelError(text: string): void { + if (this.phase !== 'oauth') return; + this.oauthStatus = { kind: 'authenticated', loadingModels: false, modelError: text }; + } + + setOAuthCancelled(): void { + if (this.phase !== 'oauth') return; + if (this.picked?.target.kind === 'create') { + this.phase = 'identity'; + this.identityFocus = 'slug'; + } else { + this.phase = 'search'; + this.picked = undefined; + } + this.status = { kind: 'prompt' }; + this.oauthPresentation = undefined; + } + /** Runner hook: verify succeeded — advance to the models step with fresh * discovered models. Selection seeds from the picked provider's enabled set * on first entry (existing connections preserve it; new ones start empty); * a re-verify preserves the user's toggles, dropping ids no longer discovered. */ setModels(models: ModelInfo[]): void { - if (this.phase !== 'key') return; + if (this.phase !== 'key' && this.phase !== 'oauth') return; this.models = models; if (!this.modelsInitialized) { this.selectedIds = new Set( @@ -1434,11 +1524,19 @@ export class OnboardingWizard implements Component { this.list.invalidate(); } - /** Step label: relays add a base-URL step, create targets an identity one. */ - private stepFor(phase: 'search' | 'identity' | 'baseUrl' | 'key' | 'models'): string { - const create = this.picked?.target.kind === 'create'; - const relay = this.picked?.requiresBaseUrl === true; - const total = 3 + (create ? 1 : 0) + (relay ? 1 : 0); + /** Step label: create targets add identity; relays add Base URL. */ + private stepFor(phase: 'search' | 'identity' | 'baseUrl' | 'key' | 'oauth' | 'models'): string { + const selectedItem = this.list.getSelectedItem(); + const entry = + this.picked ?? + this.filtered.find( + (candidate) => + selectedItem !== null && onboardingProviderKey(candidate) === selectedItem.value, + ); + const create = entry?.target.kind === 'create'; + const oauth = entry?.setupMethod === 'oauth'; + const relay = entry?.requiresBaseUrl === true; + const total = oauth ? 3 + (create ? 1 : 0) : 3 + (create ? 1 : 0) + (relay ? 1 : 0); let position = 1; if (phase === 'search') return `1/${total}`; if (create) { @@ -1450,7 +1548,7 @@ export class OnboardingWizard implements Component { if (phase === 'baseUrl') return `${position}/${total}`; } position += 1; - if (phase === 'key') return `${position}/${total}`; + if (phase === 'key' || phase === 'oauth') return `${position}/${total}`; return `${total}/${total}`; } @@ -1464,6 +1562,8 @@ export class OnboardingWizard implements Component { return this.handleBaseUrlInput(data); case 'key': return this.handleKeyInput(data); + case 'oauth': + return this.handleOAuthInput(data); case 'models': return this.handleModelsInput(data); case 'success': @@ -1565,17 +1665,48 @@ export class OnboardingWizard implements Component { this.keyEditor.handleInput(data); } + private handleOAuthInput(data: string): void { + if (matchesKey(data, Key.ctrl('c'))) { + this.input.onCancel(); + return; + } + if (matchesKey(data, Key.escape)) { + if (this.oauthStatus.kind === 'starting' || this.oauthStatus.kind === 'waiting') { + this.oauthStatus = { kind: 'cancelling' }; + this.input.onCancelOAuth(); + return; + } + if (this.oauthStatus.kind === 'error') { + this.setOAuthCancelled(); + this.input.onBack(); + } + return; + } + if ((matchesKey(data, Key.enter) || matchesKey(data, Key.return)) && !isKeyRepeat(data)) { + if (this.oauthStatus.kind === 'error') this.startOAuth(); + else if (this.oauthStatus.kind === 'authenticated' && !this.oauthStatus.loadingModels) { + this.oauthStatus = { kind: 'authenticated', loadingModels: true }; + this.input.onContinueOAuth(); + } + } + } + private handleModelsInput(data: string): void { if (matchesKey(data, Key.ctrl('c'))) { this.input.onCancel(); return; } if (matchesKey(data, Key.escape)) { - // models → key (one level back); query/selection state survives. - this.phase = 'key'; + // An OAuth login is already durable here: return to its signed-in screen + // instead of implying identity/authentication can be rolled back. + this.phase = this.picked?.setupMethod === 'oauth' ? 'oauth' : 'key'; this.status = { kind: 'prompt' }; - this.keyEditor.setText(''); - this.keyEditor.disableSubmit = false; + if (this.phase === 'oauth') { + this.oauthStatus = { kind: 'authenticated', loadingModels: false }; + } else { + this.keyEditor.setText(''); + this.keyEditor.disableSubmit = false; + } this.input.onBack(); return; } @@ -1653,6 +1784,8 @@ export class OnboardingWizard implements Component { return this.renderBaseUrl(safeWidth); case 'key': return this.renderKey(safeWidth); + case 'oauth': + return this.renderOAuth(safeWidth); case 'models': return this.renderModels(safeWidth); case 'success': @@ -1782,6 +1915,55 @@ export class OnboardingWizard implements Component { } } + private renderOAuth(width: number): string[] { + this.focusOnly(null); + const label = this.picked?.label ?? ''; + const lines = [ + padLine( + `${this.copy.setupTitle} ${ansi.dim(`· ${this.stepFor('oauth')}`)} ${ansi.accent(label)}`, + width, + ), + padLine( + this.oauthStatus.kind === 'authenticated' ? '' : ansi.dim(this.copy.oauthHint), + width, + ), + padLine('', width), + ]; + if (this.oauthPresentation) { + lines.push(padLine(this.oauthPresentation.url, width)); + lines.push(padLine('', width)); + if (this.oauthPresentation.stateHint) { + lines.push( + padLine(`${this.copy.oauthCodeLabel}: ${this.oauthPresentation.stateHint}`, width), + ); + lines.push(padLine('', width)); + } + } + lines.push(padLine(this.renderOAuthStatusLine(), width)); + lines.push(padLine(ansi.accent('-'.repeat(width)), width)); + return lines; + } + + private renderOAuthStatusLine(): string { + switch (this.oauthStatus.kind) { + case 'starting': + return `${ansi.yellow('⠋')} ${this.copy.oauthStarting}`; + case 'waiting': + return `${ansi.yellow('⠋')} ${this.copy.oauthWaiting}`; + case 'cancelling': + return `${ansi.yellow('⠋')} ${this.copy.oauthCancelling}`; + case 'authenticated': + if (this.oauthStatus.loadingModels) { + return `${ansi.yellow('⠋')} ${this.copy.oauthLoadingModels}`; + } + return this.oauthStatus.modelError + ? ansi.red(`✗ ${this.copy.oauthRetryModelsAction} · ${this.oauthStatus.modelError}`) + : ansi.green(`✓ ${this.copy.oauthContinueToModels}`); + case 'error': + return ansi.red(`✗ ${this.oauthStatus.text} · ${this.copy.oauthRetryAction}`); + } + } + private renderModels(width: number): string[] { this.focusOnly(this.status.kind !== 'saving' ? this.modelsSearchEditor : null); const label = this.picked?.label ?? ''; diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index b673dec3e9..ce1c0b7c86 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -34,11 +34,7 @@ import { } from '@earendil-works/pi-tui'; import type { PermissionMode } from '@maka/core/permission'; import { isThinkingLevel, type ThinkingLevel } from '@maka/core/model-thinking'; -import { - deriveConnectionSlug, - type ModelInfo, - type ProviderType, -} from '@maka/core/llm-connections'; +import { deriveConnectionSlug, type ProviderType } from '@maka/core/llm-connections'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SkillInvocationFailureReason, @@ -78,7 +74,6 @@ import type { MakaOnboardingSurface, MakaPiTuiTurnActivitySurface, ModelChoice, - OnboardingIdentityChoice, OnboardingProviderEntry, SessionRecapGenerator, } from './pi-tui-contracts.js'; @@ -172,6 +167,7 @@ import { getTuiPickerCopy, modelPickerItems, onboardingFailureMessage, + onboardingOAuthFailureMessage, permissionModePickerItems, skillPickerItems, thinkingLevelPickerItems, @@ -1294,9 +1290,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { })().catch(reportError); }; - // Onboarding wizard (#1098 UX redesign): one overlay spans provider search - // → API key → model curation, keeping every prompt/verifying/failure/saving/ - // success notice beside the input field instead of the transcript entry flow. + // Onboarding wizard (#1098 UX redesign): one overlay spans provider search, + // authentication, model curation, and success without transcript notices. let wizardOverlay: OverlayHandle | undefined; let wizard: OnboardingWizard | undefined; // The user's supplied key from the key step ('' reuses the stored secret for an @@ -1309,11 +1304,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // The existing connection the picked provider resolved to, so saving edits // it in place (a Desktop-created relay may live under a custom slug). let wizardTarget: OnboardingProviderEntry['target'] | undefined; - // The identity step's answer for a create target. Null halves keep the wire - // target bare so any Host vintage accepts it; an edited slug/name rides on - // the target and gets `slug_taken` back when it loses. - let wizardIdentity: OnboardingIdentityChoice = { slug: null, name: null }; - let wizardModels: readonly ModelInfo[] = []; + let wizardOAuthAbort: AbortController | undefined; // Authoritative ready model choices for `/model`. A startup snapshot refreshed // in place after `/setup` saves so newly configured models are immediately // available — the single source the picker and connection/model lookups read. @@ -2358,6 +2349,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const closeWizard = (): void => { + wizardOAuthAbort?.abort(); + wizardOAuthAbort = undefined; wizardAttempt += 1; // drop any in-flight verify/save before clearing the slots wizardOverlay?.hide(); wizardOverlay = undefined; @@ -2365,8 +2358,105 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { wizardApiKey = ''; wizardBaseUrl = ''; wizardTarget = undefined; - wizardIdentity = { slug: null, name: null }; - wizardModels = []; + }; + + const discoverWizardOAuthModels = ( + targetWizard: OnboardingWizard, + attempt: number, + target: Extract, + ): void => { + if (!input.onboarding) return; + void input.onboarding.verify({ target }).then( + (result) => { + if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + if (result.kind !== 'ok') { + targetWizard.setOAuthModelError(onboardingFailureMessage(result, locale)); + requestRender(); + return; + } + targetWizard.setModels(result.models); + requestRender(); + }, + () => { + if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + targetWizard.setOAuthModelError(onboardingFailureMessage({ kind: 'unavailable' }, locale)); + requestRender(); + }, + ); + }; + + const startWizardOAuth = (): void => { + const target = wizardTarget; + const targetWizard = wizard; + if (!target || !targetWizard) return; + if (!input.onboarding?.loginOAuth) { + targetWizard.setOAuthError(pickerCopy.onboardingUnavailable); + requestRender(); + return; + } + wizardOAuthAbort?.abort(); + const abort = new AbortController(); + wizardOAuthAbort = abort; + const attempt = ++wizardAttempt; + requestRender(); + void input.onboarding + .loginOAuth({ + target, + signal: abort.signal, + onPresentation: (presentation) => { + if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + targetWizard.setOAuthPresentation(presentation); + requestRender(); + }, + }) + .then( + (result) => { + if (wizardOAuthAbort === abort) wizardOAuthAbort = undefined; + if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + if (result.kind === 'cancelled') { + targetWizard.setOAuthCancelled(); + wizardAttempt += 1; + requestRender(); + return; + } + if (result.kind === 'failed') { + const message = onboardingOAuthFailureMessage(result.reason, locale); + if (result.reason === 'slug_taken' && target.kind === 'create') { + targetWizard.setIdentityError(message); + } else { + targetWizard.setOAuthError(message); + } + requestRender(); + return; + } + const existingTarget = { + kind: 'existing' as const, + connectionId: result.connection.connectionId, + }; + // Authentication is the durable create/reauthorize commit point. All + // later work addresses that exact Connection and never repeats OAuth. + wizardTarget = existingTarget; + targetWizard.setOAuthAuthenticated(); + requestRender(); + discoverWizardOAuthModels(targetWizard, attempt, existingTarget); + }, + () => { + if (wizardOAuthAbort === abort) wizardOAuthAbort = undefined; + if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + targetWizard.setOAuthError(onboardingOAuthFailureMessage('unavailable', locale)); + requestRender(); + }, + ); + }; + + const continueWizardOAuth = (): void => { + const target = wizardTarget; + const targetWizard = wizard; + if (!targetWizard || target?.kind !== 'existing') return; + const attempt = ++wizardAttempt; + targetWizard.setOAuthAuthenticated(); + requestRender(); + discoverWizardOAuthModels(targetWizard, attempt, target); }; // Key submit from the wizard. Slash commands route as commands (so /exit @@ -2403,7 +2493,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); return; } - wizardModels = result.models; wizard.setModels(result.models); // advance to the models step requestRender(); }, @@ -2528,15 +2617,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // reselects the same catalog row. A late save may converge only the // exact target object captured by its own submit. wizardTarget = { ...provider.target }; - wizardIdentity = { slug: null, name: null }; wizardApiKey = ''; wizardBaseUrl = ''; - wizardModels = []; wizardAttempt += 1; // a new pick supersedes any in-flight attempt requestRender(); }, onSubmitIdentity: (identity) => { - wizardIdentity = identity; if (wizardTarget?.kind === 'create') { wizardTarget = { kind: 'create', @@ -2552,6 +2638,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }, onSubmitKey: submitWizardKey, + onStartOAuth: startWizardOAuth, + onCancelOAuth: () => { + wizard?.setOAuthCancelling(); + wizardOAuthAbort?.abort(); + requestRender(); + }, + onContinueOAuth: continueWizardOAuth, onSubmitModels: submitWizardModels, onCancel: () => { closeWizard(); diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 9d359c8ce7..a7f2d807c7 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -17,26 +17,78 @@ * under the License. */ -import { deriveConnectionSlug, offerableCatalogEntries } from '@maka/core/llm-connections'; +import { randomUUID } from 'node:crypto'; +import { + deriveConnectionSlug, + deriveInteractiveOAuthConnectionSlug, + offerableCatalogEntries, + providerFallbackModelIds, + PROVIDER_REGISTRY, +} from '@maka/core/llm-connections'; import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { + createOAuthPresentationClientProvider, readRuntimeHostConnectionCatalog, + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, type RuntimeHostConnection, } from '@maka/runtime-host/client'; +import type { OAuthLoginProjection, OAuthLoginTarget } from '@maka/runtime-host/protocol'; import { listApiKeyOnboardableProviders } from './onboarding-catalog.js'; import type { ConnectionIdentity, MakaOnboardingSurface, ModelChoice, OnboardingProviderEntry, + OnboardingOAuthInput, + OnboardingOAuthResult, } from './pi-tui-contracts.js'; +export interface RuntimeHostOnboardingOAuthConnection { + readonly connection: RuntimeHostConnection; + close(): Promise; +} + +export interface RuntimeHostOnboardingSurfaceOptions { + readonly connectOAuth?: (signal: AbortSignal) => Promise; + readonly pollIntervalMs?: number; + readonly createAttemptId?: () => string; +} + +export interface RuntimeHostOnboardingSurface extends MakaOnboardingSurface { + close(): Promise; +} + /** Adapt the TUI onboarding workflow to Host-owned verification and persistence. */ export function createRuntimeHostOnboardingSurface( connection: RuntimeHostConnection, -): MakaOnboardingSurface { + options: RuntimeHostOnboardingSurfaceOptions = {}, +): RuntimeHostOnboardingSurface { + const shutdown = new AbortController(); + const activeOAuthLogins = new Set>(); return { - listProviders: async () => projectProviders(await readRuntimeHostConnectionCatalog(connection)), + listProviders: async () => { + const catalog = await readRuntimeHostConnectionCatalog(connection); + let codexOAuthEnabled = false; + try { + codexOAuthEnabled = ( + await connection.request('oauth.enrollment.query', { provider: 'openai-codex' }) + ).enabled; + } catch { + // The API-key catalog remains useful when an older or temporarily + // unavailable Host cannot answer the optional OAuth enrollment query. + } + return projectProviders(catalog, codexOAuthEnabled); + }, + loginOAuth: (input) => { + const task = runOAuthLogin(input, options, shutdown.signal); + activeOAuthLogins.add(task); + void task.then( + () => activeOAuthLogins.delete(task), + () => activeOAuthLogins.delete(task), + ); + return task; + }, verify: async (input) => { try { const result = await connection.request('connection.onboarding.verify', { @@ -89,7 +141,175 @@ export function createRuntimeHostOnboardingSurface( return { kind: 'unavailable' }; } }, + close: async () => { + shutdown.abort(); + await Promise.allSettled([...activeOAuthLogins]); + }, + }; +} + +async function runOAuthLogin( + input: OnboardingOAuthInput, + options: RuntimeHostOnboardingSurfaceOptions, + shutdownSignal: AbortSignal, +): Promise { + if (!options.connectOAuth) return { kind: 'failed', reason: 'unavailable' }; + const signal = AbortSignal.any([input.signal, shutdownSignal]); + if (signal.aborted) return { kind: 'cancelled' }; + const target = asOAuthTarget(input.target); + if (!target) return { kind: 'failed', reason: 'unavailable' }; + let connected: RuntimeHostOnboardingOAuthConnection | undefined; + let startRequested = false; + let cancellationRequested: boolean = signal.aborted; + const requestCancellation = () => { + cancellationRequested = true; }; + signal.addEventListener('abort', requestCancellation, { once: true }); + try { + connected = await options.connectOAuth(signal); + await connected.connection.replaceClientCapabilities( + createOAuthPresentationClientProvider({ + openExternal: async (url, stateHint) => { + input.onPresentation({ url, ...(stateHint === undefined ? {} : { stateHint }) }); + }, + }), + ); + const attemptId = (options.createAttemptId ?? randomUUID)(); + startRequested = true; + let projection = await startOAuthAttempt(connected.connection, attemptId, target); + let cancellationSent = false; + while (!isTerminalOAuthProjection(projection)) { + if (cancellationRequested && !cancellationSent) { + cancellationSent = true; + const cancelledProjection = await cancelOAuthAttempt(connected.connection, attemptId); + if (!cancelledProjection) return { kind: 'cancelled' }; + projection = cancelledProjection; + continue; + } + // Once cancellation has reached the Host, the aborted UI signal must no + // longer collapse this delay into a busy query loop while a commit wins. + await waitForOAuthPoll(options.pollIntervalMs ?? 250, cancellationSent ? undefined : signal); + projection = await connected.connection.request('oauth.login.query', { attemptId }); + } + if (projection.phase === 'authenticated') { + return { kind: 'authenticated', connection: projection.connection }; + } + if (projection.phase === 'cancelled') return { kind: 'cancelled' }; + return { kind: 'failed', reason: projection.failure ?? 'internal_failure' }; + } catch (error) { + if (error instanceof RuntimeHostOperationError) { + if (error.code === 'not_found') return { kind: 'failed', reason: 'connection_not_found' }; + if (error.code === 'operation_conflict') { + return { kind: 'failed', reason: 'operation_conflict' }; + } + if (error.code === 'slug_taken') return { kind: 'failed', reason: 'slug_taken' }; + if (error.code === 'capability_unavailable') { + return { kind: 'failed', reason: 'capability_unavailable' }; + } + if (error.code === 'persistence_failed' || error.code === 'internal_failure') { + return { kind: 'failed', reason: error.code }; + } + } + return signal.aborted && !startRequested + ? { kind: 'cancelled' } + : { kind: 'failed', reason: 'unavailable' }; + } finally { + signal.removeEventListener('abort', requestCancellation); + await connected?.close().catch(() => undefined); + } +} + +async function startOAuthAttempt( + connection: RuntimeHostConnection, + attemptId: string, + target: OAuthLoginTarget, +): Promise { + while (true) { + try { + return await connection.request('oauth.login.start', { attemptId, target }); + } catch (error) { + if (!isOAuthRequestInterruption(error, 'oauth.login.start')) throw error; + try { + // A write acknowledged by the local transport may already be running + // on the Host. Query the stable attempt identity before retrying the + // idempotent start so a lost response cannot create a second login. + return await connection.request('oauth.login.query', { attemptId }); + } catch (queryError) { + if (!isOAuthAttemptNotFound(queryError)) throw queryError; + // No live or durable state is visible yet. Starting again with the same + // attemptId is safe and synchronizes with an original handler still + // behind the Host start gate, even if local cancellation arrived while + // the outcome was unknown. + } + } + } +} + +async function cancelOAuthAttempt( + connection: RuntimeHostConnection, + attemptId: string, +): Promise { + while (true) { + try { + return await connection.request('oauth.login.cancel', { attemptId }); + } catch (error) { + if (isOAuthAttemptNotFound(error)) return null; + if (!isOAuthRequestInterruption(error, 'oauth.login.cancel')) throw error; + try { + const projection = await connection.request('oauth.login.query', { attemptId }); + if (isTerminalOAuthProjection(projection)) return projection; + // A non-terminal query cannot prove that the interrupted cancellation + // reached the Host. Cancel again; the operation is attempt-idempotent. + } catch (queryError) { + if (isOAuthAttemptNotFound(queryError)) return null; + throw queryError; + } + } + } +} + +function isOAuthRequestInterruption( + error: unknown, + operation: 'oauth.login.start' | 'oauth.login.cancel', +): error is RuntimeHostRequestInterruptedError { + return error instanceof RuntimeHostRequestInterruptedError && error.operation === operation; +} + +function isOAuthAttemptNotFound(error: unknown): error is RuntimeHostOperationError { + return error instanceof RuntimeHostOperationError && error.code === 'not_found'; +} + +function asOAuthTarget(target: OnboardingOAuthInput['target']): OAuthLoginTarget | null { + if (target.kind === 'existing') return target; + return target.providerType === 'openai-codex' + ? { + kind: 'create', + providerType: target.providerType, + ...(target.slug === undefined ? {} : { slug: target.slug }), + ...(target.name === undefined ? {} : { name: target.name }), + } + : null; +} + +function isTerminalOAuthProjection(projection: OAuthLoginProjection): boolean { + return ( + projection.phase === 'authenticated' || + projection.phase === 'cancelled' || + projection.phase === 'failed' + ); +} + +function waitForOAuthPoll(milliseconds: number, signal?: AbortSignal): Promise { + if (milliseconds <= 0 || signal?.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timeout = setTimeout(done, milliseconds); + signal?.addEventListener('abort', done, { once: true }); + function done(): void { + clearTimeout(timeout); + signal?.removeEventListener('abort', done); + resolve(); + } + }); } export function projectRuntimeHostModelChoices(catalog: ConnectionCatalogSnapshot): ModelChoice[] { @@ -128,7 +348,10 @@ export function projectRuntimeHostConnectionIdentities( })); } -export function projectProviders(catalog: ConnectionCatalogSnapshot): OnboardingProviderEntry[] { +export function projectProviders( + catalog: ConnectionCatalogSnapshot, + codexOAuthEnabled = false, +): OnboardingProviderEntry[] { const entries: OnboardingProviderEntry[] = []; const existingSlugs = catalog.connections.map((connection) => connection.slug); for (const provider of listApiKeyOnboardableProviders()) { @@ -149,6 +372,31 @@ export function projectProviders(catalog: ConnectionCatalogSnapshot): Onboarding suggestedSlug: deriveConnectionSlug(provider.providerType, existingSlugs), enabledModelIds: [], }); + if (provider.providerType === 'openai' && codexOAuthEnabled) { + const providerType = 'openai-codex' as const; + const definition = PROVIDER_REGISTRY[providerType]; + for (const connection of catalog.connections) { + if (connection.providerType !== providerType) continue; + entries.push({ + providerType, + label: `${connection.name} · ${connection.slug}`, + requiresBaseUrl: false, + setupMethod: 'oauth', + target: { kind: 'existing', connectionId: connection.connectionId }, + connectionSlug: connection.slug, + enabledModelIds: [...connection.enabledModelIds], + }); + } + entries.push({ + providerType, + label: definition.label, + requiresBaseUrl: false, + setupMethod: 'oauth', + target: { kind: 'create', providerType }, + suggestedSlug: deriveInteractiveOAuthConnectionSlug(providerType, existingSlugs), + enabledModelIds: [...providerFallbackModelIds(definition)], + }); + } } return entries; } diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index e8edf22d42..f7f2b2b03f 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -27,6 +27,7 @@ import { createForeignSessionStore } from '@maka/storage/foreign-session-store'; import { formatMakaResumeHint } from './cli-invocation.js'; import { connectRuntimeHostCli, + connectRuntimeHostCliConnection, resolveRuntimeHostCliConflictDecision, RuntimeHostCliConflictError, } from './runtime-host-cli-context.js'; @@ -216,6 +217,15 @@ async function runFirstRunOnboarding( interactiveSsh: true, ...(hostProfileId ? { profileId: hostProfileId } : {}), }); + const onboarding = createRuntimeHostOnboardingSurface(connected.connection, { + connectOAuth: (signal) => + connectRuntimeHostCliConnection({ + clientDataRoot, + rootPath, + profileId: connected.profile.id, + signal, + }), + }); try { await runMakaPiTui({ driver: createFirstRunSessionDriver(), @@ -229,11 +239,15 @@ async function runFirstRunOnboarding( turnActivity: { activities: new SessionActivityRegistry(), } satisfies MakaPiTuiTurnActivitySurface, - onboarding: createRuntimeHostOnboardingSurface(connected.connection), + onboarding, }); return (await readRuntimeHostConnectionCatalog(connected.connection)).defaultTarget !== null; } finally { - await connected.close(); + try { + await onboarding.close(); + } finally { + await connected.close(); + } } } diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index f4456ecf1a..a31b9e5317 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -49,6 +49,7 @@ import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile- import type { AgentGraphClientSnapshot, WorkspaceTarget } from '@maka/runtime-host/protocol'; import { connectRuntimeHostCli, + connectRuntimeHostCliConnection, readHostChatDefaultPermissionMode, resolveRuntimeHostCliTarget, } from './runtime-host-cli-context.js'; @@ -207,6 +208,15 @@ export async function createRuntimeHostTuiContext( choice.connectionSlug === selectedTarget.connectionSlug && choice.model === selectedTarget.model, )?.contextWindow; + const onboarding = createRuntimeHostOnboardingSurface(connection, { + connectOAuth: (signal) => + connectRuntimeHostCliConnection({ + clientDataRoot: input.clientDataRoot, + rootPath: input.rootPath, + profileId: connected.profile.id, + signal, + }), + }); return { connection, driver, @@ -247,25 +257,34 @@ export async function createRuntimeHostTuiContext( ), agentGraphHistory: createRuntimeHostAgentGraphHistory(connection), recap: createRuntimeHostRecapGenerator(connection), - onboarding: createRuntimeHostOnboardingSurface(connection), + onboarding, ...(mcp ? { mcp } : {}), profile: connected.profile, - close: () => closeRuntimeHostTuiContext(mcp, owner, connected.close), + close: () => closeRuntimeHostTuiContext(onboarding, mcp, owner, connected.close), }; } catch (error) { - await closeRuntimeHostTuiContext(mcp, sessionCopyCleanupOwner, connected.close).catch( - () => undefined, - ); + await closeRuntimeHostTuiContext( + undefined, + mcp, + sessionCopyCleanupOwner, + connected.close, + ).catch(() => undefined); throw error; } } async function closeRuntimeHostTuiContext( + onboarding: ReturnType | undefined, mcp: TuiMcpController | undefined, sessionCopyCleanupOwner: ProcessLifetimeOwner | undefined, closeConnection: () => Promise, ): Promise { const errors: unknown[] = []; + try { + await onboarding?.close(); + } catch (error) { + errors.push(error); + } try { await mcp?.close(); } catch (error) { diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 3cfdd9cf26..35a3715c53 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -311,7 +311,7 @@ export const TUI_COPY_RESOURCES = { en: { modelPickerTitle: 'Select Model', modelSwitchCacheWarning: - '⚠ Switching models may rebuild the prompt cache; the next request may be slower or cost more.', + '! Switching models may rebuild the prompt cache; the next request may be slower or cost more.', modelSearchHint: 'Search models / providers / connections · ↑↓ select · Enter confirm · Esc cancel', searchLabel: 'Search', @@ -362,10 +362,23 @@ export const TUI_COPY_RESOURCES = { invalid_response: 'The provider returned an invalid response. Try again.', unknown: 'Provider verification failed. Try again.', }, + oauthFailures: { + capability_unavailable: 'This client cannot present OAuth sign-in.', + authorization_failed: 'Authorization did not complete. Try again.', + provider_rejected: 'The provider rejected this account.', + slug_taken: 'That slug is already taken. Pick another, or clear it to auto-assign.', + credential_changed: 'The credential changed during sign-in. Try again.', + connection_changed: 'The connection changed during sign-in. Reopen /setup.', + persistence_failed: 'The signed-in account could not be saved. Try again.', + internal_failure: 'OAuth sign-in failed. Try again.', + connection_not_found: 'This connection no longer exists. Reopen /setup.', + operation_conflict: 'Another OAuth login is active, or this identity is unavailable.', + unavailable: 'Could not reach the Runtime Host. Try again.', + }, accountSavedRefreshFailed: 'Account saved, but the model list has not refreshed. Restart Maka to reload it.', listProvidersFailed: 'Could not read configured connections.', - noConfigurableProviders: 'No configurable API key providers are available.', + noConfigurableProviders: 'No configurable providers are available.', baseUrlRequired: 'Enter a Base URL', baseUrlInvalid: 'Base URL is not a valid URL', baseUrlProtocol: 'Base URL must use http or https', @@ -400,6 +413,15 @@ export const TUI_COPY_RESOURCES = { }, submitAction: 'Enter to submit', verifyingKey: 'Verifying key…', + oauthHint: 'Open the device page and enter the sign-in code · Esc returns', + oauthCodeLabel: 'Sign-in code', + oauthStarting: 'Preparing sign-in…', + oauthWaiting: 'Waiting for browser authorization…', + oauthCancelling: 'Cancelling sign-in…', + oauthLoadingModels: 'Signed in · Loading models…', + oauthContinueToModels: 'Signed in · Enter to continue to models', + oauthRetryAction: 'Enter retry · Esc returns', + oauthRetryModelsAction: 'Signed in · Enter retries model loading', modelSelectionHint: 'Search models · ↑↓ select · Space toggle · Enter save · Esc back', selectedModels: '{count} selected', selectedModelsAndSave: '{count} selected · Enter to save', @@ -411,7 +433,7 @@ export const TUI_COPY_RESOURCES = { }, zh: { modelPickerTitle: '选择模型', - modelSwitchCacheWarning: '⚠ 切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高。', + modelSwitchCacheWarning: '! 切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高。', modelSearchHint: '搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消', searchLabel: '搜索', noMatchingModels: '没有匹配的模型', @@ -459,9 +481,22 @@ export const TUI_COPY_RESOURCES = { invalid_response: '服务商返回了无效响应,请重试。', unknown: '服务商验证失败,请重试。', }, + oauthFailures: { + capability_unavailable: '当前客户端无法呈现 OAuth 登录。', + authorization_failed: '授权未完成,请重试。', + provider_rejected: '服务商拒绝了该账号。', + slug_taken: '该标识已被占用。请更换,或清空后自动分配。', + credential_changed: '登录期间凭据已变化,请重试。', + connection_changed: '登录期间连接已变化,请重新打开 /setup。', + persistence_failed: '无法保存已登录的账号,请重试。', + internal_failure: 'OAuth 登录失败,请重试。', + connection_not_found: '该连接已不存在,请重新打开 /setup。', + operation_conflict: '已有 OAuth 登录正在进行,或该连接标识不可用。', + unavailable: '无法连接 Runtime Host,请重试。', + }, accountSavedRefreshFailed: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', listProvidersFailed: '无法读取已配置的连接。', - noConfigurableProviders: '没有可配置的 API key 类供应商。', + noConfigurableProviders: '没有可配置的模型提供商。', baseUrlRequired: '需要填写 Base URL', baseUrlInvalid: 'Base URL 不是有效的 URL', baseUrlProtocol: 'Base URL 必须使用 http 或 https', @@ -489,6 +524,15 @@ export const TUI_COPY_RESOURCES = { }, submitAction: 'Enter 提交', verifyingKey: '正在验证 key…', + oauthHint: '打开设备登录页面并输入验证码 · Esc 返回', + oauthCodeLabel: '登录验证码', + oauthStarting: '正在准备登录…', + oauthWaiting: '正在等待浏览器授权…', + oauthCancelling: '正在取消登录…', + oauthLoadingModels: '登录成功 · 正在加载模型…', + oauthContinueToModels: '已登录 · Enter 继续选择模型', + oauthRetryAction: 'Enter 重试 · Esc 返回', + oauthRetryModelsAction: '已登录 · Enter 重试加载模型', modelSelectionHint: '搜索模型,↑↓ 选择 · Space 切换 · Enter 保存 · Esc 返回', selectedModels: '已选 {count}', selectedModelsAndSave: '已选 {count} · Enter 保存', @@ -526,7 +570,7 @@ export const TUI_COPY_RESOURCES = { resume: 'Resume latest interrupted run at a safe boundary', rewind: 'Rewind to an earlier turn', session: 'Resume session', - setup: 'Set up a model provider (API key)', + setup: 'Set up a model provider', side: 'Open a temporary side conversation', skill: 'Invoke a skill (or type /skill: inline)', swarm: 'Show, enable, disable, or run one Swarm turn', @@ -577,7 +621,7 @@ export const TUI_COPY_RESOURCES = { resume: '从安全边界恢复最近一次中断的执行', rewind: '回退到较早的对话轮次', session: '切换或恢复会话', - setup: '配置模型提供商(API Key)', + setup: '配置模型提供商', side: '打开临时 Side Conversation', skill: '调用 Skill(也可直接输入 /skill:)', swarm: '查看、启用、停用 Swarm 模式,或执行一次 Swarm 任务', @@ -674,7 +718,7 @@ export const TUI_COPY_RESOURCES = { en: { withRecovery: '{notice} {recovery}', emptyChoiceRecovery: - 'If /model has no choices, add or enable a connection first (run /setup for API-key connections).', + 'If /model has no choices, add or enable a connection first (run /setup to add one).', confirmAccount: 'This task comes from an older version and needs a one-time account confirmation. Run /model and choose an existing account and model.', accountDeleted: @@ -686,8 +730,7 @@ export const TUI_COPY_RESOURCES = { }, zh: { withRecovery: '{notice}{recovery}', - emptyChoiceRecovery: - '如果 /model 没有可选项,请先添加或启用连接(API Key 连接可运行 /setup)。', + emptyChoiceRecovery: '如果 /model 没有可选项,请先添加或启用连接(可运行 /setup 添加)。', confirmAccount: '此任务来自旧版本,需要确认一次账号。运行 /model 选择现有账号和模型。', accountDeleted: '原账号已删除;运行 /model 选择新账号和模型后继续。', identityMismatch: '任务保存的账号身份与当前连接不一致;运行 /model 重新选择账号和模型。', diff --git a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts index 0febf6d2a8..e8aca9060f 100644 --- a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts @@ -177,6 +177,76 @@ test('authenticated OAuth attempts reconcile by attemptId after Host restart', a }); }); +test('OAuth create identity is part of the attempt binding', async () => { + await withFixture('openai-codex', async (fixture) => { + const client = await attachPresentation(fixture.capabilities, 'client-oauth-create', []); + 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; + }, + now: () => NOW, + startCodexAuthorization: async () => ({ + deviceAuthId: 'deviceauth-custom-identity', + userCode: 'CODE-CUSTOM', + verificationUrl: 'https://auth.openai.com/codex/device', + expiresAt: NOW + 60_000, + intervalMs: 1_000, + }), + pollCodexAuthorization: async () => ({ + authorizationCode: 'custom-code', + codeVerifier: 'custom-verifier', + }), + exchangeCodexCode: async () => tokenFixture('custom-access'), + }); + const input = { + attemptId: 'attempt-custom-identity', + target: { + kind: 'create' as const, + providerType: 'openai-codex' as const, + slug: 'codex-work', + name: 'Work Codex', + }, + }; + + const started = await coordinator.handlers['oauth.login.start']( + input, + operationContext('client-oauth-create', fixture.acquireResidency), + ); + assert.equal(started.ok, true); + assert.equal((await waitForTerminal(coordinator, input.attemptId)).phase, 'authenticated'); + const created = (await fixture.stores.connectionCatalog.getSnapshot()).connections.find( + ({ slug }) => slug === 'codex-work', + ); + assert.equal(created?.name, 'Work Codex'); + + const rebound = await coordinator.handlers['oauth.login.start']( + { ...input, target: { ...input.target, name: 'Other Codex' } }, + operationContext('client-oauth-create', fixture.acquireResidency), + ); + assert.equal(rebound.ok, false); + if (!rebound.ok) assert.equal(rebound.error.code, 'invalid_request'); + + const collision = await coordinator.handlers['oauth.login.start']( + { + attemptId: 'attempt-custom-identity-collision', + target: { ...input.target, name: 'Other Codex' }, + }, + operationContext('client-oauth-create', fixture.acquireResidency), + ); + assert.equal(collision.ok, false); + if (!collision.ok) assert.equal(String(collision.error.code), 'slug_taken'); + + await coordinator.close(); + client.close(); + }); +}); + test('durable OAuth receipt failures stay bounded on start, query, and cancel', async () => { await withFixture('openai-codex', async (fixture) => { await writeFile( diff --git a/packages/runtime-host/src/__tests__/oauth-protocol.test.ts b/packages/runtime-host/src/__tests__/oauth-protocol.test.ts index f51c58d280..bf2f270d8d 100644 --- a/packages/runtime-host/src/__tests__/oauth-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-protocol.test.ts @@ -31,6 +31,50 @@ import { } from '../protocol/index.js'; test('OAuth login protocol binds attempt identity and closes terminal projections', () => { + assert.deepEqual( + decodeClientFrame({ + requestId: 'create-request', + operation: 'oauth.login.start', + input: { + attemptId: 'create-attempt', + target: { + kind: 'create', + providerType: 'openai-codex', + slug: 'codex-work', + name: 'Work Codex', + }, + }, + }), + { + requestId: 'create-request', + operation: 'oauth.login.start', + input: { + attemptId: 'create-attempt', + target: { + kind: 'create', + providerType: 'openai-codex', + slug: 'codex-work', + name: 'Work Codex', + }, + }, + }, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'unnamed-provider-request', + operation: 'oauth.login.start', + input: { + attemptId: 'unnamed-provider-attempt', + target: { + kind: 'create', + providerType: 'xai-oauth', + slug: 'xai-work', + }, + }, + }), + RuntimeHostProtocolError, + ); assert.deepEqual( decodeClientFrame({ requestId: 'request', @@ -121,6 +165,22 @@ test('OAuth login protocol binds attempt identity and closes terminal projection ); }); +test('OAuth slug collisions stay typed in operation errors and terminal projections', () => { + const projection = { + attemptId: 'attempt-slug-collision', + connection: { + connectionId: 'connection-id', + slug: 'codex-work', + providerType: 'openai-codex', + }, + phase: 'failed', + failure: 'slug_taken', + } as const; + + assert.ok(OAUTH_OPERATION_SPECS['oauth.login.start'].errors.includes('slug_taken' as never)); + assert.deepEqual(decodeOAuthLoginProjection(projection), projection); +}); + test('OAuth enrollment query carries the provider and its Host gate answer', () => { assert.deepEqual( decodeClientFrame({ diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 6f8210429e..ecc6f625d8 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -259,6 +259,13 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 102); }); + test('publishes a new compatibility epoch for named OAuth identity and slug failures', () => { + // Epoch 109 is the current main boundary. Named create inputs and the + // slug_taken output extend closed wire shapes, so older peers must be + // rejected during handshake rather than failing midway through setup. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 109); + }); + test('publishes a new compatibility epoch for context-budget failure detail', () => { // Epoch 50 is already used by WorkHub coordination summaries on main. // The context-budget detail therefore needs its own strictly newer diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d5a69a4d1e..328bd54c5c 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,10 @@ 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 = 109 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 110 as const; +// 110: OAuth create targets may carry a caller-selected Connection name and +// slug, and slug collisions remain a closed typed error before or after +// authorization. Older peers reject those strict input and output shapes. // 109: accepted Client Capability invocations may carry one bounded nested form // Interaction request/result round trip. // 108: Session Interaction snapshots, forwarded Runtime events, and Agent Graph diff --git a/packages/runtime-host/src/protocol/oauth.ts b/packages/runtime-host/src/protocol/oauth.ts index 472487fc5c..9bae5c3264 100644 --- a/packages/runtime-host/src/protocol/oauth.ts +++ b/packages/runtime-host/src/protocol/oauth.ts @@ -17,7 +17,11 @@ * under the License. */ -import { decodeConnectionSlug, RuntimePolicyDomainDecodeError } from '@maka/core/runtime-policy'; +import { + decodeConnectionName, + decodeConnectionSlug, + RuntimePolicyDomainDecodeError, +} from '@maka/core/runtime-policy'; import { requireEntityId, requireExactRecord, @@ -45,6 +49,7 @@ export const OAUTH_LOGIN_FAILURE_CODES = [ 'capability_unavailable', 'authorization_failed', 'provider_rejected', + 'slug_taken', 'credential_changed', 'connection_changed', 'persistence_failed', @@ -61,6 +66,7 @@ const COMMON_ERRORS = [ const START_ERRORS = [ ...COMMON_ERRORS, 'operation_conflict', + 'slug_taken', 'capability_unavailable', 'not_found', 'persistence_failed', @@ -107,7 +113,18 @@ export interface OAuthLoginStartInput { } export type OAuthLoginTarget = - | { readonly kind: 'create'; readonly providerType: OAuthLoginProvider } + | { + readonly kind: 'create'; + readonly providerType: 'openai-codex'; + readonly slug?: string; + readonly name?: string; + } + | { + readonly kind: 'create'; + readonly providerType: Exclude; + readonly slug?: never; + readonly name?: never; + } | { readonly kind: 'existing'; readonly connectionId: string }; export interface OAuthConnectionIdentity { @@ -223,8 +240,31 @@ export function decodeOAuthLoginProjection(value: unknown): OAuthLoginProjection 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) }; + const exact = requireShapedRecord( + target, + 'OAuth create target', + ['kind', 'providerType'], + ['slug', 'name'], + ); + const providerType = oauthLoginProvider(exact.providerType); + if (providerType !== 'openai-codex') { + if (exact.slug !== undefined || exact.name !== undefined) { + throw invalidProtocolFrame( + 'Custom OAuth Connection identity is only supported for openai-codex', + ); + } + return { kind: 'create', providerType }; + } + return { + kind: 'create', + providerType, + ...(exact.slug === undefined + ? {} + : { slug: decodeDomain(() => decodeConnectionSlug(exact.slug)) }), + ...(exact.name === undefined + ? {} + : { name: decodeDomain(() => decodeConnectionName(exact.name)) }), + }; } if (target.kind === 'existing') { const exact = requireExactRecord(target, 'OAuth existing target', ['kind', 'connectionId']); @@ -250,7 +290,8 @@ function assertOAuthStartOutput(input: OAuthLoginStartInput, output: OAuthLoginP assertOAuthAttemptOutput(input, output); if ( (input.target.kind === 'create' && - output.connection.providerType !== input.target.providerType) || + (output.connection.providerType !== input.target.providerType || + (input.target.slug !== undefined && output.connection.slug !== input.target.slug))) || (input.target.kind === 'existing' && output.connection.connectionId !== input.target.connectionId) ) { diff --git a/packages/runtime-host/src/protocol/operation-spec.ts b/packages/runtime-host/src/protocol/operation-spec.ts index 7b85f061a4..9f2791c5ee 100644 --- a/packages/runtime-host/src/protocol/operation-spec.ts +++ b/packages/runtime-host/src/protocol/operation-spec.ts @@ -30,6 +30,7 @@ export type HostOperationErrorCode = | 'session_busy' | 'operation_conflict' | 'capability_unavailable' + | 'slug_taken' | 'invalid_request' | 'projection_incomplete' | 'stale_cursor' diff --git a/packages/runtime-host/src/server/oauth-coordinator.ts b/packages/runtime-host/src/server/oauth-coordinator.ts index d8997a8df1..a9edb3bd7d 100644 --- a/packages/runtime-host/src/server/oauth-coordinator.ts +++ b/packages/runtime-host/src/server/oauth-coordinator.ts @@ -285,6 +285,9 @@ export class HostOAuthCoordinator { if (admitted.kind === 'catalog_full') { return operationConflict('OAuth Connection capacity is exhausted'); } + if (admitted.kind === 'slug_taken') { + return slugTaken('OAuth Connection slug is already in use'); + } if (admitted.kind === 'attempt_conflict') { return invalidRequest('OAuth attemptId is already bound to another connection'); } @@ -405,6 +408,9 @@ export class HostOAuthCoordinator { attempt.ticket.ticket, serializeOAuthSubscriptionTokens(tokens), ); + if (completion.kind === 'slug_taken') { + throw new LoginFailure('slug_taken'); + } if (completion.kind !== 'committed') { throw new LoginFailure( completion.changed.includes('connection') ? 'connection_changed' : 'credential_changed', @@ -656,7 +662,10 @@ function sameOAuthLoginTarget(actual: OAuthLoginTarget, expected: OAuthLoginTarg return ( actual.kind === expected.kind && (actual.kind === 'create' - ? expected.kind === 'create' && actual.providerType === expected.providerType + ? expected.kind === 'create' && + actual.providerType === expected.providerType && + actual.slug === expected.slug && + actual.name === expected.name : expected.kind === 'existing' && actual.connectionId === expected.connectionId) ); } @@ -706,6 +715,10 @@ function operationConflict(message: string) { return { ok: false, error: { code: 'operation_conflict', message } } as const; } +function slugTaken(message: string) { + return { ok: false, error: { code: 'slug_taken', 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 dbc20f4ac0..70f41f448a 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -4221,6 +4221,115 @@ describe('runtime policy stores', () => { }); }); + test('interactive OAuth create commits the requested Connection name and slug', async () => { + await withInteractiveOwner(async ({ stores }) => { + const target = { + kind: 'create' as const, + providerType: 'openai-codex' as const, + slug: 'codex-work', + name: 'Work Codex', + }; + const admitted = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-custom-identity', + target, + }); + assert.equal(admitted.kind, 'ready'); + if (admitted.kind !== 'ready') return; + assert.deepEqual(admitted.identity, { + connectionId: admitted.identity.connectionId, + slug: 'codex-work', + providerType: 'openai-codex', + }); + assert.equal(admitted.connection.name, 'Work Codex'); + + const completed = await stores.operations.completeInteractiveOAuthLogin( + admitted.ticket, + 'oauth-custom-secret', + ); + assert.equal(completed.kind, 'committed'); + const saved = (await stores.connectionCatalog.getSnapshot()).connections[0]; + assert.equal(saved?.connectionId, admitted.identity.connectionId); + assert.equal(saved?.slug, 'codex-work'); + assert.equal(saved?.name, 'Work Codex'); + assert.deepEqual( + await stores.operations.queryInteractiveOAuthLogin('oauth-custom-identity'), + { + kind: 'authenticated', + target, + connection: admitted.identity, + }, + ); + + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-custom-identity-collision', + target: { ...target, name: 'Other Codex' }, + }), + { kind: 'slug_taken' }, + ); + }); + }); + + test('interactive OAuth custom identity is limited to OpenAI Codex', async () => { + await withInteractiveOwner(async ({ stores }) => { + await assert.rejects( + stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-xai-custom-identity', + target: { + kind: 'create', + providerType: 'xai-oauth', + slug: 'xai-work', + } as never, + }), + isStoreError('invalid_connection_input'), + ); + }); + }); + + test('interactive OAuth create reports a slug collision that wins the commit race', async () => { + await withInteractiveOwner(async ({ stores }) => { + const target = { + kind: 'create' as const, + providerType: 'openai-codex' as const, + slug: 'codex-work', + name: 'Work Codex', + }; + const admitted = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-custom-identity-race', + target, + }); + assert.equal(admitted.kind, 'ready'); + if (admitted.kind !== 'ready') return; + + const concurrent = await createConnection( + stores, + 0, + connectionDraft('codex-work', 'openai', 'Concurrent Connection'), + ); + assert.deepEqual( + await stores.operations.completeInteractiveOAuthLogin( + admitted.ticket, + 'oauth-custom-secret', + ), + { kind: 'slug_taken' }, + ); + assert.deepEqual( + (await stores.connectionCatalog.getSnapshot()).connections.map( + ({ connectionId, slug }) => ({ connectionId, slug }), + ), + [{ connectionId: concurrent.connectionId, slug: 'codex-work' }], + ); + assert.equal( + await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId: admitted.identity.connectionId, + kind: 'oauth_token', + }), + null, + ); + }); + }); + test('interactive OAuth existing login re-enables only its frozen entity', async () => { await withInteractiveOwner(async ({ stores }) => { const original = await createConnection(stores, 0, { diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index d303658f4b..782eb70b41 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -630,17 +630,26 @@ export class RuntimePolicyCoordinator { readonly providerType: InteractiveOAuthLoginProvider; }; if (input.target.kind === 'create') { + const requestedSlug = input.target.slug; if (catalog.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { return deepFreeze({ kind: 'catalog_full' as const }); } + if ( + requestedSlug !== undefined && + catalog.connections.some(({ slug }) => slug === requestedSlug) + ) { + return deepFreeze({ kind: 'slug_taken' as const }); + } connectionBefore = null; connectionAfter = newInteractiveOAuthConnection( randomUUID(), - deriveInteractiveOAuthConnectionSlug( - input.target.providerType, - catalog.connections.map(({ slug }) => slug), - ), + requestedSlug ?? + deriveInteractiveOAuthConnectionSlug( + input.target.providerType, + catalog.connections.map(({ slug }) => slug), + ), input.target.providerType, + input.target.name, ); } else { const existing = findConnection(catalog, { connectionId: input.target.connectionId }); @@ -731,6 +740,20 @@ export class RuntimePolicyCoordinator { claimed.connectionBefore, claimed.connectionAfter, ); + const requestedSlug = claimed.target.kind === 'create' ? claimed.target.slug : undefined; + if ( + preparedCatalog.kind === 'connection_conflict' && + requestedSlug !== undefined && + catalog.connections.some( + ({ connectionId, slug }) => + slug === requestedSlug && connectionId !== claimed.connectionAfter.connectionId, + ) + ) { + // A caller-selected identity has one stable, actionable outcome even + // when another writer claims it after OAuth admission but before the + // token commit. No credential or Connection has been written yet. + return deepFreeze({ kind: 'slug_taken' as const }); + } if (preparedCatalog.kind !== 'ready') { changed.push('connection'); } @@ -2534,7 +2557,31 @@ function normalizeInteractiveOAuthLoginInput( if (!isInteractiveOAuthLoginProvider(providerType)) { throw codecError('invalid_connection_input', 'OAuth create target provider is unsupported'); } - return { attemptId, target: { kind: 'create', providerType } }; + if ( + providerType !== 'openai-codex' && + (target.slug !== undefined || target.name !== undefined) + ) { + throw codecError( + 'invalid_connection_input', + 'Custom OAuth Connection identity is only supported for openai-codex', + ); + } + if (providerType !== 'openai-codex') { + return { attemptId, target: { kind: 'create', providerType } }; + } + return { + attemptId, + target: { + kind: 'create', + providerType, + ...(target.slug === undefined + ? {} + : { slug: decodeConnectionInput(() => decodeConnectionSlug(target.slug)) }), + ...(target.name === undefined + ? {} + : { name: decodeConnectionInput(() => decodeConnectionName(target.name)) }), + }, + }; } if (target?.kind === 'existing') { return { @@ -2552,13 +2599,14 @@ function newInteractiveOAuthConnection( connectionId: string, slug: string, providerType: InteractiveOAuthLoginProvider, + name?: string, ): ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider } { const defaults = PROVIDER_REGISTRY[providerType]; return { connectionId, revision: 1, slug, - name: defaults.label, + name: name ?? defaults.label, providerType, enabled: true, enabledModelIds: providerFallbackModelIds(defaults), diff --git a/packages/storage/src/runtime-policy/oauth-login-receipt-document.ts b/packages/storage/src/runtime-policy/oauth-login-receipt-document.ts index ffc43870b8..56de264c2b 100644 --- a/packages/storage/src/runtime-policy/oauth-login-receipt-document.ts +++ b/packages/storage/src/runtime-policy/oauth-login-receipt-document.ts @@ -18,6 +18,7 @@ */ import { + decodeConnectionName, decodeConnectionSlug, decodeProviderType, decodeRuntimePolicyEntityId, @@ -192,12 +193,35 @@ function decodeTarget(value: unknown): InteractiveOAuthLoginTarget { value, 'OAuth login receipt target', 'invalid_document', - ['kind', 'providerType', 'connectionId'], + ['kind', 'providerType', 'connectionId', 'slug', 'name'], ['kind'], ); if (base.kind === 'create') { - const item = record(value, 'OAuth create target', 'invalid_document', ['kind', 'providerType']); - return { kind: 'create', providerType: decodeOAuthProvider(item.providerType) }; + const item = record( + value, + 'OAuth create target', + 'invalid_document', + ['kind', 'providerType', 'slug', 'name'], + ['kind', 'providerType'], + ); + const providerType = decodeOAuthProvider(item.providerType); + if (providerType !== 'openai-codex' && (item.slug !== undefined || item.name !== undefined)) { + throw codecError( + 'invalid_document', + 'Custom OAuth Connection identity is only supported for openai-codex', + ); + } + if (providerType !== 'openai-codex') return { kind: 'create', providerType }; + return { + kind: 'create', + providerType, + ...(item.slug === undefined + ? {} + : { slug: decodePersistedDomain(() => decodeConnectionSlug(item.slug)) }), + ...(item.name === undefined + ? {} + : { name: decodePersistedDomain(() => decodeConnectionName(item.name)) }), + }; } if (base.kind === 'existing') { const item = record(value, 'OAuth existing target', 'invalid_document', [ @@ -249,7 +273,10 @@ function sameTarget(actual: InteractiveOAuthLoginTarget, expected: InteractiveOA return ( actual.kind === expected.kind && (actual.kind === 'create' - ? expected.kind === 'create' && actual.providerType === expected.providerType + ? expected.kind === 'create' && + actual.providerType === expected.providerType && + actual.slug === expected.slug && + actual.name === expected.name : expected.kind === 'existing' && actual.connectionId === expected.connectionId) ); } @@ -270,6 +297,7 @@ function targetMatchesIdentity( connection: InteractiveOAuthConnectionIdentity, ): boolean { return target.kind === 'create' - ? target.providerType === connection.providerType + ? target.providerType === connection.providerType && + (target.slug === undefined || target.slug === connection.slug) : 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 e1ec0c41e5..df7d26d81e 100644 --- a/packages/storage/src/runtime-policy/onboarding-transaction.ts +++ b/packages/storage/src/runtime-policy/onboarding-transaction.ts @@ -297,7 +297,7 @@ export function prepareInteractiveOAuthEnrollmentIntent(input: { schemaVersion: OAUTH_SCHEMA_VERSION, kind: 'oauth_enrollment', attemptId: decodeOAuthAttemptId(input.attemptId, 'invalid_connection_input'), - target: structuredClone(input.target), + target: decodeOAuthTarget(input.target, 'invalid_connection_input'), connectionBefore: input.connectionBefore === null ? null @@ -338,10 +338,16 @@ function decodeInteractiveOAuthEnrollmentIntent(value: unknown): InteractiveOAut raw.credentialBasis === null ? null : decodePersistedDomain(() => decodeCredentialVersionBasis(raw.credentialBasis)); - const target = decodeOAuthTarget(raw.target); + const target = decodeOAuthTarget(raw.target, 'invalid_document'); if ( (target.kind === 'create' && connectionBefore !== null) || (target.kind === 'create' && target.providerType !== connectionAfter.providerType) || + (target.kind === 'create' && + target.slug !== undefined && + target.slug !== connectionAfter.slug) || + (target.kind === 'create' && + target.name !== undefined && + target.name !== connectionAfter.name) || (target.kind === 'existing' && (connectionBefore === null || connectionBefore.connectionId !== target.connectionId)) || connectionAfter.connectionId !== @@ -365,33 +371,55 @@ function decodeInteractiveOAuthEnrollmentIntent(value: unknown): InteractiveOAut }; } -function decodeOAuthTarget(value: unknown): InteractiveOAuthLoginTarget { +function decodeOAuthTarget( + value: unknown, + source: 'invalid_connection_input' | 'invalid_document', +): InteractiveOAuthLoginTarget { const base = record( value, 'OAuth enrollment target', - 'invalid_document', - ['kind', 'providerType', 'connectionId'], + source, + ['kind', 'providerType', 'connectionId', 'slug', 'name'], ['kind'], ); if (base.kind === 'create') { - const item = record(value, 'OAuth create target', 'invalid_document', ['kind', 'providerType']); - const providerType = decodePersistedDomain(() => decodeProviderType(item.providerType)); + const item = record( + value, + 'OAuth create target', + source, + ['kind', 'providerType', 'slug', 'name'], + ['kind', 'providerType'], + ); + const decode = source === 'invalid_document' ? decodePersistedDomain : decodeConnectionInput; + const providerType = decode(() => decodeProviderType(item.providerType)); if (!isOAuthProvider(providerType)) { - throw codecError('invalid_document', 'OAuth create target provider is invalid'); + throw codecError(source, 'OAuth create target provider is invalid'); + } + if (providerType !== 'openai-codex' && (item.slug !== undefined || item.name !== undefined)) { + throw codecError( + source, + 'Custom OAuth Connection identity is only supported for openai-codex', + ); } - return { kind: 'create', providerType }; + if (providerType !== 'openai-codex') return { kind: 'create', providerType }; + return { + kind: 'create', + providerType, + ...(item.slug === undefined ? {} : { slug: decode(() => decodeConnectionSlug(item.slug)) }), + ...(item.name === undefined ? {} : { name: decode(() => decodeConnectionName(item.name)) }), + }; } if (base.kind === 'existing') { - const item = record(value, 'OAuth existing target', 'invalid_document', [ - 'kind', - 'connectionId', - ]); + const item = record(value, 'OAuth existing target', source, ['kind', 'connectionId']); return { kind: 'existing', - connectionId: decodePersistedDomain(() => decodeRuntimePolicyEntityId(item.connectionId)), + connectionId: + source === 'invalid_document' + ? decodePersistedDomain(() => decodeRuntimePolicyEntityId(item.connectionId)) + : decodeConnectionInput(() => decodeRuntimePolicyEntityId(item.connectionId)), }; } - throw codecError('invalid_document', 'OAuth enrollment target kind is invalid'); + throw codecError(source, 'OAuth enrollment target kind is invalid'); } function isOAuthProvider( diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index 481d952d51..0ac4cc62a7 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -164,7 +164,18 @@ export type InteractiveOAuthLoginProvider = Extract< >; export type InteractiveOAuthLoginTarget = - | { readonly kind: 'create'; readonly providerType: InteractiveOAuthLoginProvider } + | { + readonly kind: 'create'; + readonly providerType: 'openai-codex'; + readonly slug?: string; + readonly name?: string; + } + | { + readonly kind: 'create'; + readonly providerType: Exclude; + readonly slug?: never; + readonly name?: never; + } | { readonly kind: 'existing'; readonly connectionId: string }; export interface InteractiveOAuthLoginInput { @@ -194,6 +205,7 @@ export type BeginInteractiveOAuthLoginResult = | { readonly kind: 'connection_not_found' } | { readonly kind: 'connection_disabled' } | { readonly kind: 'catalog_full' } + | { readonly kind: 'slug_taken' } | { readonly kind: 'attempt_conflict' } | { readonly kind: 'provider_action_unavailable' } | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } @@ -216,6 +228,7 @@ export type InteractiveOAuthLoginCompletionResult = readonly revision: number; readonly connection: InteractiveOAuthConnectionIdentity; } + | { readonly kind: 'slug_taken' } | { readonly kind: 'superseded'; readonly changed: readonly Extract<