diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 3cad453abf..6f2be12572 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3555,21 +3555,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, @@ -3587,7 +3583,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, @@ -3669,22 +3664,19 @@ }, "src/renderer/settings/provider-oauth-section.tsx": { "bridgePaths": { + "window.maka.githubCopilotSubscription": 1, "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 }, "environmentCapabilities": {}, "hookCalls": { - "useEffect": 1, "useMountedRef": 1, "useOAuthLoginFlow": 2, - "useRef": 1, + "useRuntimeHostSettingsErrorReporter": 1, "useRuntimeHostSettingsGenerationKey": 1, "useRuntimeHostSettingsTarget": 3, - "useState": 2, + "useState": 1, "useUiLocale": 3 }, "lifecycleMethods": {}, @@ -4474,6 +4466,7 @@ }, "src/renderer/settings/use-connection-detail.ts": { "bridgePaths": { + "window.maka.githubCopilotSubscription": 1, "window.maka.openAiCodex": 1, "window.maka.xaiOAuth": 1 }, @@ -4568,7 +4561,7 @@ "useMountedRef": 1, "useRef": 2, "useRuntimeHostSettingsErrorReporter": 1, - "useState": 5, + "useState": 6, "useToast": 1, "useUiLocale": 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..24ac2dad91 --- /dev/null +++ b/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts @@ -0,0 +1,158 @@ +/* + * 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'; + let authorization = ''; + try { + const imported = await importGitHubCopilotLocalCredential({ + fetchFn: async (url, init) => { + assert.equal(String(url), 'https://api.githubcopilot.com/models'); + authorization = new Headers(init?.headers).get('authorization') ?? ''; + return copilotModelsResponse(); + }, + }); + + assert.equal(imported.result.ok, true); + if (imported.result.ok) { + assert.deepEqual( + imported.result.models.map(({ id }) => id), + ['gpt-5.4'], + ); + } + assert.equal(authorization, 'Bearer github_pat_copilot_requests'); + } finally { + if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; + else process.env.COPILOT_GITHUB_TOKEN = previous; + } + }); + + test('returns the credential to the caller instead of storing it anywhere local', async () => { + let requestAuthorization = ''; + const imported = await importGitHubCopilotLocalCredential({ + 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(); + }, + }); + + assert.equal(imported.result.ok, true); + if (imported.result.ok) { + assert.deepEqual( + imported.result.models.map(({ id }) => id), + ['gpt-5.4'], + ); + } + assert.equal(requestAuthorization, 'Bearer gho_existing_login'); + // The Host vault is the only place this credential is written; the shape is + // the one `setRuntimeHostAccountCredential` commits verbatim. + 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 before any Copilot request', async () => { + let requested = false; + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => 'ghp_classic_pat', + fetchFn: async () => { + requested = true; + return Response.json({}); + }, + }); + + 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); + assert.equal(requested, false); + }); + + test('explains subscription or Copilot Requests policy rejection without exposing provider details', async () => { + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => 'gho_without_copilot_permission', + fetchFn: async () => new Response(null, { status: 403 }), + }); + + assert.equal(imported.result.ok, false); + if (!imported.result.ok) { + assert.match(imported.result.message, /Copilot Requests/); + assert.doesNotMatch(imported.result.message, /403|gho_without/); + } + assert.equal(imported.secret, undefined); + }); + + test('refuses an account that reaches no Copilot model', async () => { + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => 'gho_no_entitlement', + fetchFn: async () => Response.json({ data: [] }), + }); + + assert.equal(imported.result.ok, false); + assert.equal(imported.secret, undefined); + }); + + test('distinguishes a transient entitlement failure from account ineligibility', async () => { + const imported = await importGitHubCopilotLocalCredential({ + resolveGitHubToken: async () => 'gho_temporarily_unavailable', + fetchFn: async () => new Response(null, { status: 429 }), + }); + + assert.equal(imported.result.ok, false); + if (!imported.result.ok) { + assert.equal(imported.result.reason, 'token_exchange_failed'); + assert.match(imported.result.message, /暂时无法验证/); + assert.doesNotMatch(imported.result.message, /没有可用/); + } + assert.equal(imported.secret, undefined); + }); +}); + +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 }, + }, + }, + ], + }); +} 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-account-connection.test.ts b/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts index c02770cec1..edb4dfe340 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts @@ -119,4 +119,132 @@ describe('synchronizeRuntimeHostAccountConnection', () => { assert.equal(selectCalls(), 0); }); + + it("opens on a model the account's live list reported", async () => { + // The Connection is created before a credential exists, so its enabled ids + // are the curated fallback guess in the order this build ships them. The + // first of those may be a model the account never serves. + const { client, selected } = discoveringAccountClient( + ['fallback-only', 'gpt-5-codex'], + [{ id: 'gpt-5-codex' }, { id: 'gpt-5-codex-mini' }], + ); + + await synchronizeRuntimeHostAccountConnection(client, 'openai-codex'); + + assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'gpt-5-codex' }); + }); + + it('keeps every enabled id the live response omitted', async () => { + // Ordering only. A `/models` answer that cannot see a model is not evidence + // the account cannot run it, so nothing is pruned and nothing is rewritten. + const { client, catalog, updateCalls } = discoveringAccountClient( + ['fallback-only', 'gpt-5-codex'], + [{ id: 'gpt-5-codex' }], + ); + + await synchronizeRuntimeHostAccountConnection(client, 'openai-codex'); + + assert.deepEqual(catalog().connections[0]?.enabledModelIds, ['fallback-only', 'gpt-5-codex']); + assert.equal(updateCalls(), 0); + }); + + it('opens on the first enabled id when no live response arrived', async () => { + const { client, selected } = discoveringAccountClient( + ['fallback-only', 'gpt-5-codex'], + [{ id: 'gpt-5-codex' }], + 'fallback', + ); + + await synchronizeRuntimeHostAccountConnection(client, 'openai-codex'); + + assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'fallback-only' }); + }); + + it('opens on the first enabled id when the live list shares none of them', async () => { + const { client, selected } = discoveringAccountClient( + ['fallback-only'], + [{ id: 'gpt-5-codex' }], + ); + + await synchronizeRuntimeHostAccountConnection(client, 'openai-codex'); + + assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'fallback-only' }); + }); }); + +/** A client whose discovery commits an inventory, so a live list exists. */ +function discoveringAccountClient( + enabledModelIds: readonly string[], + models: readonly { id: string }[], + modelSource: 'fetched' | 'fallback' = 'fetched', +): { + readonly client: RuntimeHostAccountConnectionClient; + catalog(): ConnectionCatalogSnapshot; + selected(): ConnectionTarget | null | undefined; + updateCalls(): number; +} { + let catalog: ConnectionCatalogSnapshot = { + ...catalogWithoutDefault(), + connections: [ + { + ...catalogWithoutDefault().connections[0]!, + enabledModelIds: [...enabledModelIds], + models: [...models], + modelSource, + }, + ], + }; + let selected: ConnectionTarget | null | undefined; + let updates = 0; + const client = { + loadConnectionCatalog: async (): Promise => catalog, + fetchConnectionModels: async () => ({ + kind: 'committed' as const, + catalogRevision: catalog.revision, + connection: { + connectionId: CONNECTION_ID, + revision: catalog.connections[0]?.revision ?? 1, + }, + modelCount: models.length, + source: modelSource, + fetchedAt: 1, + }), + updateConnection: async ( + expected: { connectionId: string; revision: number }, + changes: { enabledModelIds?: string[] }, + ) => { + updates += 1; + const current = catalog.connections[0]!; + assert.deepEqual(expected, { + connectionId: current.connectionId, + revision: current.revision, + }); + const updated = { + ...current, + ...(changes.enabledModelIds ? { enabledModelIds: changes.enabledModelIds } : {}), + revision: current.revision + 1, + }; + catalog = { ...catalog, revision: catalog.revision + 1, connections: [updated] }; + return { + kind: 'committed' as const, + catalogRevision: catalog.revision, + connection: { connectionId: updated.connectionId, revision: updated.revision }, + }; + }, + setDefaultConnectionTarget: async ( + expectedCatalogRevision: number, + target: ConnectionTarget | null, + ) => { + assert.equal(expectedCatalogRevision, catalog.revision); + selected = target; + catalog = { ...catalog, revision: catalog.revision + 1, defaultTarget: target }; + return { kind: 'committed' as const, catalogRevision: catalog.revision }; + }, + } as unknown as RuntimeHostAccountConnectionClient; + return { + client, + catalog: () => catalog, + selected: () => selected, + updateCalls: () => updates, + }; +} 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 517d089756..88742f5dc9 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 @@ -175,16 +175,11 @@ test('imports a local GitHub credential through the shared Host account path', a 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); + // Interactive enrollment, account state, refresh, and sign-out belong to the + // Host OAuth coordinator's shared adapter; Desktop registers the local + // credential import and nothing else. + assert.deepEqual([...handlers.keys()], ['github-copilot:connect-existing-login']); }); async function invoke( 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 84fa6dd386..b2777c4862 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 @@ -22,6 +22,7 @@ import test from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { OAuthLoginProvider } from '@maka/runtime-host/protocol'; import { RUNTIME_HOST_OAUTH_IPC_CHANNELS, registerRuntimeHostOAuthIpc, @@ -642,6 +643,30 @@ test('keeps a committed OAuth login successful when model discovery fails withou 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, + isProviderEnabled: () => true, + }); + + assert.deepEqual(await invoke(handlers, 'github-copilot:get-enrollment-state'), { + enabled: expected, + }); + assertNoUnexpectedClientCalls(); + } +}); + function createFailClosedOAuthClient(overrides: Partial): { readonly client: OAuthClient; assertNoUnexpectedClientCalls(): void; @@ -664,6 +689,7 @@ function createFailClosedOAuthClient(overrides: Partial): { startOAuthLogin: unexpected('startOAuthLogin'), queryOAuthLogin: unexpected('queryOAuthLogin'), cancelOAuthLogin: unexpected('cancelOAuthLogin'), + queryOAuthEnrollment: unexpected('queryOAuthEnrollment'), ...overrides, } satisfies OAuthClient; return { diff --git a/apps/desktop/src/main/oauth-connection-identities.ts b/apps/desktop/src/main/oauth-connection-identities.ts new file mode 100644 index 0000000000..f74938a8f2 --- /dev/null +++ b/apps/desktop/src/main/oauth-connection-identities.ts @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { OAuthLoginProvider } from '@maka/runtime-host/protocol'; + +/** Stable Desktop connection identities for Host-supported interactive OAuth providers. */ +export const INTERACTIVE_OAUTH_CONNECTION_SLUGS = { + 'openai-codex': 'codex-subscription', + 'xai-oauth': 'xai-oauth', + // Shared with the local `gh` credential import so both routes to a Copilot + // account land on one Connection instead of two. + 'github-copilot': 'github-copilot', +} as const satisfies Readonly>; 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..cd43c57950 --- /dev/null +++ b/apps/desktop/src/main/oauth/github-copilot-local-credential.ts @@ -0,0 +1,144 @@ +/* + * 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, + isSupportedGitHubCopilotAccountToken, + serializeOAuthSubscriptionTokens, +} from '@maka/runtime/subscription-credentials'; +import { fetchGitHubCopilotModels } from '@maka/runtime/model-fetcher'; +import { + GitHubCopilotEntitlementError, + GitHubCopilotEntitlementUnavailableError, + verifyGitHubCopilotModelEntitlement, +} from '@maka/runtime/github-copilot-oauth-enrollment'; + +const execFileAsync = promisify(execFile); + +export interface ImportedGitHubCopilotCredential { + readonly result: + | { readonly ok: true; readonly models: ModelInfo[] } + | Exclude; + /** Present only on success; the caller commits it to the Host vault. */ + readonly secret?: string; +} + +export interface ImportGitHubCopilotLocalCredentialDeps { + readonly resolveGitHubToken?: () => Promise; + readonly fetchFn?: typeof fetch; +} + +/** + * Reads a GitHub credential this machine already holds (`gh auth token`, or one + * of the `*_TOKEN` environment variables) and validates that it reaches a + * Copilot model. + * + * This is the one Copilot responsibility that genuinely depends on the local + * machine, so it is the only one Desktop keeps: interactive enrollment, account + * state, refresh, and sign-out all belong to the Host's OAuth coordinator. It + * holds no state between calls — the credential is returned to the caller and + * never written to disk here. + */ +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。', + }, + }; + } + const tokens = createGitHubCopilotAccountTokens(githubToken); + let models: ModelInfo[]; + try { + await verifyGitHubCopilotModelEntitlement({ + tokens, + fetchFn: deps.fetchFn ?? fetch, + }); + // The verifier owns classification; this second read only obtains the + // model IDs needed by the Host connection catalog. + models = await fetchGitHubCopilotModels(tokens.base_url!, tokens.access_token, deps.fetchFn); + } catch (error) { + if (error instanceof GitHubCopilotEntitlementError) { + return { + result: { + ok: false, + reason: 'token_exchange_failed', + message: + '当前 GitHub 账号没有可用的 Copilot 订阅权限;请确认账号具有 Copilot Requests 权限。', + }, + }; + } + if (error instanceof GitHubCopilotEntitlementUnavailableError) { + return { + result: { + ok: false, + reason: 'token_exchange_failed', + message: '暂时无法验证 GitHub Copilot 订阅状态,请稍后重试。', + }, + }; + } + throw error; + } + return { result: { ok: true, models }, secret: serializeOAuthSubscriptionTokens(tokens) }; + } catch { + return { + result: { + ok: false, + reason: 'token_exchange_failed', + message: + '无法连接 GitHub Copilot。请确认账号具有订阅访问权限,且凭据具有 Copilot Requests 权限;普通 gh auth login 可能不包含该权限。', + }, + }; + } +} + +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 f561dee5a9..2df04baba2 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -18,6 +18,7 @@ */ import { + classifyConnectionModelInventory, PROVIDER_DEFAULTS, type ProviderType, } from '@maka/core/llm-connections'; @@ -130,7 +131,7 @@ export async function synchronizeRuntimeHostAccountConnectionById( const catalog = await client.loadConnectionCatalog(); if (catalog.defaultTarget !== null) return; const updated = findRuntimeHostAccountConnectionById(catalog, connectionId); - const modelId = updated?.enabledModelIds[0]; + const modelId = updated && firstAccountDefaultModelId(updated); if (!updated || !modelId) return; const selected = await client.setDefaultConnectionTarget(catalog.revision, { connectionId: updated.connectionId, @@ -141,6 +142,28 @@ export async function synchronizeRuntimeHostAccountConnectionById( } } +/** + * The model this account's first default should name. + * + * An account Connection is created before anyone can ask the account what it + * has — the OAuth login path holds no credential at that point — so its enabled + * ids start as the provider's curated fallback list, in the order this build + * ships them. Taking the first one names a model the account may never serve, + * and every later operation that falls back to the default then fails on a + * model the user never chose. + * + * A live response is the better-informed opinion about which of those ids to + * open on, so it picks the order. It does not pick the set: an id the response + * omitted stays enabled, because a `/models` answer that cannot see a model is + * not evidence the account cannot run it (`authorizeConnectionModel`). + */ +function firstAccountDefaultModelId(connection: ConnectionCatalogEntry): string | undefined { + const enabled = connection.enabledModelIds; + if (classifyConnectionModelInventory(connection) !== 'live') return enabled[0]; + const live = new Set((connection.models ?? []).map(({ id }) => id)); + return enabled.find((id) => live.has(id)) ?? enabled[0]; +} + export async function setRuntimeHostAccountCredential( client: RuntimeHostAccountConnectionClient & Pick, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index e2ffc31762..5409d245f0 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -496,6 +496,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..537d9d307a 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,18 +17,14 @@ * 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 { INTERACTIVE_OAUTH_CONNECTION_SLUGS } from './oauth-connection-identities.js'; import { - disableRuntimeHostAccountConnection, ensureRuntimeHostAccountConnection, - findRuntimeHostAccountConnection, - runtimeHostAccountCredential, setRuntimeHostAccountCredential, synchronizeRuntimeHostAccountConnection, type RuntimeHostAccountConnectionClient, @@ -36,18 +32,11 @@ import { import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; const PROVIDER = 'github-copilot'; -const CONNECTION_SLUG = 'github-copilot'; +const CONNECTION_SLUG = INTERACTIVE_OAUTH_CONNECTION_SLUGS[PROVIDER]; type GitHubCopilotClient = RuntimeHostAccountConnectionClient & Pick; -interface ImportedGitHubCopilotCredential { - readonly result: - | { readonly ok: true; readonly models: ModelInfo[] } - | Exclude; - readonly secret?: string; -} - export interface RuntimeHostGitHubCopilotIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; readonly client: GitHubCopilotClient; @@ -55,11 +44,19 @@ 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 owns exactly one thing for GitHub Copilot: importing a credential + * that already exists on this machine (`gh` / a compatible PAT). Interactive + * enrollment is not here — the device grant runs through the Host's OAuth + * coordinator like every other account login, so there is one authority that + * serializes starts, owns supersede and cancellation, keeps the Host resident + * while polling, uses the configured network transport, verifies the account + * reaches a Copilot model, and commits the credential atomically. Account + * state, refresh, and sign-out ride the same shared `github-copilot:*` channels + * the coordinator's IPC adapter registers. + */ +export function registerRuntimeHostGitHubCopilotIpc(deps: RuntimeHostGitHubCopilotIpcDeps): void { + const importExistingLogin = deps.importExistingLogin ?? importGitHubCopilotLocalCredential; deps.ipcMain.handle('github-copilot:connect-existing-login', async () => { const imported = await importExistingLogin(); @@ -72,80 +69,15 @@ export function registerRuntimeHostGitHubCopilotIpc( imported.result.models.map(({ id }) => id), ); await setRuntimeHostAccountCredential(deps.client, connection, imported.secret); - await synchronizeRuntimeHostAccountConnection(deps.client, PROVIDER).catch( - () => undefined, - ); + await synchronizeRuntimeHostAccountConnection(deps.client, PROVIDER).catch(() => undefined); 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 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 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..0d80875891 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -23,6 +23,7 @@ import { 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,6 +55,7 @@ const SHARED_OAUTH_IPC_OPERATIONS = [ 'complete-authorization', 'cancel-authorization', 'get-account-state', + 'get-enrollment-state', 'refresh-tokens', 'logout', ] as const; @@ -67,6 +69,7 @@ export const RUNTIME_HOST_OAUTH_IPC_CHANNELS = Object.freeze([ type OAuthClient = RuntimeHostAccountConnectionClient & Pick< DesktopRuntimeHostClient, | 'cancelOAuthLogin' + | 'queryOAuthEnrollment' | 'queryOAuthLogin' | 'startOAuthLogin' >; @@ -145,9 +148,26 @@ 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. The + // authoritative answer is the Host's; the local flag above only fails + // fast before a round trip. + 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 } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1525a4c34e..bbdb80cad2 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1498,6 +1498,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; }; @@ -1520,18 +1521,46 @@ 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; + isExperimentalEnabled(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 5a64dd317a..2ef012cbd0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2853,6 +2853,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); }, @@ -2886,6 +2889,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); }, @@ -2897,18 +2903,36 @@ const makaBridge = { connectExistingLogin(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'github-copilot:connect-existing-login'); }, - getAccountState(host?: DesktopRuntimeHostRef): Promise<{ + isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { + return invokeSelectedRuntimeHost(host, 'github-copilot:is-experimental-enabled'); + }, + 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); + }, + getEnrollmentState(host?: DesktopRuntimeHostRef): Promise<{ enabled: boolean }> { + return invokeSelectedRuntimeHost(host, 'github-copilot:get-enrollment-state'); }, - refreshTokens(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'github-copilot:refresh-tokens'); + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'github-copilot:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'github-copilot:logout'); + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { + return invokeSelectedRuntimeHost(host, 'github-copilot:logout', connectionId); }, }, scheduledTasks: { diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index c2ad504ded..cc8ee53af5 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/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: '全部启用', @@ -224,8 +222,10 @@ const zhCopy = { 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: '移除本地登录', + 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} 尚未登录。`, @@ -282,10 +282,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', @@ -380,8 +378,10 @@ const enCopy: ProviderSettingsCopy = { 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', + 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/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index c3d69a8627..0aa9eb1565 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -56,11 +56,9 @@ import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; import { providerDisplay } from './provider-display'; import { AddModelDialog } from './provider-add-model-dialog'; import { EnabledModelManager } from './provider-enabled-model-manager'; -import { useActionGuard } from './use-action-guard'; import { RuntimeHostSettingsGenerationBoundary, useRuntimeHostSettingsErrorReporter, - useRuntimeHostSettingsTarget, } from './runtime-host-settings-target.js'; import { useOAuthLoginFlow } from './use-oauth-login-flow'; import { @@ -170,7 +168,6 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { supportsApiKey, needsOAuth, retired, - usesGitHubCopilotLogin, oauthLoginService, supportsRemoteDiscovery, credentialProbePending, @@ -408,8 +405,6 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { {needsOAuth && ( retired ? ( - ) : usesGitHubCopilotLogin ? ( - ) : oauthLoginService ? ( ; -}) { - return ( - - - - ); -} - -function GitHubCopilotReloginNoticeForCurrentGeneration(props: { - hasSecret: CredentialPresenceStatus; - onRelogin(): Promise; -}) { - const host = useRuntimeHostSettingsTarget(); - const locale = useUiLocale(); - const copy = getProviderSettingsCopy(locale).detail; - // connectGuard stays: it survives this component's renders and is the - // cross-render "one connect at a time" record. The `busy` state it used to - // mirror is gone — one button, so clickAction's own disable and spinner are - // the whole visible story. - const connectGuard = useActionGuard<'connect'>(); - const mountedRef = useMountedRef(); - const reportHostError = useRuntimeHostSettingsErrorReporter(); - const loggedIn = props.hasSecret === true; - const loading = props.hasSecret === 'loading'; - - async function connect() { - if (!connectGuard.begin('connect')) return; - try { - const result = await window.maka.githubCopilotSubscription.connectExistingLogin(host); - // A same-key Runtime Host replacement remounts this controller through - // the generation boundary above. The old import cannot report into, or - // refresh, the connection detail now owned by the replacement Host. - if (!mountedRef.current) return; - if (!result.ok) { - reportHostError(copy.copilotImportFailed, result.message); - return; - } - await props.onRelogin(); - } catch (error) { - if (mountedRef.current) { - reportHostError( - copy.copilotImportFailed, - providerPanelActionErrorMessage(error, locale), - ); - } - } finally { - connectGuard.finish(); - } - } - - return ( - connect()} label={loggedIn ? copy.reimport : copy.importCredential} /> - ) : undefined} /> - ); -} - // The OAuth notice for a re-loginable connection. The 重新登录 button drives // the SAME shared browser-assisted OAuth flow the catalog cards use, so an // expired connection can be re-authorized right where the problem surfaces. @@ -1135,8 +1066,7 @@ function OAuthReloginNoticeForCurrentGeneration(props: { : errored ? copy.oauthUnknownDetail : copy.oauthStartDetail; - // Codex's device page has no code in its URL — the user must type the - // code shown here, so hiding it makes the re-login impossible to finish. + // Device pages without the code in their URL require the surface to show it. const deviceCode = props.service.showsDeviceCode ? flow.stateHint : null; return ( { - return { - codex: null, - 'github-copilot': null, - xai: null, - }; -} - /** - * Account enrollment rows for the provider catalog. Codex and xAI are pure - * add intents; only the singleton Copilot import retains aggregate state. + * Account enrollment rows for the provider catalog. Every OAuth provider is + * now a connection-scoped add intent, so each row derives its state from the + * Connection catalog rather than from a provider-wide account snapshot. * * This used to be a self-contained `ModelOAuthSection` that rendered both the * rows and a Dialog per service. The rows and the login body are now two @@ -86,13 +81,6 @@ export function useOAuthCards(props: { const locale = useUiLocale(); const copy = getProviderSettingsCopy(locale).oauthSection; const cards = modelOAuthCards(copy); - const mountedRef = useMountedRef(); - const refreshTicketRef = useRef(0); - // Copilot remains a singleton local import, so its catalog row retains live - // account state. Codex and xAI are pure enrollment intents and derive only - // their Connection counts from the catalog. - const [cardStates, setCardStates] = useState(emptyOAuthCardStates); - const [refreshError, setRefreshError] = useState(null); const normalizedQuery = props.query?.trim().toLocaleLowerCase() ?? ''; function matchesQuery(card: { id: string; name: string; description: string }): boolean { @@ -101,86 +89,23 @@ export function useOAuthCards(props: { .some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); } - async function refreshAllCards() { - const ticket = refreshTicketRef.current + 1; - refreshTicketRef.current = ticket; - const results = await Promise.all( - cards.filter((card) => card.id === 'github-copilot').map(async (card) => { - try { - const snapshot = await getSubscriptionSnapshot(card.id, host); - return { id: card.id, snapshot } as const; - } catch (error) { - return { id: card.id, error } as const; - } - }), - ); - if (!mountedRef.current || refreshTicketRef.current !== ticket) return false; - const failures = results.filter((result) => 'error' in result); - setCardStates((prev) => { - const next = { ...prev }; - for (const result of results) { - if ('snapshot' in result && result.snapshot !== undefined) next[result.id] = result.snapshot; - } - return next; - }); - if (failures.length > 0) { - const firstFailure = failures[0]; - const error = firstFailure && 'error' in firstFailure ? firstFailure.error : undefined; - const message = error - ? subscriptionActionErrorMessage(error, locale) - : copy.serviceUnavailable; - // Reported once, in the Banner above the rows. This refresh runs on - // mount, before the user has done anything, so a toast for it would be a - // second report of a failure they did not ask for. - setRefreshError(message); - return false; - } - setRefreshError(null); - return true; - } - - useEffect(() => { - // A same-key Host replacement keeps the catalog mounted to preserve its - // route, query, scroll, and focus. Retire only the account snapshots: the - // outer Settings fence may lift before these independent OAuth reads do, - // so the previous generation must never remain visible as current state. - setCardStates(emptyOAuthCardStates()); - setRefreshError(null); - void refreshAllCards(); - return () => { - refreshTicketRef.current += 1; - }; - }, [generationKey]); - const visibleCards: OAuthCard[] = cards .map((card) => { - const snapshot = cardStates[card.id]; const connectionCount = props.connections.filter( (connection) => connection.providerType === card.providerType, ).length; - const runtimeState = snapshot?.runtimeState ?? 'unknown'; - const isLoggedIn = card.id === 'github-copilot' && ( - runtimeState === 'authenticated' || - runtimeState === 'refreshing' || - runtimeState === 'quota_unavailable' || - runtimeState === 'provider_rejected' - ); + const isLoggedIn = connectionCount > 0; return { id: card.id, providerType: card.providerType, name: card.name, - description: card.id !== 'github-copilot' && connectionCount > 0 - ? copy.configuredConnections(connectionCount) - : isLoggedIn && snapshot?.email - ? snapshot.email - : card.description, - ...(isLoggedIn ? { status: copy.signedIn } : {}), + description: isLoggedIn ? copy.configuredConnections(connectionCount) : card.description, isLoggedIn, }; }) .filter(matchesQuery); - return { cards: visibleCards, refreshError }; + return { cards: visibleCards, refreshError: null as string | null }; } /** @@ -282,61 +207,88 @@ function SubscriptionLoginPanel(props: { ); } -function GitHubCopilotLoginPanel(props: { onLoginSuccess(): void | Promise }) { +function GitHubCopilotLoginPanel(props: { + onLoginSuccess(connection?: OAuthConnectionIdentity): void | Promise; +}) { const host = useRuntimeHostSettingsTarget(); - const copy = getProviderSettingsCopy(useUiLocale()).oauthSection; - // The shared login-flow controller owns the snapshot refresh, the - // synchronous one-shot pending guard, and the unmount safety; Copilot - // rides it through the direct account flow (one bridge call per action, - // no browser handoff, no logout confirm) instead of owning a separate - // pending-action state machine here (#1042). + const locale = useUiLocale(); + const copy = getProviderSettingsCopy(locale).oauthSection; + const reportHostError = useRuntimeHostSettingsErrorReporter(); + const mountedRef = useMountedRef(); + // Copilot now enrolls through the same Host-owned device grant as Codex and + // xAI, so it drives the shared browser-assisted controller rather than a + // Desktop-owned state machine. Importing a credential this machine already + // holds stays available beside it as the secondary route to the same account. const flow = useOAuthLoginFlow({ - mode: 'direct', - accountBridge: { - getAccountState: () => window.maka.githubCopilotSubscription.getAccountState(host), - logout: () => window.maka.githubCopilotSubscription.logout(host), - }, + mode: 'create', + authorizationBridge: runtimeHostOAuthAuthorizationBridge( + window.maka.githubCopilotSubscription, + host, + { kind: 'create' }, + ), display: { name: 'GitHub Copilot', shortName: 'GitHub Copilot' }, onLoginSuccess: props.onLoginSuccess, - direct: { - login: () => window.maka.githubCopilotSubscription.connectExistingLogin(host), - refreshTokens: () => window.maka.githubCopilotSubscription.refreshTokens(host), - }, }); - const refreshTokens = flow.refreshTokens; - const loggedIn = flow.state?.runtimeState === 'authenticated' || flow.state?.runtimeState === 'refreshing'; + const [importing, setImporting] = useState(false); + const actionBusy = flow.actionBusy || importing; + // The Host answers whether Copilot may enrol on this install. Until it does + // (undefined) sign-in stays offered; once it says no, Import becomes the + // primary action — a disabled sign-in is not a working primary button. + const enrollmentDisabled = flow.enrollmentEnabled === false; + + const importLocalCredential = async () => { + if (actionBusy) return; + setImporting(true); + try { + const result = await window.maka.githubCopilotSubscription.connectExistingLogin(host); + if (!mountedRef.current) return; + if (!result.ok) { + reportHostError( + copy.copilotActionFailed, + subscriptionResultMessage(result.message, copy.copilotActionFailed, locale, result.reason), + ); + return; + } + await props.onLoginSuccess(); + } catch (error) { + if (mountedRef.current) { + reportHostError(copy.copilotActionFailed, subscriptionActionErrorMessage(error, locale)); + } + } finally { + if (mountedRef.current) setImporting(false); + } + }; + return ( - - {loggedIn - ? copy.copilotImported - : flow.state?.runtimeState === 'refresh_failed' || flow.state?.runtimeState === 'storage_failed' - ? flow.state.errorMessage - : copy.copilotSetup} - + {copy.copilotSetup} + {flow.authRequestId && ( + + {flow.stateHint ? <>{copy.deviceCode} {flow.stateHint} : copy.waitingAuthorization} + + )} + {flow.errorMessage && } -