diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4afcca17f4..2d396dab4b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -71,6 +71,7 @@ "@ant-design/icons-svg": "4.5.0", "@astryxdesign/core": "0.5.2", "@astryxdesign/theme-neutral": "0.5.2", + "@babel/parser": "7.29.7", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 387101dc9c..e54ddacfd2 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -175,7 +175,6 @@ "src/renderer/settings/runtime-host-onboarding-dialog.tsx", "src/renderer/settings/runtime-host-profiles-section.tsx", "src/renderer/settings/runtime-host-project-directory-editor.tsx", - "src/renderer/settings/runtime-host-settings-bridge.ts", "src/renderer/settings/runtime-host-settings-target.tsx", "src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx", "src/renderer/settings/settings-error-copy.ts", @@ -3409,21 +3408,17 @@ } }, "src/renderer/settings/provider-connection-detail.tsx": { - "bridgePaths": { - "window.maka.githubCopilotSubscription.connectExistingLogin": 1 - }, + "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useActionGuard": 1, "useConnectionDetail": 1, "useEffect": 2, - "useMountedRef": 3, + "useMountedRef": 2, "useOAuthLoginFlow": 1, - "useRuntimeHostSettingsErrorReporter": 3, - "useRuntimeHostSettingsTarget": 1, + "useRuntimeHostSettingsErrorReporter": 2, "useState": 8, "useToast": 2, - "useUiLocale": 4 + "useUiLocale": 3 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -3440,7 +3435,6 @@ "./runtime-host-settings-target.js": 1, "./settings-expandable-row": 1, "./settings-section": 1, - "./use-action-guard": 1, "./use-connection-detail": 1, "./use-oauth-login-flow": 1, "@astryxdesign/core": 1, @@ -3520,23 +3514,13 @@ } }, "src/renderer/settings/provider-oauth-section.tsx": { - "bridgePaths": { - "window.maka.githubCopilotSubscription.connectExistingLogin": 1, - "window.maka.githubCopilotSubscription.getAccountState": 2, - "window.maka.githubCopilotSubscription.logout": 1, - "window.maka.githubCopilotSubscription.refreshTokens": 1, - "window.maka.openAiCodex": 1, - "window.maka.xaiOAuth": 1 - }, + "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useEffect": 1, "useMountedRef": 1, "useOAuthLoginFlow": 2, - "useRef": 1, "useRuntimeHostSettingsGenerationKey": 1, - "useRuntimeHostSettingsTarget": 3, - "useState": 2, + "useState": 1, "useUiLocale": 3 }, "lifecycleMethods": {}, @@ -3545,7 +3529,6 @@ "dependencyPaths": { "../../preload/bridge-contract.js": 1, "../features/connection-settings": 1, - "./runtime-host-settings-bridge.js": 1, "./runtime-host-settings-target.js": 1, "./use-oauth-login-flow": 1, "@astryxdesign/core": 1, @@ -3770,18 +3753,6 @@ "@maka/ui": 1 } }, - "src/renderer/settings/runtime-host-settings-bridge.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../../preload/bridge-contract.js": 1, - "./use-oauth-login-flow.js": 1 - } - }, "src/renderer/settings/runtime-host-settings-target.tsx": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4235,10 +4206,7 @@ } }, "src/renderer/settings/use-connection-detail.ts": { - "bridgePaths": { - "window.maka.openAiCodex": 1, - "window.maka.xaiOAuth": 1 - }, + "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { "useEffect": 6, @@ -4246,7 +4214,6 @@ "useMountedRef": 1, "useRef": 4, "useRuntimeHostSettingsErrorReporter": 1, - "useRuntimeHostSettingsTarget": 1, "useState": 14, "useToast": 1, "useUiLocale": 1 @@ -4255,13 +4222,11 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../../preload/bridge-contract.js": 1, "../features/connection-settings": 1, "./connection-name-draft.js": 1, "./provider-connection-status": 1, "./relay-profile-draft": 1, "./relay-thinking-bulk": 1, - "./runtime-host-settings-bridge.js": 1, "./runtime-host-settings-target.js": 1, "./use-action-guard": 1, "./use-oauth-login-flow": 1, diff --git a/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts b/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts new file mode 100644 index 0000000000..a034d7cd1d --- /dev/null +++ b/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { importGitHubCopilotLocalCredential } from '../oauth/github-copilot-local-credential.js'; + +describe('importGitHubCopilotLocalCredential', () => { + test('prefers an explicit Copilot Requests credential over the generic GitHub CLI login', async () => { + const previous = process.env.COPILOT_GITHUB_TOKEN; + process.env.COPILOT_GITHUB_TOKEN = 'github_pat_copilot_requests'; + try { + const imported = await importGitHubCopilotLocalCredential(); + + assert.deepEqual(JSON.parse(imported.secret ?? ''), { + access_token: 'github_pat_copilot_requests', + refresh_token: 'github_pat_copilot_requests', + expires_at: Number.MAX_SAFE_INTEGER, + token_type: 'Bearer', + base_url: 'https://api.githubcopilot.com', + }); + assert.deepEqual(imported.result, { ok: true }); + } finally { + if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; + else process.env.COPILOT_GITHUB_TOKEN = previous; + } + }); + + test('returns credential material without asking GitHub about entitlement', async () => { + let resolutions = 0; + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => { + resolutions += 1; + return 'gho_existing_login\n'; + }, + }); + + assert.equal(resolutions, 1); + assert.deepEqual(imported.result, { ok: true }); + assert.deepEqual(JSON.parse(imported.secret ?? ''), { + access_token: 'gho_existing_login', + refresh_token: 'gho_existing_login', + expires_at: Number.MAX_SAFE_INTEGER, + token_type: 'Bearer', + base_url: 'https://api.githubcopilot.com', + }); + }); + + test('rejects classic PATs locally', async () => { + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => 'ghp_classic_pat', + }); + + assert.equal(imported.result.ok, false); + if (!imported.result.ok) { + assert.equal(imported.result.reason, 'token_exchange_failed'); + assert.match(imported.result.message, /不支持 classic PAT/); + assert.equal(imported.result.message.includes('ghp_classic_pat'), false); + } + assert.equal(imported.secret, undefined); + }); + + test('rejects an unsupported local credential shape', async () => { + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => 'unsupported-token', + }); + + assert.equal(imported.result.ok, false); + if (!imported.result.ok) assert.match(imported.result.message, /凭据类型不受支持/); + assert.equal(imported.secret, undefined); + }); + + test('reports when no local GitHub credential can be discovered', async () => { + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => { + throw new Error('gh is not installed'); + }, + }); + + assert.equal(imported.result.ok, false); + if (!imported.result.ok) assert.match(imported.result.message, /未找到可导入/); + assert.equal(imported.secret, undefined); + }); +}); diff --git a/apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts b/apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts deleted file mode 100644 index 1d2742316c..0000000000 --- a/apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import { GitHubCopilotSubscriptionService } from '../oauth/github-copilot-subscription-service.js'; - -describe('GitHubCopilotSubscriptionService', () => { - test('prefers an explicit Copilot Requests credential over the generic GitHub CLI login', async () => { - const previous = process.env.COPILOT_GITHUB_TOKEN; - process.env.COPILOT_GITHUB_TOKEN = 'github_pat_copilot_requests'; - let stored: string | null = null; - let authorization = ''; - try { - const service = new GitHubCopilotSubscriptionService({ - credentialStore: { - getSecret: async () => stored, - setSecret: async (_slug, _kind, value) => { stored = value; }, - deleteSecret: async () => { stored = null; }, - }, - fetchFn: async (url, init) => { - assert.equal(String(url), 'https://api.githubcopilot.com/models'); - authorization = new Headers(init?.headers).get('authorization') ?? ''; - return copilotModelsResponse(); - }, - }); - - const result = await service.connectExistingLogin(); - assert.equal(result.ok, true); - if (result.ok) assert.deepEqual(result.models.map(({ id }) => id), ['gpt-5.4']); - assert.equal(authorization, 'Bearer github_pat_copilot_requests'); - assert.ok(stored); - } finally { - if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; - else process.env.COPILOT_GITHUB_TOKEN = previous; - } - }); - - test('imports a supported existing gh login into the shared OAuth credential lifecycle', async () => { - let stored: string | null = null; - let requestAuthorization = ''; - const service = new GitHubCopilotSubscriptionService({ - credentialStore: { - getSecret: async () => stored, - setSecret: async (_slug, _kind, value) => { stored = value; }, - deleteSecret: async () => { stored = null; }, - }, - resolveGitHubToken: async () => 'gho_existing_login\n', - fetchFn: async (url, init) => { - assert.equal(String(url), 'https://api.githubcopilot.com/models'); - requestAuthorization = new Headers(init?.headers).get('authorization') ?? ''; - return copilotModelsResponse(); - }, - }); - - const result = await service.connectExistingLogin(); - assert.equal(result.ok, true); - if (result.ok) assert.deepEqual(result.models.map(({ id }) => id), ['gpt-5.4']); - assert.equal(requestAuthorization, 'Bearer gho_existing_login'); - assert.deepEqual(JSON.parse(stored ?? ''), { - access_token: 'gho_existing_login', - refresh_token: 'gho_existing_login', - expires_at: Number.MAX_SAFE_INTEGER, - token_type: 'Bearer', - base_url: 'https://api.githubcopilot.com', - }); - assert.deepEqual(await service.getAccountState(), { - provider: 'github-copilot', - runtimeState: 'authenticated', - }); - }); - - test('rejects classic PATs before any Copilot request', async () => { - let requested = false; - const service = new GitHubCopilotSubscriptionService({ - credentialStore: memoryCredentialStore(), - resolveGitHubToken: async () => 'ghp_classic_pat', - fetchFn: async () => { - requested = true; - return Response.json({}); - }, - }); - - const result = await service.connectExistingLogin(); - assert.equal(result.ok, false); - if (!result.ok) { - assert.equal(result.reason, 'token_exchange_failed'); - assert.match(result.message, /不支持 classic PAT/); - assert.equal(result.message.includes('ghp_classic_pat'), false); - } - assert.equal(requested, false); - }); - - test('explains subscription or Copilot Requests policy rejection without exposing provider details', async () => { - const service = new GitHubCopilotSubscriptionService({ - credentialStore: { - getSecret: async () => null, - setSecret: async () => {}, - deleteSecret: async () => {}, - }, - resolveGitHubToken: async () => 'gho_without_copilot_permission', - fetchFn: async () => new Response(null, { status: 403 }), - }); - - const result = await service.connectExistingLogin(); - assert.equal(result.ok, false); - assert.match(result.message, /Copilot Requests/); - assert.doesNotMatch(result.message, /404|gho_without/); - }); - - test('refreshes and logs out through the same store without exposing either token in state', async () => { - let stored: string | null = JSON.stringify({ - access_token: 'github_pat_supported', - refresh_token: 'github_pat_supported', - expires_at: Number.MAX_SAFE_INTEGER, - base_url: 'https://api.githubcopilot.com', - }); - let writes = 0; - const service = new GitHubCopilotSubscriptionService({ - credentialStore: { - getSecret: async () => stored, - setSecret: async (_slug, _kind, value) => { - writes += 1; - stored = value; - }, - deleteSecret: async () => { stored = null; }, - }, - now: () => 10_000, - fetchFn: async () => copilotModelsResponse(), - }); - - const result = await service.refreshTokens(); - assert.equal(result.ok, true); - if (result.ok) assert.deepEqual(result.models.map(({ id }) => id), ['gpt-5.4']); - assert.equal(writes, 0, 'validating an unchanged durable token must not rewrite it after network I/O'); - const state = await service.getAccountState(); - assert.deepEqual(state, { provider: 'github-copilot', runtimeState: 'authenticated' }); - assert.equal('access_token' in state, false); - assert.equal('refresh_token' in state, false); - assert.deepEqual(await service.logout(), { ok: true }); - assert.deepEqual(await service.getAccountState(), { - provider: 'github-copilot', - runtimeState: 'not_logged_in', - }); - }); - -}); - -function copilotModelsResponse(): Response { - return Response.json({ - data: [{ - id: 'gpt-5.4', - model_picker_enabled: true, - supported_endpoints: ['/responses'], - policy: { state: 'enabled' }, - capabilities: { - limits: { max_prompt_tokens: 128_000, max_output_tokens: 16_000 }, - supports: { tool_calls: true }, - }, - }], - }); -} - -function memoryCredentialStore() { - return { - getSecret: async () => null, - setSecret: async () => undefined, - deleteSecret: async () => undefined, - }; -} diff --git a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts index 2d1eefaaf1..ec74c8e205 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts @@ -20,114 +20,51 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; -import type { - CredentialStatus, -} from '@maka/core/runtime-policy'; import type { RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, -} from '@maka/runtime-host/client';import { +} from '@maka/runtime-host/client'; +import { registerRuntimeHostGitHubCopilotIpc, type RuntimeHostGitHubCopilotIpcDeps, } from '../runtime-host-github-copilot-ipc-main.js'; const CONNECTION_ID = '00000000-0000-4000-8000-000000000001'; -const CREDENTIAL_ID = '00000000-0000-4000-8000-000000000002'; -test('imports a local GitHub credential through the shared Host account path', async () => { +// Public seam: the Desktop IPC discovers local material, while one Host command +// owns provider discovery, generation checks, and the credential/catalog commit. +test('delegates local credential adoption to one Host onboarding command', async () => { const handlers = new Map< string, Parameters[1] >(); - const importedSecret = 'serialized-account-credential'; + const importedSecret = '{"access_token":"gho_local","token_type":"Bearer"}'; const discoveredModelId = 'account-discovered-model'; - let storedSecret: string | undefined; - let credential: CredentialStatus | null = null; let changed = 0; + let adoptionCalls = 0; let catalog: ConnectionCatalogSnapshot = { revision: 1, defaultTarget: null, - connections: [], + connections: [connectionFixture()], }; + const client: RuntimeHostGitHubCopilotIpcDeps['client'] = { loadConnectionCatalog: async () => catalog, - createConnection: async (expectedCatalogRevision, draft) => { - assert.equal(expectedCatalogRevision, catalog.revision); - assert.deepEqual(draft.enabledModelIds, [discoveredModelId]); - const connection: ConnectionCatalogEntry = { - ...draft, - connectionId: CONNECTION_ID, - revision: 1, - catalogEntries: [], - models: [], - }; - catalog = { - revision: catalog.revision + 1, - defaultTarget: null, - connections: [connection], - }; - return { - kind: 'committed', - catalogRevision: catalog.revision, - connection: { connectionId: CONNECTION_ID, revision: 1 }, - }; - }, - updateConnection: async (expected, changes) => { - const current = catalog.connections[0]; - assert.ok(current); - assert.deepEqual(expected, { - connectionId: current.connectionId, - revision: current.revision, + saveConnectionOnboarding: async (input) => { + adoptionCalls += 1; + assert.deepEqual(input, { + target: { kind: 'existing', connectionId: CONNECTION_ID }, + apiKey: importedSecret, + baseUrl: null, + // Empty means the Host adopts the non-empty model set it verifies. + enabledModelIds: [], }); - const { relayModelProfiles, requestBodyOverlay, ...restChanges } = changes; - const updated: ConnectionCatalogEntry = { - ...current, - ...restChanges, - ...(relayModelProfiles === null ? {} : { relayModelProfiles }), - ...(requestBodyOverlay === null ? {} : { requestBodyOverlay }), - revision: current.revision + 1, - }; - catalog = { - ...catalog, - revision: catalog.revision + 1, - connections: [updated], - }; - return { - kind: 'committed', - catalogRevision: catalog.revision, - connection: { connectionId: updated.connectionId, revision: updated.revision }, - }; - }, - queryCredential: async () => credential, - setCredential: async ({ locator, secret }) => { - storedSecret = secret; - credential = { - locator, - configured: true, - credentialId: CREDENTIAL_ID, - revision: 1, - updatedAt: 1, - }; - return { kind: 'committed', vaultRevision: 1, status: credential }; - }, - deleteCredential: async ({ expected }) => { - storedSecret = undefined; - credential = { - locator: expected.locator, - configured: false, - credentialId: null, - revision: null, - updatedAt: null, - }; - return { kind: 'committed', vaultRevision: 2, status: credential }; - }, - fetchConnectionModels: async (connectionId) => { const current = catalog.connections[0]; assert.ok(current); - assert.equal(connectionId, current.connectionId); const updated: ConnectionCatalogEntry = { ...current, revision: current.revision + 1, + enabledModelIds: [discoveredModelId], models: [{ id: discoveredModelId }], modelSource: 'fetched', }; @@ -137,12 +74,13 @@ test('imports a local GitHub credential through the shared Host account path', a connections: [updated], }; return { - kind: 'committed', - catalogRevision: catalog.revision, - connection: { connectionId: updated.connectionId, revision: updated.revision }, - modelCount: 1, - source: 'fetched', - fetchedAt: 1, + kind: 'saved', + connection: { + connectionId: updated.connectionId, + revision: updated.revision, + slug: updated.slug, + providerType: updated.providerType, + }, }; }, setDefaultConnectionTarget: async (expectedCatalogRevision, target) => { @@ -163,7 +101,7 @@ test('imports a local GitHub credential through the shared Host account path', a changed += 1; }, importExistingLogin: async () => ({ - result: { ok: true, models: [{ id: discoveredModelId }] }, + result: { ok: true }, secret: importedSecret, }), }); @@ -171,23 +109,29 @@ test('imports a local GitHub credential through the shared Host account path', a const connected = await invoke(handlers, 'github-copilot:connect-existing-login'); assert.deepEqual(connected, { ok: true }); assert.equal(JSON.stringify(connected).includes(importedSecret), false); - assert.equal(storedSecret, importedSecret); + assert.equal(adoptionCalls, 1); assert.deepEqual(catalog.defaultTarget, { connectionId: CONNECTION_ID, modelId: discoveredModelId, }); - assert.deepEqual(await invoke(handlers, 'github-copilot:get-account-state'), { - provider: 'github-copilot', - runtimeState: 'authenticated', - }); - - assert.deepEqual(await invoke(handlers, 'github-copilot:refresh-tokens'), { ok: true }); - assert.deepEqual(await invoke(handlers, 'github-copilot:logout'), { ok: true }); - assert.equal(storedSecret, undefined); - assert.equal(catalog.connections[0]?.enabled, false); - assert.equal(changed, 3); + assert.equal(changed, 1); + assert.deepEqual([...handlers.keys()], ['github-copilot:connect-existing-login']); }); +function connectionFixture(): ConnectionCatalogEntry { + return { + connectionId: CONNECTION_ID, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot', + enabled: true, + enabledModelIds: ['copilot-fallback'], + catalogEntries: [], + models: [], + }; +} + async function invoke( handlers: ReadonlyMap< string, 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 8320a63aac..1f1ef10d2c 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 @@ -21,10 +21,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import type { RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, } from '@maka/runtime-host/client'; +import type { OAuthLoginProvider } from '@maka/runtime-host/protocol'; import { RUNTIME_HOST_OAUTH_IPC_CHANNELS, registerRuntimeHostOAuthIpc, @@ -156,7 +158,6 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { emitConnectionListChanged: () => { changed += 1; }, - isProviderEnabled: () => true, }); assert.deepEqual([...handlers.keys()].sort(), [...RUNTIME_HOST_OAUTH_IPC_CHANNELS].sort()); @@ -244,7 +245,6 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide emitConnectionListChanged: () => { mutations += 1; }, - isProviderEnabled: () => true, }); assert.deepEqual( @@ -292,7 +292,6 @@ test('malformed OAuth Connection IDs fail closed before catalog or credential ac emitConnectionListChanged: () => { emissions += 1; }, - isProviderEnabled: () => true, }); for (const malformed of [ @@ -425,7 +424,6 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a clientOverrides, presentation, emitConnectionListChanged: () => undefined, - isProviderEnabled: () => true, }); const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }); @@ -517,7 +515,6 @@ test('completion rejects a terminal projection that changes Connection identity' emitConnectionListChanged: () => { emitted += 1; }, - isProviderEnabled: () => true, }); await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }); @@ -613,7 +610,6 @@ test('keeps a committed OAuth login successful when model discovery fails withou emitConnectionListChanged: () => { changed += 1; }, - isProviderEnabled: () => true, }); assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), { @@ -650,6 +646,57 @@ test('keeps a committed OAuth login successful when model discovery fails withou assertNoUnexpectedClientCalls(); }); +test('lets the selected Host refuse enrollment instead of prechecking Desktop state', async () => { + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides: { + startOAuthLogin: async () => { + throw new RuntimeHostOperationError( + 'oauth.login.start', + 'operation_unavailable', + 'OAuth enrollment is disabled for this provider', + ); + }, + }, + presentation: new RuntimeHostOAuthPresentation(async () => { + throw new Error('A refused enrollment must never open a browser'); + }), + emitConnectionListChanged: () => undefined, + }); + + assert.deepEqual( + await invoke(handlers, 'github-copilot:get-auth-url', { kind: 'create' }), + { + ok: false, + reason: 'experimental_disabled', + message: 'OAuth enrollment is disabled for this provider', + }, + ); + assertNoUnexpectedClientCalls(); +}); + +test('projects the selected Host answer for whether a provider may enrol', async () => { + // The renderer must be able to disable a sign-in the install refuses before + // the user clicks it, and the authoritative answer belongs to the selected + // Host — a remote Host that enabled Copilot is not bound by this Desktop + // process's environment. + for (const [enabled, expected] of [[true, true], [false, false]] as const) { + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides: { + queryOAuthEnrollment: async (provider: OAuthLoginProvider) => ({ provider, enabled }), + }, + presentation: new RuntimeHostOAuthPresentation(async () => { + throw new Error('An enrollment probe must never open a browser'); + }), + emitConnectionListChanged: () => undefined, + }); + + assert.deepEqual(await invoke(handlers, 'github-copilot:get-enrollment-state'), { + enabled: expected, + }); + assertNoUnexpectedClientCalls(); + } +}); + function createFailClosedOAuthClient(overrides: Partial): { readonly client: OAuthClient; assertNoUnexpectedClientCalls(): void; @@ -663,7 +710,6 @@ function createFailClosedOAuthClient(overrides: Partial): { }; const client = { loadConnectionCatalog: unexpected('loadConnectionCatalog'), - createConnection: unexpected('createConnection'), updateConnection: unexpected('updateConnection'), deleteCredential: unexpected('deleteCredential'), fetchConnectionModels: unexpected('fetchConnectionModels'), @@ -672,6 +718,7 @@ function createFailClosedOAuthClient(overrides: Partial): { startOAuthLogin: unexpected('startOAuthLogin'), queryOAuthLogin: unexpected('queryOAuthLogin'), cancelOAuthLogin: unexpected('cancelOAuthLogin'), + queryOAuthEnrollment: unexpected('queryOAuthEnrollment'), ...overrides, } satisfies OAuthClient; return { @@ -684,7 +731,6 @@ function registerOAuthTestHandlers(input: { readonly clientOverrides: Partial; readonly presentation: RuntimeHostOAuthPresentation; readonly emitConnectionListChanged: () => void; - readonly isProviderEnabled: NonNullable; }): { readonly handlers: ReadonlyMap; assertNoUnexpectedClientCalls(): void; @@ -698,7 +744,6 @@ function registerOAuthTestHandlers(input: { client, presentation: input.presentation, emitConnectionListChanged: input.emitConnectionListChanged, - isProviderEnabled: input.isProviderEnabled, }); return { handlers, assertNoUnexpectedClientCalls }; } diff --git a/apps/desktop/src/main/oauth/github-copilot-local-credential.ts b/apps/desktop/src/main/oauth/github-copilot-local-credential.ts new file mode 100644 index 0000000000..2a39fed564 --- /dev/null +++ b/apps/desktop/src/main/oauth/github-copilot-local-credential.ts @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import type { SubscriptionActionResult } from '@maka/core/oauth-subscription'; +import { + createGitHubCopilotAccountTokens, + isSupportedGitHubCopilotAccountToken, + serializeOAuthSubscriptionTokens, +} from '@maka/runtime/subscription-credentials'; + +const execFileAsync = promisify(execFile); + +export interface ImportedGitHubCopilotCredential { + readonly result: SubscriptionActionResult; + /** Present only on success; the selected Host validates and commits it. */ + readonly secret?: string; +} + +export interface ImportGitHubCopilotLocalCredentialDeps { + readonly resolveGitHubToken?: () => Promise; +} + +/** + * Discovers a GitHub credential already held by this machine (`gh auth token` + * or a compatible environment variable) and performs only local shape checks. + * Provider entitlement, network transport, ordering, and persistence all belong + * to the selected Host's adoption operation. + */ +export async function importGitHubCopilotLocalCredential( + deps: ImportGitHubCopilotLocalCredentialDeps = {}, +): Promise { + const resolveToken = deps.resolveGitHubToken ?? resolveGitHubAccountToken; + try { + const githubToken = (await resolveToken()).trim(); + if (githubToken.startsWith('ghp_')) { + return { + result: { + ok: false, + reason: 'token_exchange_failed', + message: + 'GitHub Copilot 不支持 classic PAT;请使用兼容 OAuth 登录或具有 Copilot Requests 权限的 fine-grained PAT。', + }, + }; + } + if (!isSupportedGitHubCopilotAccountToken(githubToken)) { + return { + result: { + ok: false, + reason: 'token_exchange_failed', + message: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', + }, + }; + } + return { + result: { ok: true }, + secret: serializeOAuthSubscriptionTokens(createGitHubCopilotAccountTokens(githubToken)), + }; + } catch { + return { + result: { + ok: false, + reason: 'token_exchange_failed', + message: '未找到可导入的 GitHub 凭据;请先使用 gh 登录或配置兼容凭据。', + }, + }; + } +} + +async function resolveGitHubAccountToken(): Promise { + for (const name of ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const) { + const token = process.env[name]?.trim(); + if (token) return token; + } + const result = await execFileAsync('gh', ['auth', 'token'], { + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 64 * 1024, + }); + return result.stdout; +} diff --git a/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts b/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts deleted file mode 100644 index 1d6930eed2..0000000000 --- a/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - -import type { ModelInfo } from '@maka/core/llm-connections'; - -import type { SubscriptionActionResult } from '@maka/core/oauth-subscription'; -import { - createGitHubCopilotAccountTokens, - GITHUB_COPILOT_DEFAULT_API_ENDPOINT, - isSupportedGitHubCopilotAccountToken, - parseOAuthSubscriptionTokens, - resolveOAuthSubscriptionTokens, - serializeOAuthSubscriptionTokens, - type OAuthSubscriptionTokens, -} from '@maka/runtime/subscription-credentials'; -import { fetchGitHubCopilotModels } from '@maka/runtime/model-fetcher'; -import type { CredentialStore } from '@maka/storage/credential-store'; - -const GITHUB_COPILOT_CONNECTION_SLUG = 'github-copilot'; -const execFileAsync = promisify(execFile); - -export interface GitHubCopilotAccountStateSnapshot { - provider: 'github-copilot'; - runtimeState: 'not_logged_in' | 'authenticated' | 'refreshing' | 'refresh_failed' | 'storage_failed'; - errorMessage?: string; -} - -export interface GitHubCopilotSubscriptionServiceDeps { - credentialStore: Pick; - resolveGitHubToken?: () => Promise; - now?: () => number; - fetchFn?: typeof fetch; -} - -export type GitHubCopilotValidatedActionResult = - | { ok: true; models: ModelInfo[] } - | Exclude; - -/** Main-process adapter for importing an existing supported `gh` login. */ -export class GitHubCopilotSubscriptionService { - private readonly credentialStore: GitHubCopilotSubscriptionServiceDeps['credentialStore']; - private readonly resolveGitHubToken: () => Promise; - private readonly now: () => number; - private readonly fetchFn: typeof fetch; - private refreshing = false; - private lastRefreshError: string | null = null; - private lastStorageError: string | null = null; - - constructor(deps: GitHubCopilotSubscriptionServiceDeps) { - this.credentialStore = deps.credentialStore; - this.resolveGitHubToken = deps.resolveGitHubToken ?? resolveGitHubAccountToken; - this.now = deps.now ?? (() => Date.now()); - this.fetchFn = deps.fetchFn ?? fetch; - } - - async connectExistingLogin(): Promise { - try { - const githubToken = (await this.resolveGitHubToken()).trim(); - if (githubToken.startsWith('ghp_')) { - return { - ok: false, - reason: 'token_exchange_failed', - message: 'GitHub Copilot 不支持 classic PAT;请使用兼容 OAuth 登录或具有 Copilot Requests 权限的 fine-grained PAT。', - }; - } - if (!isSupportedGitHubCopilotAccountToken(githubToken)) { - return { - ok: false, - reason: 'token_exchange_failed', - message: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', - }; - } - const tokens = createGitHubCopilotAccountTokens(githubToken); - const models = await fetchGitHubCopilotModels(tokens.base_url!, tokens.access_token, this.fetchFn); - if (models.length === 0) throw new Error('GitHub Copilot account returned no usable models.'); - await this.saveTokens(tokens); - this.lastRefreshError = null; - return { ok: true, models }; - } catch { - return { - ok: false, - reason: 'token_exchange_failed', - message: '无法连接 GitHub Copilot。请确认账号具有订阅访问权限,且凭据具有 Copilot Requests 权限;普通 gh auth login 可能不包含该权限。', - }; - } - } - - async getAccountState(): Promise { - let tokens: OAuthSubscriptionTokens | null; - try { - tokens = await this.loadTokens(); - this.lastStorageError = null; - } catch { - this.lastStorageError = 'GitHub Copilot 本地凭据读取失败。'; - tokens = null; - } - if (this.lastStorageError) { - return { provider: 'github-copilot', runtimeState: 'storage_failed', errorMessage: this.lastStorageError }; - } - if (!tokens) return { provider: 'github-copilot', runtimeState: 'not_logged_in' }; - if (this.refreshing) return { provider: 'github-copilot', runtimeState: 'refreshing' }; - if (this.lastRefreshError) { - return { provider: 'github-copilot', runtimeState: 'refresh_failed', errorMessage: this.lastRefreshError }; - } - return { provider: 'github-copilot', runtimeState: 'authenticated' }; - } - - async refreshTokens(): Promise { - const current = await this.loadTokens().catch(() => null); - if (!current) return { ok: false, reason: 'refresh_failed', message: '当前未导入 GitHub Copilot 登录。' }; - this.refreshing = true; - try { - const models = await fetchGitHubCopilotModels( - current.base_url ?? GITHUB_COPILOT_DEFAULT_API_ENDPOINT, - current.access_token, - this.fetchFn, - ); - if (models.length === 0) throw new Error('GitHub Copilot account returned no usable models.'); - this.lastRefreshError = null; - return { ok: true, models }; - } catch { - this.lastRefreshError = 'GitHub Copilot 凭据刷新失败,请重新导入 GitHub CLI 登录。'; - return { ok: false, reason: 'refresh_failed', message: this.lastRefreshError }; - } finally { - this.refreshing = false; - } - } - - async logout(): Promise { - try { - await this.credentialStore.deleteSecret(GITHUB_COPILOT_CONNECTION_SLUG, 'oauth_token'); - this.lastRefreshError = null; - this.lastStorageError = null; - return { ok: true }; - } catch { - return { ok: false, reason: 'storage_failed', message: '删除 GitHub Copilot 本地凭据失败。' }; - } - } - - async getAccessTokenInternal(): Promise { - const tokens = await resolveOAuthSubscriptionTokens({ - providerType: 'github-copilot', - slug: GITHUB_COPILOT_CONNECTION_SLUG, - credentialStore: this.credentialStore, - now: this.now, - fetchFn: this.fetchFn, - }); - return tokens?.access_token ?? null; - } - - async getTokensInternal(): Promise { - return resolveOAuthSubscriptionTokens({ - providerType: 'github-copilot', - slug: GITHUB_COPILOT_CONNECTION_SLUG, - credentialStore: this.credentialStore, - now: this.now, - fetchFn: this.fetchFn, - }); - } - - async hasStoredCredential(): Promise { - return (await this.loadTokens().catch(() => null)) !== null; - } - - private async loadTokens(): Promise { - const raw = await this.credentialStore.getSecret(GITHUB_COPILOT_CONNECTION_SLUG, 'oauth_token'); - return raw ? parseOAuthSubscriptionTokens(raw) : null; - } - - private async saveTokens(tokens: OAuthSubscriptionTokens): Promise { - await this.credentialStore.setSecret( - GITHUB_COPILOT_CONNECTION_SLUG, - 'oauth_token', - serializeOAuthSubscriptionTokens(tokens), - ); - } -} - -async function resolveGitHubAccountToken(): Promise { - for (const name of ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const) { - const token = process.env[name]?.trim(); - if (token) return token; - } - const result = await execFileAsync('gh', ['auth', 'token'], { - encoding: 'utf8', - timeout: 10_000, - maxBuffer: 64 * 1024, - }); - return result.stdout; -} diff --git a/apps/desktop/src/main/runtime-host-account-connection.ts b/apps/desktop/src/main/runtime-host-account-connection.ts index e98892fd37..e6230985d3 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -17,11 +17,7 @@ * under the License. */ -import { - PROVIDER_REGISTRY, - providerFallbackModelIds, - type ProviderType, -} from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, type ProviderType } from '@maka/core/llm-connections'; import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot, @@ -31,7 +27,6 @@ import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; export type RuntimeHostAccountConnectionClient = Pick< DesktopRuntimeHostClient, - | 'createConnection' | 'deleteCredential' | 'fetchConnectionModels' | 'loadConnectionCatalog' @@ -40,67 +35,6 @@ export type RuntimeHostAccountConnectionClient = Pick< | 'updateConnection' >; -export interface RuntimeHostAccountConnectionIdentity { - readonly providerType: ProviderType; - readonly slug: string; -} - -export async function ensureRuntimeHostAccountConnection( - client: RuntimeHostAccountConnectionClient, - identity: RuntimeHostAccountConnectionIdentity, - enabledModelIds?: readonly string[], -): Promise { - let catalog = await client.loadConnectionCatalog(); - const existing = findRuntimeHostAccountConnection(catalog, identity.providerType); - const desiredModels = - enabledModelIds && enabledModelIds.length > 0 - ? enabledModelIds - : existing?.enabledModelIds.length - ? existing.enabledModelIds - : providerFallbackModelIds(PROVIDER_REGISTRY[identity.providerType]); - if (!existing) { - const slugOwner = catalog.connections.find(({ slug }) => slug === identity.slug); - if (slugOwner) { - throw new Error(`Connection slug belongs to ${slugOwner.providerType}`); - } - const defaults = PROVIDER_REGISTRY[identity.providerType]; - const created = await client.createConnection(catalog.revision, { - slug: identity.slug, - name: defaults.label, - providerType: identity.providerType, - ...(defaults.baseUrl ? { baseUrl: defaults.baseUrl } : {}), - enabled: true, - enabledModelIds: [...desiredModels], - }); - if (created.kind !== 'committed') { - throw new Error(`Unable to create account Connection: ${created.kind}`); - } - catalog = await client.loadConnectionCatalog(); - const connection = findRuntimeHostAccountConnection(catalog, identity.providerType); - if (!connection) throw new Error('Account Connection was not committed'); - return connection; - } - if ( - existing.enabled && - sameStrings(existing.enabledModelIds, desiredModels) - ) { - return existing; - } - const updated = await client.updateConnection( - { connectionId: existing.connectionId, revision: existing.revision }, - accountConnectionChanges(existing, true, desiredModels), - ); - if (updated.kind !== 'committed') { - throw new Error(`Unable to prepare account Connection: ${updated.kind}`); - } - const prepared = findRuntimeHostAccountConnection( - await client.loadConnectionCatalog(), - identity.providerType, - ); - if (!prepared) throw new Error('Account Connection disappeared after preparation'); - return prepared; -} - export async function synchronizeRuntimeHostAccountConnection( client: RuntimeHostAccountConnectionClient, providerType: ProviderType, @@ -142,38 +76,6 @@ export async function synchronizeRuntimeHostAccountConnectionById( } } -export async function setRuntimeHostAccountCredential( - client: RuntimeHostAccountConnectionClient & - Pick, - connection: ConnectionCatalogEntry, - secret: string, -): Promise { - const locator = runtimeHostAccountCredential(connection); - const current = await client.queryCredential(locator); - const result = await client.setCredential({ - locator, - expected: current?.configured - ? { credentialId: current.credentialId, revision: current.revision } - : null, - secret, - }); - if (result.kind !== 'committed') { - throw new Error(`Unable to save account credential: ${result.kind}`); - } -} - -export async function disableRuntimeHostAccountConnection( - client: RuntimeHostAccountConnectionClient, - providerType: ProviderType, -): Promise { - const connection = findRuntimeHostAccountConnection( - await client.loadConnectionCatalog(), - providerType, - ); - if (!connection) return; - return disableRuntimeHostAccountConnectionById(client, connection.connectionId); -} - export async function disableRuntimeHostAccountConnectionById( client: RuntimeHostAccountConnectionClient, connectionId: string, @@ -253,7 +155,3 @@ function accountConnectionChanges( // declarations another writer made. }; } - -function sameStrings(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 5c67b9ff82..5a39dba44b 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -557,6 +557,12 @@ export class DesktopRuntimeHostClient { return this.request("oauth.login.cancel", { attemptId }); } + queryOAuthEnrollment( + provider: OperationInput<"oauth.enrollment.query">["provider"], + ): Promise> { + return this.request("oauth.enrollment.query", { provider }); + } + async loadSkillCatalog( context: SkillCatalogWorkspaceContext, view: SkillCatalogView, diff --git a/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts b/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts index d04224cb16..d463637e23 100644 --- a/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts @@ -17,36 +17,23 @@ * under the License. */ -import type { ModelInfo } from '@maka/core/llm-connections'; -import type { SubscriptionActionResult } from '@maka/core/oauth-subscription'; import { - handleReconnectableRead, - type ReconnectableReadIpcMain, -} from './ipc-reconnect-policy.js'; -import { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js'; + importGitHubCopilotLocalCredential, + type ImportedGitHubCopilotCredential, +} from './oauth/github-copilot-local-credential.js'; +import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; import { - disableRuntimeHostAccountConnection, - ensureRuntimeHostAccountConnection, findRuntimeHostAccountConnection, - runtimeHostAccountCredential, - setRuntimeHostAccountCredential, - synchronizeRuntimeHostAccountConnection, - type RuntimeHostAccountConnectionClient, + findRuntimeHostAccountConnectionById, } from './runtime-host-account-connection.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; const PROVIDER = 'github-copilot'; -const CONNECTION_SLUG = 'github-copilot'; -type GitHubCopilotClient = RuntimeHostAccountConnectionClient & - Pick; - -interface ImportedGitHubCopilotCredential { - readonly result: - | { readonly ok: true; readonly models: ModelInfo[] } - | Exclude; - readonly secret?: string; -} +type GitHubCopilotClient = Pick< + DesktopRuntimeHostClient, + 'loadConnectionCatalog' | 'saveConnectionOnboarding' | 'setDefaultConnectionTarget' +>; export interface RuntimeHostGitHubCopilotIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; @@ -55,97 +42,76 @@ export interface RuntimeHostGitHubCopilotIpcDeps { readonly importExistingLogin?: () => Promise; } -/** Keeps local `gh` discovery in Desktop while committing its credential only to the Host vault. */ -export function registerRuntimeHostGitHubCopilotIpc( - deps: RuntimeHostGitHubCopilotIpcDeps, -): void { - const importExistingLogin = deps.importExistingLogin ?? importGitHubCopilotCredential; +/** + * Desktop discovers credential material already present on this machine. The + * selected Host owns everything after that seam: provider discovery over its + * transport, the connection/credential generation basis, and the atomic commit. + */ +export function registerRuntimeHostGitHubCopilotIpc(deps: RuntimeHostGitHubCopilotIpcDeps): void { + const importExistingLogin = deps.importExistingLogin ?? importGitHubCopilotLocalCredential; deps.ipcMain.handle('github-copilot:connect-existing-login', async () => { const imported = await importExistingLogin(); if (!imported.result.ok) return imported.result; if (!imported.secret) return storageFailure('GitHub Copilot login produced no credential'); + try { - const connection = await ensureRuntimeHostAccountConnection( - deps.client, - { providerType: PROVIDER, slug: CONNECTION_SLUG }, - imported.result.models.map(({ id }) => id), - ); - await setRuntimeHostAccountCredential(deps.client, connection, imported.secret); - await synchronizeRuntimeHostAccountConnection(deps.client, PROVIDER).catch( - () => undefined, - ); + const before = await deps.client.loadConnectionCatalog(); + const existing = findRuntimeHostAccountConnection(before, PROVIDER); + const adopted = await deps.client.saveConnectionOnboarding({ + target: existing + ? { kind: 'existing', connectionId: existing.connectionId } + : { kind: 'create', providerType: PROVIDER }, + apiKey: imported.secret, + baseUrl: null, + // The Host enables the non-empty model set this same operation verifies. + enabledModelIds: [], + }); + if (adopted.kind === 'rejected') { + if (adopted.reason === 'superseded') { + return storageFailure('GitHub Copilot 账号在导入期间发生变化,请重试。'); + } + return adopted.reason === 'model_unavailable' + ? actionFailure('当前 GitHub 账号没有可用的 Copilot 订阅权限。') + : actionFailure('当前 GitHub 凭据无法导入,请检查凭据后重试。'); + } + if (adopted.kind === 'failed') { + return adopted.errorClass === 'auth' + ? actionFailure('当前 GitHub 账号没有可用的 Copilot 订阅权限。') + : actionFailure('暂时无法验证 GitHub Copilot 订阅状态,请稍后重试。'); + } + + await selectAccountDefaultIfMissing(deps.client, adopted.connection.connectionId); deps.emitConnectionListChanged(); return { ok: true as const }; } catch { return storageFailure('GitHub Copilot login could not be committed to Runtime Host'); } }); +} - handleReconnectableRead(deps.ipcMain, 'github-copilot:get-account-state', async () => { - const connection = findRuntimeHostAccountConnection( - await deps.client.loadConnectionCatalog(), - PROVIDER, - ); - const credential = connection - ? await deps.client.queryCredential(runtimeHostAccountCredential(connection)) - : null; - return { - provider: PROVIDER, - runtimeState: credential?.configured ? 'authenticated' : 'not_logged_in', - } as const; - }); - - deps.ipcMain.handle('github-copilot:refresh-tokens', async () => { - const connection = findRuntimeHostAccountConnection( - await deps.client.loadConnectionCatalog(), - PROVIDER, - ); - if (!connection) return refreshFailure('GitHub Copilot is not connected'); - const credential = await deps.client.queryCredential( - runtimeHostAccountCredential(connection), - ); - if (!credential?.configured) return refreshFailure('GitHub Copilot is not connected'); - const refreshed = await deps.client.fetchConnectionModels(connection.connectionId); - if (refreshed.kind !== 'committed') { - return refreshFailure(`GitHub Copilot refresh failed: ${refreshed.kind}`); - } - deps.emitConnectionListChanged(); - return { ok: true as const }; - }); - - deps.ipcMain.handle('github-copilot:logout', async () => { - try { - await disableRuntimeHostAccountConnection(deps.client, PROVIDER); - } catch { - return storageFailure('GitHub Copilot account could not be removed from Runtime Host'); - } - deps.emitConnectionListChanged(); - return { ok: true as const }; +async function selectAccountDefaultIfMissing( + client: GitHubCopilotClient, + connectionId: string, +): Promise { + const catalog = await client.loadConnectionCatalog(); + if (catalog.defaultTarget !== null) return; + const connection = findRuntimeHostAccountConnectionById(catalog, connectionId); + const modelId = connection?.enabledModelIds[0]; + if (!connection || !modelId) return; + const selected = await client.setDefaultConnectionTarget(catalog.revision, { + connectionId, + modelId, }); + if (selected.kind !== 'committed') { + throw new Error(`Unable to select account default: ${selected.kind}`); + } } -async function importGitHubCopilotCredential(): Promise { - let secret: string | undefined; - const service = new GitHubCopilotSubscriptionService({ - credentialStore: { - getSecret: async () => secret ?? null, - setSecret: async (_slug, _kind, value) => { - secret = value; - }, - deleteSecret: async () => { - secret = undefined; - }, - }, - }); - const result = await service.connectExistingLogin(); - return { result, ...(result.ok && secret ? { secret } : {}) }; +function actionFailure(message: string) { + return { ok: false as const, reason: 'token_exchange_failed' as const, message }; } function storageFailure(message: string) { return { ok: false as const, reason: 'storage_failed' as const, message }; } - -function refreshFailure(message: string) { - return { ok: false as const, reason: 'refresh_failed' as const, message }; -} 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 6fd139b3b3..f1e34826f9 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -22,7 +22,7 @@ import { decodeRuntimePolicyEntityId, type ConnectionCatalogEntry, } from '@maka/core/runtime-policy'; -import { isOAuthEnrollmentProviderEnabled } from '@maka/runtime/oauth-provider-contracts'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import { OAUTH_LOGIN_PROVIDERS, type OAuthConnectionIdentity, @@ -54,19 +54,20 @@ const SHARED_OAUTH_IPC_OPERATIONS = [ 'complete-authorization', 'cancel-authorization', 'get-account-state', + 'get-enrollment-state', 'refresh-tokens', 'logout', ] as const; export const RUNTIME_HOST_OAUTH_IPC_CHANNELS = Object.freeze([ - ...OAUTH_LOGIN_PROVIDERS.flatMap((provider) => [ - ...(provider === 'xai-oauth' ? [] : [`${provider}:is-experimental-enabled`]), - ...SHARED_OAUTH_IPC_OPERATIONS.map((operation) => `${provider}:${operation}`), - ]), + ...OAUTH_LOGIN_PROVIDERS.flatMap((provider) => + SHARED_OAUTH_IPC_OPERATIONS.map((operation) => `${provider}:${operation}`), + ), ]); type OAuthClient = RuntimeHostAccountConnectionClient & Pick< DesktopRuntimeHostClient, | 'cancelOAuthLogin' + | 'queryOAuthEnrollment' | 'queryOAuthLogin' | 'startOAuthLogin' >; @@ -76,7 +77,6 @@ export interface RuntimeHostOAuthIpcDeps { readonly client: OAuthClient; readonly presentation: RuntimeHostOAuthPresentation; readonly emitConnectionListChanged: () => void; - readonly isProviderEnabled?: (provider: OAuthLoginProvider) => boolean; } interface ActiveOAuthAttempt { @@ -87,15 +87,10 @@ interface ActiveOAuthAttempt { /** Adapts the existing Desktop OAuth UI to the Host's provider-neutral OAuth operations. */ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void { const activeAttempts = new Map(); - const providerEnabled = deps.isProviderEnabled ?? isOAuthEnrollmentProviderEnabled; for (const provider of OAUTH_LOGIN_PROVIDERS) { const channel = (operation: string) => `${provider}:${operation}`; - if (provider !== 'xai-oauth') { - deps.ipcMain.handle(channel('is-experimental-enabled'), () => providerEnabled(provider)); - } deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawTarget: unknown) => { - if (!providerEnabled(provider)) return providerDisabled(); const selection = decodeOAuthLoginSelection(rawTarget); if (selection.kind === 'invalid') return invalidConnectionIdentity(); const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; @@ -145,9 +140,25 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void error instanceof Error && error.message.trim().length > 0 ? error.message : 'Unable to start OAuth authorization'; - return actionFailure(detail); + // The selected Host refuses an enrollment that install has not opted + // into with `operation_unavailable`. Keep that as its own reason so the + // renderer can say the path is off rather than that authorization + // failed — a remote Host may gate differently from this Desktop process. + return actionFailure( + detail, + error instanceof RuntimeHostOperationError && error.code === 'operation_unavailable' + ? 'experimental_disabled' + : 'unknown', + ); } }); + handleReconnectableRead(deps.ipcMain, channel('get-enrollment-state'), async () => { + // The renderer asks the selected Host whether this provider may enrol, so + // it can avoid presenting a primary sign-in that the install refuses. + // Desktop keeps no second copy of the Host's gate. + const enrollment = await deps.client.queryOAuthEnrollment(provider); + return { enabled: enrollment.enabled }; + }); deps.ipcMain.handle(channel('open-auth-url'), (_event, attemptId: unknown) => { return isProviderAttempt(activeAttempts, attemptId, provider) ? { ok: true as const } @@ -432,10 +443,6 @@ async function configuredOAuthAccountConnections( return configured.filter(({ status }) => status?.configured).map(({ connection }) => connection); } -function providerDisabled() { - return actionFailure('OAuth enrollment is disabled for this provider', 'experimental_disabled'); -} - function actionFailure( message: string, reason: diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index dae56b81e8..d68e5c75c2 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -380,7 +380,7 @@ export type DesktopOAuthLoginTarget = export interface DesktopOAuthConnectionIdentity { readonly connectionId: string; readonly slug: string; - readonly providerType: 'openai-codex' | 'xai-oauth'; + readonly providerType: 'openai-codex' | 'xai-oauth' | 'github-copilot'; } export type DesktopOAuthAuthorizationStartResult = @@ -1548,7 +1548,6 @@ export interface MakaBridge { >; }; openAiCodex: { - isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise; getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; @@ -1570,6 +1569,7 @@ export interface MakaBridge { } | Exclude >; + getEnrollmentState(host?: DesktopRuntimeHostRef): Promise<{ enabled: boolean }>; refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; @@ -1592,18 +1592,45 @@ export interface MakaBridge { } | Exclude >; + getEnrollmentState(host?: DesktopRuntimeHostRef): Promise<{ enabled: boolean }>; refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; githubCopilotSubscription: { connectExistingLogin(host?: DesktopRuntimeHostRef): Promise; - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ - provider: 'github-copilot'; - runtimeState: 'not_logged_in' | 'authenticated' | 'refreshing' | 'refresh_failed' | 'storage_failed'; - errorMessage?: string; - }>; - refreshTokens(host?: DesktopRuntimeHostRef): Promise; - logout(host?: DesktopRuntimeHostRef): Promise; + getAuthUrl( + host: DesktopRuntimeHostRef | undefined, + target: DesktopOAuthLoginTarget, + ): Promise; + openAuthUrl( + authRequestId: string, + host?: DesktopRuntimeHostRef, + ): Promise; + completeAuthorization( + authRequestId: string, + host?: DesktopRuntimeHostRef, + ): Promise; + cancelAuthorization( + authRequestId?: string, + host?: DesktopRuntimeHostRef, + ): Promise<{ ok: true }>; + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise< + | { + provider: 'github-copilot'; + runtimeState: + | 'not_logged_in' + | 'authorizing' + | 'authenticated' + | 'refreshing' + | 'refresh_failed' + | 'storage_failed'; + errorMessage?: string; + } + | Exclude + >; + getEnrollmentState(host?: DesktopRuntimeHostRef): Promise<{ enabled: boolean }>; + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; scheduledTasks: { list(host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index da59cbf5de..b4013452f4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2931,9 +2931,6 @@ const makaBridge = { // `authRequestId`; the URL is held by main from the earlier `getAuthUrl` // call. Renderer can never hand `shell.openExternal` an arbitrary URL. openAiCodex: { - isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled'); - }, getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', target); }, @@ -2957,6 +2954,9 @@ const makaBridge = { }> { return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state', connectionId); }, + getEnrollmentState(host?: DesktopRuntimeHostRef): Promise<{ enabled: boolean }> { + return invokeSelectedRuntimeHost(host, 'openai-codex:get-enrollment-state'); + }, refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens', connectionId); }, @@ -2990,6 +2990,9 @@ const makaBridge = { }> { return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state', connectionId); }, + getEnrollmentState(host?: DesktopRuntimeHostRef): Promise<{ enabled: boolean }> { + return invokeSelectedRuntimeHost(host, 'xai-oauth:get-enrollment-state'); + }, refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens', connectionId); }, @@ -3001,18 +3004,33 @@ const makaBridge = { connectExistingLogin(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'github-copilot:connect-existing-login'); }, - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { + return invokeSelectedRuntimeHost(host, 'github-copilot:get-auth-url', target); + }, + openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'github-copilot:open-auth-url', authRequestId); + }, + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'github-copilot:complete-authorization', authRequestId); + }, + cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { + return invokeSelectedRuntimeHost(host, 'github-copilot:cancel-authorization', authRequestId); + }, + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{ provider: 'github-copilot'; - runtimeState: 'not_logged_in' | 'authenticated' | 'refreshing' | 'refresh_failed' | 'storage_failed'; + runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed' | 'storage_failed'; errorMessage?: string; }> { - return invokeSelectedRuntimeHost(host, 'github-copilot:get-account-state'); + return invokeSelectedRuntimeHost(host, 'github-copilot:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'github-copilot:refresh-tokens'); + getEnrollmentState(host?: DesktopRuntimeHostRef): Promise<{ enabled: boolean }> { + return invokeSelectedRuntimeHost(host, 'github-copilot:get-enrollment-state'); }, - logout(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'github-copilot:logout'); + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'github-copilot:refresh-tokens', connectionId); + }, + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'github-copilot:logout', connectionId); }, }, scheduledTasks: { diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index c323608c01..fa93449442 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -24,6 +24,8 @@ export { } from './services-context.js'; export type { ApiKeyOnboardingBridge, + ConnectionOAuthBridge, + ConnectionOAuthProviderBridge, ConnectionSettingsServices, ConnectionsBridge, DesktopConnectionOnboardingIdentity, diff --git a/apps/desktop/src/renderer/features/connection-settings/ports.ts b/apps/desktop/src/renderer/features/connection-settings/ports.ts index d6980a953b..3a479464c5 100644 --- a/apps/desktop/src/renderer/features/connection-settings/ports.ts +++ b/apps/desktop/src/renderer/features/connection-settings/ports.ts @@ -27,11 +27,13 @@ import type { SavedRequestHeaders, UpdateConnectionInput, } from '@maka/core/llm-connections'; +import type { SubscriptionActionResult } from '@maka/core/oauth-subscription'; import type { ConnectionOnboardingSaveInput, ConnectionOnboardingSaveResult, ConnectionOnboardingVerifyInput, ConnectionOnboardingVerifyResult, + OAuthConnectionIdentity, } from '@maka/runtime-host/protocol'; import type { DesktopConnectionIdentity, @@ -47,7 +49,43 @@ export type DesktopConnectionOnboardingIdentity = Extract< { readonly kind: 'saved' } >['connection']; +export type ConnectionOAuthLoginTarget = + | { readonly kind: 'create' } + | { readonly kind: 'existing'; readonly connectionId: string }; + +export type ConnectionOAuthAuthorizationStartResult = + | { + readonly authRequestId: string; + readonly stateHint: string; + readonly connection: OAuthConnectionIdentity; + } + | Exclude; + +export type ConnectionOAuthAuthorizationResult = + | { readonly ok: true; readonly connection: OAuthConnectionIdentity } + | Exclude; + +export interface ConnectionOAuthProviderBridge { + getAuthUrl(target: ConnectionOAuthLoginTarget): Promise; + openAuthUrl(authRequestId: string): Promise; + completeAuthorization(authRequestId: string): Promise; + cancelAuthorization(authRequestId?: string): Promise<{ readonly ok: true }>; + getEnrollmentState(): Promise<{ readonly enabled: boolean }>; + getAccountState(connectionId: string): Promise; + logout(connectionId: string): Promise; +} + +export interface ConnectionOAuthBridge { + readonly openAiCodex: ConnectionOAuthProviderBridge; + readonly xaiOAuth: ConnectionOAuthProviderBridge; + readonly githubCopilotSubscription: ConnectionOAuthProviderBridge & { + connectExistingLogin(): Promise; + }; +} + export interface ConnectionsBridge { + /** Host-bound account operations; every adapter and fixture must provide them. */ + readonly oauth: ConnectionOAuthBridge; getSnapshot(): Promise; setDefault(connection: DesktopConnectionIdentity | null): Promise; create(input: CreateConnectionInput): Promise; diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index d46309144e..ad35d5a432 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -126,10 +126,8 @@ const zhCopy = { endpointCredentialsMasked: '已保存的地址内嵌凭据,编辑时默认隐藏', modelManagement: '模型', disconnectAndDelete: '退出账号并删除', - copilotImportFailed: '导入 GitHub Copilot 登录失败', copilotLoggedIn: 'GitHub Copilot 已登录', copilotWaiting: '等待兼容 GitHub 凭据', - copilotLoggedInDetail: '若账号或组织策略变化,可重新导入兼容凭据。', copilotWaitingDetail: '配置具有 Copilot Requests 权限的凭据后从本机安全导入。', - reimport: '重新导入', importCredential: '导入兼容凭据', login: '登录', loggingIn: '登录中…', relogin: '重新登录', - oauthReloginDetail: '若请求提示需要重新登录,点这里重新走一遍授权。', + login: '登录', loggingIn: '登录中…', relogin: '重新登录', + oauthReloginDetail: '若请求提示需要重新登录,点这里重新走一遍授权。', deviceCode: '登录码:', oauthStartDetail: '点下方按钮打开浏览器完成登录,授权成功后会自动刷新这里的状态。', enabledModels: '启用的模型', searchModels: '搜索模型', selectAllModels: '全部启用', @@ -216,20 +214,22 @@ const zhCopy = { incomplete: '登录未完成', incompleteRetry: '登录未完成,请重新打开浏览器授权。', loginFailed: '登录失败', logoutDescription: '将删除本机保存的订阅凭据,之后需要重新登录才能继续使用这些 OAuth 模型。', logout: '退出登录', cancel: '取消', loggedOut: '已退出登录', credentialsCleared: '本地凭据已清除。', logoutFailed: '退出失败', logoutFailedRetry: '退出登录失败,请稍后重试。', - reverifyFailedRetry: '重新验证失败,请稍后重试。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', + serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', logoutTitle: (name: string) => `退出 ${name} 登录?`, }, oauthSection: { signedIn: '已登录', codexDescription: '使用 ChatGPT Plus / Pro 账号添加连接。', xaiDescription: '使用 SuperGrok / X Premium 账号添加连接。', configuredConnections: (count: number) => `已有 ${count} 个连接 · 添加另一个账号`, - copilotDescription: '导入兼容 GitHub 凭据连接 Copilot 订阅。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', + copilotDescription: '使用 GitHub 登录连接 Copilot 订阅,或导入兼容凭据。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', aria: 'OAuth 登录', staleState: 'OAuth 登录状态暂时没刷新成功,已保留上一次状态。', codexDetail: '点击下方按钮打开设备授权页,并在页面中输入这里显示的登录码。', xaiDetail: '点击下方按钮打开浏览器登录,授权完成后会自动回写。', deviceCode: '登录码:', 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: '移除本地登录', + copilotSubtitle: '使用 GitHub 登录,或导入兼容凭据;token 不会暴露给渲染进程。', copilotImported: '已导入 GitHub Copilot 订阅账号。', + copilotSetup: '使用 GitHub 登录以连接 Copilot,或导入已有的 gh 凭据。', importing: '导入中…', + copilotSignIn: '使用 GitHub 登录', copilotActionFailed: 'GitHub Copilot 账号操作失败', + copilotSignInDisabledHint: '本机未启用 GitHub 登录;请改用导入兼容凭据,或由管理员启用后重试。', + importCredential: '导入兼容凭据', loadingAccount: '正在加载账号状态…', authorizing: '请在弹出的浏览器窗口完成登录。', refreshing: '正在刷新访问令牌…', refreshTokenFailed: '令牌刷新失败,请重新登录。', 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} 尚未登录。`, @@ -286,10 +286,8 @@ const enCopy: ProviderSettingsCopy = { endpointCredentialsMasked: 'The saved URL embeds credentials and stays masked while editing', modelManagement: 'Models', disconnectAndDelete: 'Sign out and delete', - copilotImportFailed: 'Failed to import GitHub Copilot sign-in', copilotLoggedIn: 'GitHub Copilot signed in', copilotWaiting: 'Waiting for compatible GitHub credentials', - copilotLoggedInDetail: 'Reimport compatible credentials if the account or organization policy changes.', copilotWaitingDetail: 'Configure credentials with Copilot Requests permission, then import them securely from this device.', - reimport: 'Reimport', importCredential: 'Import compatible credentials', login: 'Sign in', loggingIn: 'Signing in…', relogin: 'Sign in again', - oauthReloginDetail: 'If a request asks you to sign in again, restart authorization here.', + login: 'Sign in', loggingIn: 'Signing in…', relogin: 'Sign in again', + oauthReloginDetail: 'If a request asks you to sign in again, restart authorization here.', deviceCode: 'Sign-in code:', oauthStartDetail: 'Open the browser below to sign in. This status refreshes automatically after authorization.', enabledModels: 'Enabled models', searchModels: 'Search models', selectAllModels: 'Enable all', @@ -376,20 +374,22 @@ const enCopy: ProviderSettingsCopy = { incomplete: 'Sign-in incomplete', incompleteRetry: 'Sign-in did not finish. Reopen the browser authorization and try again.', loginFailed: 'Sign-in failed', logoutDescription: 'This removes the locally stored subscription credentials. You must sign in again to use these OAuth models.', logout: 'Sign out', cancel: 'Cancel', loggedOut: 'Signed out', credentialsCleared: 'Local credentials cleared.', logoutFailed: 'Sign-out failed', logoutFailedRetry: 'Sign-out failed. Try again later.', - reverifyFailedRetry: 'Verification failed. Try again later.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', + serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', logoutTitle: (name: string) => `Sign out of ${name}?`, }, oauthSection: { 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.', + copilotDescription: 'Sign in with GitHub to connect a Copilot subscription, or import compatible credentials.', 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…', 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', + copilotSubtitle: 'Sign in with GitHub, or import compatible credentials. The token is never exposed to the renderer.', copilotImported: 'GitHub Copilot subscription account imported.', + copilotSetup: 'Sign in with GitHub to connect Copilot, or import an existing gh credential.', importing: 'Importing…', + copilotSignIn: 'Sign in with GitHub', copilotActionFailed: 'GitHub Copilot account action failed', + copilotSignInDisabledHint: 'GitHub sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it.', + importCredential: 'Import compatible credentials', loadingAccount: 'Loading account status…', authorizing: 'Complete sign-in in the browser window.', refreshing: 'Refreshing access token…', refreshTokenFailed: 'Token refresh failed. Sign in again.', 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.`, diff --git a/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts b/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts index 781261546b..a1c410253f 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts @@ -17,11 +17,26 @@ * under the License. */ -import type { MakaBridge } from '../../../preload/bridge-contract.js'; -import type { ConnectionSettingsServices } from '../../features/connection-settings'; +import type { + DesktopRuntimeHostRef, + MakaBridge, +} from '../../../preload/bridge-contract.js'; +import type { + ConnectionOAuthProviderBridge, + ConnectionSettingsServices, +} from '../../features/connection-settings'; + +type DesktopConnectionSettingsBridge = Pick< + MakaBridge, + 'connections' | 'openAiCodex' | 'xaiOAuth' | 'githubCopilotSubscription' +>; +type DesktopOAuthProviderBridge = + | MakaBridge['openAiCodex'] + | MakaBridge['xaiOAuth'] + | MakaBridge['githubCopilotSubscription']; export function createDesktopConnectionSettingsServices( - bridge: () => Pick = () => window.maka, + bridge: () => DesktopConnectionSettingsBridge = () => window.maka, ): ConnectionSettingsServices { const uncertainTargets = new Map(); const uncertaintyListeners = new Map void>>(); @@ -36,6 +51,15 @@ export function createDesktopConnectionSettingsServices( const targetKey = `${host.profileId}\u0000${host.hostId}`; return { connections: { + oauth: { + openAiCodex: bindOAuthProvider(() => bridge().openAiCodex, host), + xaiOAuth: bindOAuthProvider(() => bridge().xaiOAuth, host), + githubCopilotSubscription: { + ...bindOAuthProvider(() => bridge().githubCopilotSubscription, host), + connectExistingLogin: () => + bridge().githubCopilotSubscription.connectExistingLogin(host), + }, + }, getSnapshot: () => bridge().connections.getSnapshot(undefined, host), setDefault: (connection) => bridge().connections.setDefault(connection, host), setDefaultModel: (input) => bridge().connections.setDefaultModel(input, host), @@ -86,3 +110,33 @@ export function createDesktopConnectionSettingsServices( }, }; } + +function bindOAuthProvider( + provider: () => DesktopOAuthProviderBridge, + host: DesktopRuntimeHostRef, +): ConnectionOAuthProviderBridge { + return { + getAuthUrl: (target) => provider().getAuthUrl(host, target), + openAuthUrl: (authRequestId) => provider().openAuthUrl(authRequestId, host), + completeAuthorization: (authRequestId) => + provider().completeAuthorization(authRequestId, host), + cancelAuthorization: (authRequestId) => + provider().cancelAuthorization(authRequestId, host), + getEnrollmentState: () => provider().getEnrollmentState(host), + getAccountState: async (connectionId) => { + const value = await provider().getAccountState(host, connectionId); + if ( + value && + typeof value === 'object' && + 'ok' in value && + value.ok === false && + 'message' in value && + typeof value.message === 'string' + ) { + throw new Error(value.message); + } + return value; + }, + logout: (connectionId) => provider().logout(host, connectionId), + }; +} diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx index 10e7346cc0..149617a6d0 100644 --- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx +++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx @@ -73,7 +73,7 @@ export const CATALOG_INITIAL_FILTER: CatalogFilter = { query: '', category: 'all export interface CreatedOAuthConnectionIdentity { connectionId: string; slug: string; - providerType: 'openai-codex' | 'xai-oauth'; + providerType: 'openai-codex' | 'xai-oauth' | 'github-copilot'; } /** @@ -176,7 +176,7 @@ export function ProviderCatalogPage(props: { 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