diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2f8f24e25e..4b1534321a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3676,9 +3676,7 @@ "window.maka.githubCopilotSubscription.logout": 1, "window.maka.githubCopilotSubscription.refreshTokens": 1, "window.maka.openAiCodex": 1, - "window.maka.openAiCodex.getAccountState": 1, - "window.maka.xaiOAuth": 1, - "window.maka.xaiOAuth.getAccountState": 1 + "window.maka.xaiOAuth": 1 }, "environmentCapabilities": {}, "hookCalls": { @@ -3985,9 +3983,7 @@ "window.maka.connections.test": 1, "window.maka.connections.update": 1 }, - "environmentCapabilities": { - "window": 2 - }, + "environmentCapabilities": {}, "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts index 8d0302037d..5bf2621988 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -29,7 +29,7 @@ import { PROVIDER_DEFAULTS, providerSupportsModelDiscovery, type CreateConnectionInput, - type LlmConnection, + type IdentifiedLlmConnection, type ProviderType, } from '@maka/core/llm-connections'; @@ -53,8 +53,9 @@ function draft(over: Partial = {}): AddProviderDraft { }; } -function connection(slug: string): LlmConnection { +function connection(slug: string): IdentifiedLlmConnection { return { + connectionId: `connection-${slug}`, slug, name: slug, providerType: 'openai-compatible', @@ -62,12 +63,12 @@ function connection(slug: string): LlmConnection { enabled: true, createdAt: 0, updatedAt: 0, - } as LlmConnection; + } as IdentifiedLlmConnection; } function bridge(over: { - create?: (input: CreateConnectionInput) => Promise; - fetchModels?: (slug: string) => Promise; + create?: (input: CreateConnectionInput) => Promise; + fetchModels?: (connection: { readonly connectionId: string; readonly slug: string }) => Promise; }) { return { create: over.create ?? (async (input) => connection(input.slug)), diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index a452275c24..96e730be8b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -93,7 +93,7 @@ test('retries connection delete after a stale revision instead of failing perman emitConnectionListChanged() {}, }); - await handlers.get('connections:delete')?.({}, 'openrouter'); + await handlers.get('connections:delete')?.({}, connectionIdentity()); assert.equal(removals, 2); }); @@ -123,12 +123,41 @@ test('treats a missing connection as a successful delete without calling remove' }, }); - await handlers.get('connections:delete')?.({}, 'already-gone'); + await handlers.get('connections:delete')?.({}, { + connectionId: 'already-gone', + slug: 'already-gone', + }); assert.equal(removals, 0); assert.equal(listChanged, 1); }); -test('rejects invalid connection slug input instead of treating it as already deleted', async () => { +test('does not delete a replacement Connection that reused the stale detail slug', async () => { + const handlers = new Map unknown>(); + let removals = 0; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + loadConnectionCatalog: async (): Promise => ({ + ...catalog(), + connections: [{ ...catalog().connections[0]!, connectionId: 'connection-2' }], + }), + removeConnection: async () => { + removals += 1; + return { kind: 'committed', catalogRevision: 8 }; + }, + } as never, + emitConnectionListChanged() {}, + }); + + await handlers.get('connections:delete')?.({}, connectionIdentity()); + assert.equal(removals, 0); +}); + +test('rejects invalid connection identity input instead of treating it as already deleted', async () => { const handlers = new Map unknown>(); registerRuntimeHostConnectionsIpc({ ipcMain: { @@ -149,7 +178,7 @@ test('rejects invalid connection slug input instead of treating it as already de await assert.rejects( async () => handlers.get('connections:delete')?.({}, 42), - /Invalid connection slug|connection slug/i, + /Invalid Connection identity/i, ); }); @@ -176,7 +205,7 @@ test('reports an existing but unconfigured credential as missing', async () => { }); assert.equal( - await handlers.get('connections:hasSecret')?.({}, 'openrouter'), + await handlers.get('connections:hasSecret')?.({}, connectionIdentity()), false, ); }); @@ -205,16 +234,16 @@ test('keeps saved custom header values out of the renderer and preserves them by }); assert.deepEqual( - await handlers.get('connections:getRequestHeaders')?.({}, 'openrouter'), + await handlers.get('connections:getRequestHeaders')?.({}, connectionIdentity()), { names: ['HTTP-Referer'] }, ); assert.equal( - JSON.stringify(await handlers.get('connections:getRequestHeaders')?.({}, 'openrouter')).includes('private.example'), + JSON.stringify(await handlers.get('connections:getRequestHeaders')?.({}, connectionIdentity())).includes('private.example'), false, ); assert.deepEqual( - await handlers.get('connections:setRequestHeaders')?.({}, 'openrouter', [ + await handlers.get('connections:setRequestHeaders')?.({}, connectionIdentity(), [ { name: 'HTTP-Referer' }, { name: 'X-Title', value: 'Maka' }, ]), @@ -371,3 +400,7 @@ function catalog(): ConnectionCatalogSnapshot { ], }; } + +function connectionIdentity() { + return { connectionId: 'connection-1', slug: 'openrouter' } as const; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index 64f824e4be..84fa6dd386 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -167,9 +167,18 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { const authorization = await invoke( handlers, 'openai-codex:get-auth-url', - catalog.connections[0]?.connectionId, + { kind: 'existing', connectionId: catalog.connections[0]?.connectionId }, ); - assert.deepEqual(authorization, { authRequestId: attemptId, stateHint: 'STATE-HINT' }); + const expectedConnection = { + connectionId: catalog.connections[0]?.connectionId, + slug: 'openai-codex', + providerType: provider, + }; + assert.deepEqual(authorization, { + authRequestId: attemptId, + stateHint: 'STATE-HINT', + connection: expectedConnection, + }); assert.deepEqual(opened, ['https://codex.example/authorize']); assert.deepEqual( await invoke( @@ -178,7 +187,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { attemptId, 'authorization-code#state', ), - { ok: true }, + { ok: true, connection: expectedConnection }, ); assert.equal(changed, 1); assert.deepEqual(catalog.defaultTarget, { @@ -187,7 +196,11 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { }); // No quota: reporting it required the retired provider's own client identity, // so the account state carries the runtime state alone. - assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), { + assert.deepEqual(await invoke( + handlers, + 'openai-codex:get-account-state', + catalog.connections[0]?.connectionId, + ), { provider, runtimeState: 'authenticated', }); @@ -231,7 +244,10 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide }); assert.deepEqual( - await invoke(handlers, 'openai-codex:get-auth-url', xaiConnection.connectionId), + await invoke(handlers, 'openai-codex:get-auth-url', { + kind: 'existing', + connectionId: xaiConnection.connectionId, + }), { ok: false, reason: 'unknown', @@ -240,14 +256,18 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide ); assert.deepEqual( await invoke(handlers, 'openai-codex:get-account-state', xaiConnection.connectionId), - { provider: 'openai-codex', runtimeState: 'not_logged_in' }, + { + ok: false, + reason: 'unknown', + message: 'OAuth account does not match this provider', + }, ); assert.deepEqual( await invoke(handlers, 'openai-codex:refresh-tokens', xaiConnection.connectionId), { ok: false, reason: 'refresh_failed', - message: 'OAuth account is not connected', + message: 'OAuth account does not match this provider', }, ); assert.deepEqual(await invoke(handlers, 'openai-codex:logout', xaiConnection.connectionId), { @@ -271,7 +291,13 @@ test('malformed OAuth Connection IDs fail closed before catalog or credential ac isProviderEnabled: () => true, }); - for (const malformed of [null, 7, {}]) { + for (const malformed of [ + null, + 7, + {}, + { kind: 'create', connectionId: '00000000-0000-4000-8000-000000000001' }, + { kind: 'existing', connectionId: '00000000-0000-4000-8000-000000000001', extra: true }, + ]) { assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', malformed), { ok: false, reason: 'unknown', @@ -395,9 +421,9 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a isProviderEnabled: () => true, }); - const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url'); + const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }); await firstPresentationPoll; - assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { + assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), { ok: false, reason: 'unknown', message: 'Another OAuth login is already in progress', @@ -413,6 +439,11 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a assert.deepEqual(await firstAuthorization, { authRequestId: firstAttemptId, stateHint: 'FIRST', + connection: { + connectionId, + slug: 'openai-codex', + providerType: provider, + }, }); assert.deepEqual( await invoke(handlers, 'openai-codex:logout', foreignConnection.connectionId), @@ -425,7 +456,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a assert.deepEqual(await invoke(handlers, 'openai-codex:logout'), { ok: false, reason: 'unknown', - message: 'Select a specific OAuth account to log out', + message: 'Invalid OAuth Connection identity', }); assert.equal(cancels, 0); assert.deepEqual( @@ -435,7 +466,14 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a assert.equal(cancels, 0); assert.deepEqual( await invoke(handlers, 'openai-codex:complete-authorization', firstAttemptId), - { ok: true }, + { + ok: true, + connection: { + connectionId, + slug: 'openai-codex', + providerType: provider, + }, + }, ); assert.equal(cancels, 0); assertNoUnexpectedClientCalls(); @@ -475,7 +513,7 @@ test('completion rejects a terminal projection that changes Connection identity' isProviderEnabled: () => true, }); - await invoke(handlers, 'openai-codex:get-auth-url'); + await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }); assert.deepEqual(await invoke(handlers, 'openai-codex:complete-authorization', attemptId), { ok: false, reason: 'unknown', @@ -486,7 +524,7 @@ test('completion rejects a terminal projection that changes Connection identity' assertNoUnexpectedClientCalls(); }); -test('keeps a committed OAuth login successful when model discovery fails', async () => { +test('keeps a committed OAuth login successful when model discovery fails without replacing the existing default', async () => { const provider = 'openai-codex' as const; const modelId = PROVIDER_DEFAULTS[provider].fallbackModels[0]; assert.ok(modelId); @@ -507,7 +545,7 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn }; let catalog: ConnectionCatalogSnapshot = { revision: 1, - defaultTarget: null, + defaultTarget: { connectionId: existing.connectionId, modelId }, connections: [existing], }; const presentation = new RuntimeHostOAuthPresentation(async () => undefined); @@ -550,11 +588,6 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn fetchedConnectionIds.push(connectionId); throw new Error('provider temporarily unavailable'); }, - setDefaultConnectionTarget: async (expectedCatalogRevision, target) => { - assert.equal(expectedCatalogRevision, catalog.revision); - catalog = { ...catalog, revision: catalog.revision + 1, defaultTarget: target }; - return { kind: 'committed' as const, catalogRevision: catalog.revision }; - }, queryCredential: async (locator) => locator.scope === 'connection' && locator.connectionId === created.connectionId ? { @@ -575,18 +608,34 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn isProviderEnabled: () => true, }); - assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { + assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), { authRequestId: attemptId, stateHint: 'DEVICE-CODE', + connection: { + connectionId: created.connectionId, + slug: created.slug, + providerType: provider, + }, }); assert.deepEqual( await invoke(handlers, 'openai-codex:complete-authorization', attemptId, undefined), - { ok: true }, + { + ok: true, + connection: { + connectionId: created.connectionId, + slug: created.slug, + providerType: provider, + }, + }, ); assert.equal(changed, 1); assert.deepEqual(fetchedConnectionIds, [created.connectionId]); - assert.deepEqual(catalog.defaultTarget, { connectionId: created.connectionId, modelId }); - assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), { + assert.deepEqual(catalog.defaultTarget, { connectionId: existing.connectionId, modelId }); + assert.deepEqual(await invoke( + handlers, + 'openai-codex:get-account-state', + created.connectionId, + ), { provider, runtimeState: 'authenticated', }); @@ -655,7 +704,7 @@ function oauthProjection( attemptId, connection: { connectionId, - slug: 'codex-subscription', + slug: 'openai-codex', providerType: 'openai-codex' as const, }, phase, diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index 78951d41c4..64ef266438 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -51,7 +51,10 @@ import { normalizeConnectionSlugForIpc, normalizeCreateConnectionInputForIpc, } from './connections-ipc-validation.js'; -import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; +import type { + DesktopConnectionIdentity, + DesktopConnectionSnapshot, +} from '../shared/desktop-connection-snapshot.js'; type HostConnectionsClient = Pick< DesktopRuntimeHostClient, @@ -89,9 +92,9 @@ export function registerRuntimeHostConnectionsIpc( chatModelChoices: buildChatModelChoices(connections), } satisfies DesktopConnectionSnapshot; }); - handleReconnectableRead(deps.ipcMain, 'connections:hasSecret', async (_event, slug: unknown) => { + handleReconnectableRead(deps.ipcMain, 'connections:hasSecret', async (_event, identity: unknown) => { const catalog = await snapshot(); - const connection = requireConnection(catalog, slug); + const connection = requireConnectionIdentity(catalog, identity); if (!providerAuthRequiresSecret(connection.providerType)) return true; return ( (await deps.client.queryCredential(connectionCredential(connection))) @@ -101,8 +104,8 @@ export function registerRuntimeHostConnectionsIpc( handleReconnectableRead( deps.ipcMain, 'connections:getRequestHeaders', - async (_event, slug: unknown) => { - const connection = requireConnection(await snapshot(), slug); + async (_event, identity: unknown) => { + const connection = requireConnectionIdentity(await snapshot(), identity); const result = await deps.client.getConnectionRequestHeaders(connection.connectionId); if (result.kind !== 'found') throw new Error('Connection no longer exists'); return { names: result.names } satisfies SavedRequestHeaders; @@ -110,8 +113,8 @@ export function registerRuntimeHostConnectionsIpc( ); deps.ipcMain.handle( 'connections:setRequestHeaders', - async (_event, slug: unknown, rawUpdates: unknown) => { - const connection = requireConnection(await snapshot(), slug); + async (_event, identity: unknown, rawUpdates: unknown) => { + const connection = requireConnectionIdentity(await snapshot(), identity); const result = await deps.client.replaceConnectionRequestHeaders( connection.connectionId, normalizeRequestHeaderUpdates(rawUpdates), @@ -121,17 +124,28 @@ export function registerRuntimeHostConnectionsIpc( return { names: result.names } satisfies SavedRequestHeaders; }, ); - deps.ipcMain.handle('connections:setDefault', async (_event, slug: unknown) => { + deps.ipcMain.handle('connections:setDefault', async (_event, identity: unknown) => { const catalog = await snapshot(); - const target = slug === null + const target = identity === null ? null - : defaultTargetForConnection(requireConnection(catalog, slug)); + : defaultTargetForConnection(requireConnectionIdentity(catalog, identity)); requireCommitted( await deps.client.setDefaultConnectionTarget(catalog.revision, target), 'set default Connection', ); deps.emitConnectionListChanged(); }); + deps.ipcMain.handle('connections:setDefaultBySlug', async (_event, slug: unknown) => { + const catalog = await snapshot(); + requireCommitted( + await deps.client.setDefaultConnectionTarget( + catalog.revision, + defaultTargetForConnection(requireConnection(catalog, slug)), + ), + 'set default Connection', + ); + deps.emitConnectionListChanged(); + }); deps.ipcMain.handle('connections:setDefaultModel', async (_event, input: unknown) => { const catalog = await snapshot(); const target = input === null ? null : explicitDefaultTarget(catalog, input); @@ -193,9 +207,9 @@ export function registerRuntimeHostConnectionsIpc( deps.emitConnectionListChanged(); return requireProjectedConnection(await snapshot(), input.slug); }); - deps.ipcMain.handle('connections:update', async (_event, rawSlug: unknown, rawPatch: unknown) => { + deps.ipcMain.handle('connections:update', async (_event, rawIdentity: unknown, rawPatch: unknown) => { const catalog = await snapshot(); - const current = requireConnection(catalog, rawSlug); + const current = requireConnectionIdentity(catalog, rawIdentity); const patch = normalizeUpdateInput(current, rawPatch); const updated = await deps.client.updateConnection( { connectionId: current.connectionId, revision: current.revision }, @@ -227,7 +241,7 @@ export function registerRuntimeHostConnectionsIpc( if (patch.apiKey !== undefined) await updateCredential(deps.client, current, patch.apiKey); if (patch.defaultModel !== undefined) { const latest = await snapshot(); - const entry = requireConnection(latest, current.slug); + const entry = requireConnectionIdentity(latest, connectionIdentity(current)); const target = patch.defaultModel ? { connectionId: entry.connectionId, modelId: patch.defaultModel } : latest.defaultTarget?.connectionId === entry.connectionId @@ -239,9 +253,10 @@ export function registerRuntimeHostConnectionsIpc( ); } deps.emitConnectionListChanged(); - return requireProjectedConnection(await snapshot(), current.slug); + return requireProjectedConnectionIdentity(await snapshot(), connectionIdentity(current)); }); - deps.ipcMain.handle('connections:delete', async (_event, slug: unknown) => { + deps.ipcMain.handle('connections:delete', async (_event, rawIdentity: unknown) => { + const identity = normalizeConnectionIdentity(rawIdentity); // OAuth/model-fetch can bump the connection revision under the UI. Retry // on connection_stale with a fresh snapshot so delete does not fail with a // opaque "service unavailable" after the user already confirmed. @@ -250,10 +265,11 @@ export function registerRuntimeHostConnectionsIpc( const catalog = await snapshot(); let current: ReturnType; try { - current = requireConnection(catalog, slug); + current = requireConnectionIdentity(catalog, identity); } catch (error) { - // Only treat a missing slug as success. Invalid input must still fail. - if (error instanceof Error && error.message.startsWith('No such Connection:')) { + // The exact entity is already gone. A new entity may reuse its slug; + // deleting that replacement would violate the detail route's binding. + if (error instanceof Error && error.message.startsWith('No such Connection identity:')) { deps.emitConnectionListChanged(); return; } @@ -275,14 +291,14 @@ export function registerRuntimeHostConnectionsIpc( throw new Error('Unable to delete Connection: connection_stale'); } }); - deps.ipcMain.handle('connections:fetchModels', async (_event, slug: unknown) => { - const current = requireConnection(await snapshot(), slug); + deps.ipcMain.handle('connections:fetchModels', async (_event, identity: unknown) => { + const current = requireConnectionIdentity(await snapshot(), identity); const result = await deps.client.fetchConnectionModels(current.connectionId); if (result.kind !== 'committed') { throw new Error(`Unable to fetch Connection models: ${result.kind}`); } deps.emitConnectionListChanged(); - const latest = requireConnection(await snapshot(), current.slug); + const latest = requireConnectionIdentity(await snapshot(), connectionIdentity(current)); return { models: [...latest.models], source: result.source, @@ -291,6 +307,19 @@ export function registerRuntimeHostConnectionsIpc( }); deps.ipcMain.handle( 'connections:test', + async (_event, identity: unknown, options?: { model?: unknown }) => { + const current = requireConnectionIdentity(await snapshot(), identity); + const model = options?.model; + if (model !== undefined && (typeof model !== 'string' || model.length === 0)) { + throw new Error('Invalid Connection test model'); + } + const result = await deps.client.testConnection(current.connectionId, model); + deps.emitConnectionListChanged(); + return projectHostConnectionTest(result); + }, + ); + deps.ipcMain.handle( + 'connections:testBySlug', async (_event, slug: unknown, options?: { model?: unknown }) => { const current = requireConnection(await snapshot(), slug); const model = options?.model; @@ -426,6 +455,43 @@ function requireConnection( return connection; } +function normalizeConnectionIdentity(value: unknown): DesktopConnectionIdentity { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Invalid Connection identity'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 2 || keys[0] !== 'connectionId' || keys[1] !== 'slug') { + throw new Error('Invalid Connection identity'); + } + if (typeof record.connectionId !== 'string' || record.connectionId.length === 0) { + throw new Error('Connection identity id is required'); + } + return { + connectionId: record.connectionId, + slug: normalizeConnectionSlugForIpc(record.slug, 'connection identity slug'), + }; +} + +function requireConnectionIdentity( + catalog: ConnectionCatalogSnapshot, + value: unknown, +): ConnectionCatalogEntry { + const identity = normalizeConnectionIdentity(value); + const connection = catalog.connections.find( + (candidate) => candidate.connectionId === identity.connectionId, + ); + if (!connection) throw new Error(`No such Connection identity: ${identity.connectionId}`); + if (connection.slug !== identity.slug) { + throw new Error('Connection identity no longer matches its slug'); + } + return connection; +} + +function connectionIdentity(connection: ConnectionCatalogEntry): DesktopConnectionIdentity { + return { connectionId: connection.connectionId, slug: connection.slug }; +} + function requireProjectedConnection( catalog: ConnectionCatalogSnapshot, slug: string, @@ -435,6 +501,18 @@ function requireProjectedConnection( return connection; } +function requireProjectedConnectionIdentity( + catalog: ConnectionCatalogSnapshot, + identity: DesktopConnectionIdentity, +): IdentifiedLlmConnection { + const connection = requireConnectionIdentity(catalog, identity); + const projected = projectHostConnections(catalog).find( + (candidate) => candidate.connectionId === connection.connectionId, + ); + if (!projected) throw new Error(`No such Connection identity: ${identity.connectionId}`); + return projected; +} + function defaultTargetForConnection(connection: ConnectionCatalogEntry) { const modelId = connection.enabledModelIds[0]; if (!modelId) throw new Error(`Connection has no enabled model: ${connection.slug}`); diff --git a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts index e151449c56..6fd139b3b3 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -94,9 +94,9 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (provider !== 'xai-oauth') { deps.ipcMain.handle(channel('is-experimental-enabled'), () => providerEnabled(provider)); } - deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawConnectionId: unknown) => { + deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawTarget: unknown) => { if (!providerEnabled(provider)) return providerDisabled(); - const selection = decodeOAuthConnectionSelection(rawConnectionId); + const selection = decodeOAuthLoginSelection(rawTarget); if (selection.kind === 'invalid') return invalidConnectionIdentity(); const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; if (connectionId) { @@ -129,7 +129,11 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void provider, connection: started.connection, }); - return { authRequestId: attemptId, stateHint: presented.stateHint }; + return { + authRequestId: attemptId, + stateHint: presented.stateHint, + connection: started.connection, + }; } catch (error) { expectation?.cancel(error); if (startedOnHost) { @@ -176,7 +180,7 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void terminal.connection.connectionId, ).catch(() => undefined); deps.emitConnectionListChanged(); - return { ok: true as const }; + return { ok: true as const, connection: terminal.connection }; } catch { activeAttempts.delete(attemptId); return actionFailure('Unable to complete OAuth authorization'); @@ -192,45 +196,44 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void return { ok: true as const }; }); handleReconnectableRead(deps.ipcMain, channel('get-account-state'), async (_event, rawConnectionId: unknown) => { - const selection = decodeOAuthConnectionSelection(rawConnectionId); - if (selection.kind === 'invalid') return invalidConnectionIdentity(); - const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity(); const candidates = oauthAccountCandidates( await deps.client.loadConnectionCatalog(), provider, connectionId, ); + if (candidates.length === 0) { + return actionFailure('OAuth account does not match this provider'); + } const authorizing = [...activeAttempts.values()].some( (attempt) => attempt.provider === provider && - (connectionId === undefined || attempt.connection.connectionId === connectionId), + attempt.connection.connectionId === connectionId, ); - if (candidates.length === 0) { - return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in'); - } if ((await configuredOAuthAccountConnections(deps.client, candidates)).length > 0) { return accountState(provider, 'authenticated'); } return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in'); }); deps.ipcMain.handle(channel('refresh-tokens'), async (_event, rawConnectionId: unknown) => { - const selection = decodeOAuthConnectionSelection(rawConnectionId); - if (selection.kind === 'invalid') return invalidConnectionIdentity('refresh_failed'); - const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity('refresh_failed'); + const candidates = oauthAccountCandidates( + await deps.client.loadConnectionCatalog(), + provider, + connectionId, + ); + if (candidates.length === 0) { + return actionFailure('OAuth account does not match this provider', 'refresh_failed'); + } const connections = await configuredOAuthAccountConnections( deps.client, - oauthAccountCandidates( - await deps.client.loadConnectionCatalog(), - provider, - connectionId, - ), + candidates, ); if (connections.length === 0) { return actionFailure('OAuth account is not connected', 'refresh_failed'); } - if (connectionId === undefined && connections.length > 1) { - return actionFailure('Select a specific OAuth account to refresh', 'refresh_failed'); - } const connection = connections[0]!; const refreshed = await deps.client.fetchConnectionModels(connection.connectionId); return refreshed.kind === 'committed' @@ -238,34 +241,25 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void : actionFailure('Unable to refresh OAuth account', 'refresh_failed'); }); deps.ipcMain.handle(channel('logout'), async (_event, rawConnectionId: unknown) => { - const selection = decodeOAuthConnectionSelection(rawConnectionId); - if (selection.kind === 'invalid') return invalidConnectionIdentity(); - const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity(); try { const candidates = oauthAccountCandidates( await deps.client.loadConnectionCatalog(), provider, connectionId, ); - if (connectionId !== undefined && candidates.length === 0) { + if (candidates.length === 0) { return actionFailure('OAuth account does not match this provider'); } - const connections = - connectionId !== undefined - ? candidates - : await configuredOAuthAccountConnections(deps.client, candidates); - if (connectionId === undefined && connections.length > 1) { - return actionFailure('Select a specific OAuth account to log out'); - } - const connection = connections[0]; + const connection = candidates[0]!; await cancelProviderAttempts( deps, activeAttempts, provider, - connection?.connectionId, + connection.connectionId, ); - if (connection) - await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId); + await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId); } catch { return actionFailure('Unable to remove OAuth account'); } @@ -370,30 +364,46 @@ function accountState( function oauthAccountCandidates( catalog: Awaited>, provider: OAuthLoginProvider, - connectionId: string | undefined, + connectionId: string, ): ConnectionCatalogEntry[] { - if (connectionId !== undefined) { - const connection = findRuntimeHostAccountConnectionById(catalog, connectionId); - return connection?.providerType === provider ? [connection] : []; - } - return catalog.connections.filter((connection) => connection.providerType === provider); + const connection = findRuntimeHostAccountConnectionById(catalog, connectionId); + return connection?.providerType === provider ? [connection] : []; } -type OAuthConnectionSelection = - | { readonly kind: 'aggregate' } +type OAuthLoginSelection = + | { readonly kind: 'create' } | { readonly kind: 'exact'; readonly connectionId: string } | { readonly kind: 'invalid' }; -function decodeOAuthConnectionSelection(value: unknown): OAuthConnectionSelection { - if (value === undefined) return { kind: 'aggregate' }; - if (typeof value !== 'string') return { kind: 'invalid' }; +function decodeOAuthLoginSelection(value: unknown): OAuthLoginSelection { + if (!value || typeof value !== 'object' || Array.isArray(value)) return { kind: 'invalid' }; + const candidate = value as { readonly kind?: unknown; readonly connectionId?: unknown }; + const keys = Object.keys(value).sort(); + if (candidate.kind === 'create') { + return keys.length === 1 && keys[0] === 'kind' ? { kind: 'create' } : { kind: 'invalid' }; + } + if (candidate.kind !== 'existing' || typeof candidate.connectionId !== 'string') { + return { kind: 'invalid' }; + } + if (keys.length !== 2 || keys[0] !== 'connectionId' || keys[1] !== 'kind') { + return { kind: 'invalid' }; + } try { - return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(value) }; + return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(candidate.connectionId) }; } catch { return { kind: 'invalid' }; } } +function decodeExactOAuthConnectionId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + try { + return decodeRuntimePolicyEntityId(value); + } catch { + return undefined; + } +} + function sameOAuthConnectionIdentity( left: OAuthConnectionIdentity, right: OAuthConnectionIdentity, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 10fc8cf377..b0f06bb514 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -350,6 +350,26 @@ export interface DesktopRuntimeHostRef { readonly hostId: string; } +/** Desktop-local OAuth intent. Runtime Host remains the identity allocator. */ +export type DesktopOAuthLoginTarget = + | { readonly kind: 'create' } + | { readonly kind: 'existing'; readonly connectionId: string }; + +/** Secret-free canonical identity returned by the Runtime Host OAuth flow. */ +export interface DesktopOAuthConnectionIdentity { + readonly connectionId: string; + readonly slug: string; + readonly providerType: 'openai-codex' | 'xai-oauth'; +} + +export type DesktopOAuthAuthorizationStartResult = + | (AuthorizationUrlPayload & { readonly connection: DesktopOAuthConnectionIdentity }) + | Exclude; + +export type DesktopOAuthAuthorizationResult = + | { readonly ok: true; readonly connection: DesktopOAuthConnectionIdentity } + | Exclude; + export type DesktopNewTaskHostRef = DesktopRuntimeHostRef; export interface DesktopNewTaskTarget extends DesktopRuntimeHostRef { @@ -1296,17 +1316,17 @@ export interface MakaBridge { }; connections: { getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef): Promise; - setDefault(slug: string | null, host?: DesktopRuntimeHostRef): Promise; + setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise; setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise; - create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise; - update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; - delete(slug: string, host?: DesktopRuntimeHostRef): Promise; - test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; - fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise; - hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise; - getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise; + create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise; + update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; + delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; + test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; + fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; + hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; + getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; setRequestHeaders( - slug: string, + connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, headers: readonly import('@maka/core/llm-connections').RequestHeaderUpdate[], host?: DesktopRuntimeHostRef, ): Promise; @@ -1438,45 +1458,51 @@ export interface MakaBridge { }; openAiCodex: { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise; - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ - provider: 'openai-codex'; - runtimeState: - | 'not_logged_in' - | 'authorizing' - | 'authenticated' - | 'refreshing' - | 'refresh_failed'; - accountId?: string; - email?: string; - plan?: string; - picture?: string; - errorMessage?: string; - }>; - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise< + | { + provider: 'openai-codex'; + runtimeState: + | 'not_logged_in' + | 'authorizing' + | 'authenticated' + | 'refreshing' + | 'refresh_failed'; + accountId?: string; + email?: string; + plan?: string; + picture?: string; + errorMessage?: string; + } + | Exclude + >; + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ - provider: 'xai-oauth'; - runtimeState: - | 'not_logged_in' - | 'authorizing' - | 'authenticated' - | 'refreshing' - | 'refresh_failed' - | 'storage_failed'; - errorMessage?: string; - }>; - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise< + | { + provider: 'xai-oauth'; + runtimeState: + | 'not_logged_in' + | 'authorizing' + | 'authenticated' + | 'refreshing' + | 'refresh_failed' + | 'storage_failed'; + errorMessage?: string; + } + | Exclude + >; + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; githubCopilotSubscription: { connectExistingLogin(host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 157a758e6e..d8c0221fb9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -62,6 +62,8 @@ import type { DesktopNewTaskHostRef, DesktopNewTaskTarget, DesktopRuntimeHostRef, + DesktopOAuthLoginTarget, + DesktopOAuthAuthorizationResult, DesktopProjectSnapshot, DesktopAppInfo, DesktopSessionTracePage, @@ -159,10 +161,7 @@ import type { } from '@maka/core/artifacts'; import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities'; import type { LocalMemoryState } from '@maka/core/local-memory'; -import type { - AuthorizationUrlPayload, - SubscriptionActionResult, -} from '@maka/core/oauth-subscription'; +import type { SubscriptionActionResult } from '@maka/core/oauth-subscription'; import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; import type { @@ -2525,39 +2524,48 @@ const makaBridge = { ? invokeRuntimeHostForSession('connections:getSnapshot', sessionId) : invokeSelectedRuntimeHost(host, 'connections:getSnapshot'); }, - setDefault(slug: string | null, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:setDefault', slug); + setDefault(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost( + host, + typeof connection === 'string' ? 'connections:setDefaultBySlug' : 'connections:setDefault', + connection, + ); }, setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:setDefaultModel', input); }, - create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise { + create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:create', input); }, - update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:update', slug, patch); + update(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:update', connection, patch); }, - delete(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:delete', slug); + delete(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:delete', connection); }, - test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:test', slug, opts); + test(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost( + host, + typeof connection === 'string' ? 'connections:testBySlug' : 'connections:test', + connection, + opts, + ); }, - fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug); + fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection); }, - hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug); + hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection); }, - getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'connections:getRequestHeaders', slug); + getRequestHeaders(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'connections:getRequestHeaders', connection); }, setRequestHeaders( - slug: string, + connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, headers: readonly import('@maka/core/llm-connections').RequestHeaderUpdate[], host?: DesktopRuntimeHostRef, ): Promise { - return invokeSelectedRuntimeHost(host, 'connections:setRequestHeaders', slug, headers); + return invokeSelectedRuntimeHost(host, 'connections:setRequestHeaders', connection, headers); }, subscribeEvents(handler: (event: ConnectionEvent) => void, host?: DesktopRuntimeHostRef): () => void { return host @@ -2763,19 +2771,19 @@ const makaBridge = { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled'); }, - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', connectionId); + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { + return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', target); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:open-auth-url', authRequestId); }, - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:complete-authorization', authRequestId); }, cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'openai-codex:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{ provider: 'openai-codex'; runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed'; accountId?: string; @@ -2786,27 +2794,27 @@ const makaBridge = { }> { return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:logout', connectionId); }, }, xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', connectionId); + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { + return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', target); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:open-auth-url', authRequestId); }, - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:complete-authorization', authRequestId); }, cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'xai-oauth:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{ provider: 'xai-oauth'; runtimeState: | 'not_logged_in' @@ -2819,10 +2827,10 @@ const makaBridge = { }> { return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:logout', connectionId); }, }, diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 6a2633bd39..c2ad504ded 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -176,7 +176,9 @@ const zhCopy = { panel: { tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' }, loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接', - retry: '点击重试。', empty: '还没有模型连接', + retry: '点击重试。', empty: '还没有模型连接', connectionRemoved: '原连接已被删除或移除,已返回模型连接列表。', + connectedLoading: '账号已连接,正在载入新的模型连接…', connectedLoadFailed: '账号已连接,但暂时无法载入新的模型连接。', + connectionIdentityChanged: '新连接的身份与登录结果不一致,请返回连接列表后重试。', emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。', categoriesAria: '模型供应商分类', searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索', createSubtitle: '完成必要配置后,连接会出现在模型页上方。', connection: '模型连接', @@ -214,18 +216,19 @@ const zhCopy = { logoutTitle: (name: string) => `退出 ${name} 登录?`, }, oauthSection: { - signedIn: '已登录', codexDescription: 'ChatGPT Plus / Pro 订阅账号登录。', xaiDescription: 'SuperGrok / X Premium 账号登录。', + signedIn: '已登录', codexDescription: '使用 ChatGPT Plus / Pro 账号添加连接。', xaiDescription: '使用 SuperGrok / X Premium 账号添加连接。', + configuredConnections: (count: number) => `已有 ${count} 个连接 · 添加另一个账号`, copilotDescription: '导入兼容 GitHub 凭据连接 Copilot 订阅。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', aria: 'OAuth 登录', staleState: 'OAuth 登录状态暂时没刷新成功,已保留上一次状态。', codexDetail: '点击下方按钮打开设备授权页,并在页面中输入这里显示的登录码。', xaiDetail: '点击下方按钮打开浏览器登录,授权完成后会自动回写。', deviceCode: '登录码:', - openingBrowser: '打开浏览器…', logout: '退出登录', loggingOut: '退出中…', + openingBrowser: '打开浏览器…', waitingAuthorization: '等待浏览器授权…', logout: '退出登录', loggingOut: '退出中…', copilotSubtitle: '导入兼容的 GitHub 登录;token 不会暴露给渲染进程。', copilotImported: '已导入 GitHub Copilot 订阅账号。', copilotSetup: '请配置具有 Copilot Requests 权限的 fine-grained PAT;普通 gh auth login 可能不包含该权限。', importing: '导入中…', reimport: '重新导入', importCredential: '导入兼容凭据', verifying: '验证中…', reverify: '重新验证', removing: '移除中…', removeLocal: '移除本地登录', loadingAccount: '正在加载账号状态…', authorizing: '请在弹出的浏览器窗口完成登录。', refreshing: '正在刷新访问令牌…', refreshTokenFailed: '令牌刷新失败,请重新登录。', - cardAria: (name: string, status: string | undefined, description: string) => `打开 OAuth 登录:${name}${status ? `,状态:${status}` : ''},${description.replace(/[。.!!??]+$/u, '')}`, - connectTitle: (name: string) => `连接 ${name}`, login: (name: string) => `登录 ${name}`, signedOut: (name: string) => `${name} 尚未登录。`, + cardAria: (intent: 'add' | 'import' | 'manage', name: string, status: string | undefined, description: string) => `${intent === 'add' ? '添加' : intent === 'import' ? '导入' : '管理'}账号连接:${name}${status ? `,状态:${status}` : ''},${description.replace(/[。.!!??]+$/u, '')}`, + connectTitle: (name: string) => `连接 ${name}`, addAccountTitle: (name: string) => `添加 ${name} 账号`, login: (name: string) => `登录 ${name}`, loginAndAdd: '登录并添加', signedOut: (name: string) => `${name} 尚未登录。`, storageFailed: (name: string) => `${name} 本地凭据读取失败,请重新登录。`, providerUnavailable: (name: string) => `${name} 已登录,但当前 provider 状态不可用。`, }, } as const; @@ -329,7 +332,9 @@ const enCopy: ProviderSettingsCopy = { panel: { tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' }, loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections', - retry: 'Select to retry.', empty: 'No model connections yet', + retry: 'Select to retry.', empty: 'No model connections yet', connectionRemoved: 'The original connection was deleted or removed. Returned to the connection list.', + connectedLoading: 'Account connected. Loading the new model connection…', connectedLoadFailed: 'Account connected, but the new model connection could not be loaded yet.', + connectionIdentityChanged: 'The new connection identity did not match the sign-in result. Return to the connection list and try again.', emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.', categoriesAria: 'Model provider categories', searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search', createSubtitle: 'After required setup, the connection appears above on the Models page.', connection: 'Model connection', @@ -367,18 +372,19 @@ const enCopy: ProviderSettingsCopy = { logoutTitle: (name: string) => `Sign out of ${name}?`, }, oauthSection: { - signedIn: 'Signed in', codexDescription: 'Sign in with a ChatGPT Plus / Pro subscription.', xaiDescription: 'Sign in with SuperGrok or X Premium.', + signedIn: 'Signed in', codexDescription: 'Use a ChatGPT Plus / Pro account to add a connection.', xaiDescription: 'Use a SuperGrok or X Premium account to add a connection.', + configuredConnections: (count: number) => `${count} ${count === 1 ? 'connection' : 'connections'} configured · Add another account`, copilotDescription: 'Import compatible GitHub credentials to connect a Copilot subscription.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', aria: 'OAuth sign-in', staleState: 'OAuth sign-in status could not be refreshed. The last known state is preserved. ', codexDetail: 'Open the device page below and enter the sign-in code shown here.', xaiDetail: 'Open the browser below to sign in. Authorization is written back automatically.', deviceCode: 'Sign-in code:', - openingBrowser: 'Opening browser…', logout: 'Sign out', loggingOut: 'Signing out…', + openingBrowser: 'Opening browser…', waitingAuthorization: 'Waiting for browser authorization…', logout: 'Sign out', loggingOut: 'Signing out…', copilotSubtitle: 'Import a compatible GitHub sign-in. The token is never exposed to the renderer.', copilotImported: 'GitHub Copilot subscription account imported.', copilotSetup: 'Configure a fine-grained PAT with Copilot Requests permission. A normal gh auth login may not include it.', importing: 'Importing…', reimport: 'Reimport', importCredential: 'Import compatible credentials', verifying: 'Verifying…', reverify: 'Verify again', removing: 'Removing…', removeLocal: 'Remove local sign-in', loadingAccount: 'Loading account status…', authorizing: 'Complete sign-in in the browser window.', refreshing: 'Refreshing access token…', refreshTokenFailed: 'Token refresh failed. Sign in again.', - cardAria: (name: string, status: string | undefined, description: string) => `Open OAuth sign-in: ${name}${status ? `; status: ${status}` : ''}; ${description.replace(/[。.!!??]+$/u, '')}`, - connectTitle: (name: string) => `Connect ${name}`, login: (name: string) => `Sign in to ${name}`, signedOut: (name: string) => `${name} is signed out.`, + cardAria: (intent: 'add' | 'import' | 'manage', name: string, status: string | undefined, description: string) => `${intent === 'add' ? 'Add' : intent === 'import' ? 'Import' : 'Manage'} account connection: ${name}${status ? `; status: ${status}` : ''}; ${description.replace(/[。.!!??]+$/u, '')}`, + connectTitle: (name: string) => `Connect ${name}`, addAccountTitle: (name: string) => `Add ${name} account`, login: (name: string) => `Sign in to ${name}`, loginAndAdd: 'Sign in and add', signedOut: (name: string) => `${name} is signed out.`, storageFailed: (name: string) => `Could not read local credentials for ${name}. Sign in again.`, providerUnavailable: (name: string) => `${name} is signed in, but the provider status is currently unavailable.`, }, }; diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts index 0b14d65eab..8b3b8ec734 100644 --- a/apps/desktop/src/renderer/settings/provider-add-submission.ts +++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts @@ -25,7 +25,7 @@ import { validateSlug, type ProviderType, } from '@maka/core/llm-connections'; -import type { CreateConnectionInput, LlmConnection } from '@maka/core/llm-connections'; +import type { CreateConnectionInput, IdentifiedLlmConnection } from '@maka/core/llm-connections'; /** * The two decisions 添加连接 makes that are not layout: which fields a provider @@ -93,7 +93,7 @@ export function validateAddProviderDraft(draft: AddProviderDraft): AddProviderIs } export interface CreatedProvider { - readonly connection: LlmConnection; + readonly connection: IdentifiedLlmConnection; /** * Present when the catalog fetch that follows creation threw. The connection * exists either way — discovery is a convenience on top of a successful @@ -104,8 +104,8 @@ export interface CreatedProvider { } export interface ProviderCreationBridge { - create(input: CreateConnectionInput): Promise; - fetchModels(slug: string): Promise; + create(input: CreateConnectionInput): Promise; + fetchModels(connection: { readonly connectionId: string; readonly slug: string }): Promise; } /** @@ -123,7 +123,7 @@ export async function createProviderWithDiscovery( const connection = await bridge.create(input); if (!providerSupportsModelDiscovery(input.providerType)) return { connection }; try { - await bridge.fetchModels(connection.slug); + await bridge.fetchModels({ connectionId: connection.connectionId, slug: connection.slug }); } catch (modelDiscoveryError) { return { connection, modelDiscoveryError }; } diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx index 364407fcfa..b1d3c8ecad 100644 --- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx +++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx @@ -34,7 +34,7 @@ import { type ProviderCatalogGroup, type ProviderType, } from '@maka/core/provider-registry'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; import { Button, TextInput, useUiLocale } from '@maka/ui'; import { AddProviderForm } from './provider-add-form'; import { ProviderLogo, providerDisplay } from './provider-display'; @@ -66,6 +66,12 @@ export interface CatalogFilter { export const CATALOG_INITIAL_FILTER: CatalogFilter = { query: '', category: 'all' }; +export interface CreatedOAuthConnectionIdentity { + connectionId: string; + slug: string; + providerType: 'openai-codex' | 'xai-oauth'; +} + /** * One provider being set up. `credentials` is a form, `account` is a browser * login — two bodies of one level, not two levels: they are reached the same @@ -87,6 +93,7 @@ export type SetupTarget = */ export function ProviderCatalogPage(props: { filter: CatalogFilter; + connections: readonly LlmConnection[]; onFilterChange(filter: CatalogFilter): void; onPick(target: SetupTarget): void; }) { @@ -96,7 +103,10 @@ export function ProviderCatalogPage(props: { const catalogCopy = providerCopy.catalog; const { query, category } = props.filter; const showsOAuth = category === 'all' || category === 'recommended' || category === 'accounts'; - const oauth = useOAuthCards({ query: showsOAuth ? query : undefined }); + const oauth = useOAuthCards({ + query: showsOAuth ? query : undefined, + connections: props.connections, + }); // Category is a Selector, not a second TabList: the page header already owns // one level of navigation, and six tabs beside a search field made the @@ -161,7 +171,12 @@ export function ProviderCatalogPage(props: { data-status="ready" data-logged-in={card.isLoggedIn ? 'true' : undefined} startContent={} - label={/* a11y-allow: this label names the ROW, not the span. Astryx's Item puts consumer props on its outer wrapper and renders a separate invisible