diff --git a/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts index 5d10b16b42..193a0fc2a8 100644 --- a/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts +++ b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts @@ -40,6 +40,20 @@ test("renders the typed experimental_disabled reason per locale", () => { assert.equal(subscriptionResultMessage(result, "fallback", "en"), "This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it."); }); +test("renders the typed login_in_progress and presentation_failed reasons per locale", () => { + const conflict = { reason: "login_in_progress", message: "Another OAuth login is already in progress" }; + assert.equal(subscriptionResultMessage(conflict, "fallback", "zh-CN"), "上一轮登录仍在进行,等它结束后再点登录。"); + assert.equal(subscriptionResultMessage(conflict, "fallback", "en"), "A previous login is still running. Start again after it settles."); + const presentation = { reason: "presentation_failed", message: "Desktop has no matching OAuth presentation request" }; + assert.equal(subscriptionResultMessage(presentation, "fallback", "zh-CN"), "无法打开系统浏览器完成登录,请检查是否拦截了弹窗后重试。"); + assert.equal(subscriptionResultMessage(presentation, "fallback", "en"), "Could not open the system browser for login. Check popup blockers and try again."); +}); + +test("presentation prose no longer hijacks the presenter after the regex removal", () => { + const legacy = { message: "Runtime Host did not present OAuth authorization" }; + assert.notEqual(subscriptionResultMessage(legacy, "fallback", "en"), "Could not open the system browser for login. Check popup blockers and try again."); +}); + test("falls back to catalog copy for an unknown code instead of the raw message", () => { const result = { code: "not_a_known_code", message: "内部错误" }; assert.equal(subscriptionResultMessage(result, "fallback", "en"), "fallback"); 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 1f1ef10d2c..20fee6ea2c 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 @@ -430,7 +430,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a await firstPresentationPoll; assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), { ok: false, - reason: 'unknown', + reason: 'login_in_progress', message: 'Another OAuth login is already in progress', }); assert.equal(starts, 1); @@ -697,6 +697,50 @@ test('projects the selected Host answer for whether a provider may enrol', async } }); +test('get-auth-url maps only specific Host failures to typed reasons', async () => { + const cases = [ + { + label: 'generic Host conflict', + thrown: new RuntimeHostOperationError( + 'oauth.login.start', + 'operation_conflict', + 'OAuth Connection capacity is exhausted', + ), + reason: 'unknown', + }, + { + label: 'gated Host', + thrown: new RuntimeHostOperationError( + 'oauth.login.start', + 'operation_unavailable', + 'Enrollment is disabled for this install', + ), + reason: 'experimental_disabled', + }, + ]; + for (const { label, thrown, reason } of cases) { + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides: { + startOAuthLogin: async () => { + throw thrown; + }, + }, + presentation: new RuntimeHostOAuthPresentation(async () => undefined), + emitConnectionListChanged: () => undefined, + }); + assert.deepEqual( + await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), + { + ok: false, + reason, + message: thrown.message, + }, + label, + ); + assertNoUnexpectedClientCalls(); + } +}); + function createFailClosedOAuthClient(overrides: Partial): { readonly client: OAuthClient; assertNoUnexpectedClientCalls(): void; @@ -776,3 +820,43 @@ async function invoke( if (!handler) throw new Error(`Missing IPC handler: ${channel}`); return handler({} as IpcMainInvokeEvent, ...args); } + +test('get-auth-url carries a browser-open rejection through presentation to the IPC result', async () => { + const connectionId = '00000000-0000-4000-8000-000000000016'; + const presentation = new RuntimeHostOAuthPresentation(async () => { + throw new Error('EACCES: xdg-open is not executable'); + }); + let startedAttemptId = ''; + let cancelledAttemptId = ''; + const { handlers, assertNoUnexpectedClientCalls } = registerOAuthTestHandlers({ + clientOverrides: { + startOAuthLogin: async (attemptId) => { + startedAttemptId = attemptId; + void presentation + .openExternal( + 'https://example.test/auth', + 'state-1', + new AbortController().signal, + ) + .catch(() => undefined); + return oauthProjection(attemptId, connectionId, 'awaiting_authorization'); + }, + cancelOAuthLogin: async (attemptId) => { + cancelledAttemptId = attemptId; + return oauthProjection(attemptId, connectionId, 'cancelled'); + }, + }, + presentation, + emitConnectionListChanged: () => undefined, + }); + + const result = await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }); + + assert.deepEqual(result, { + ok: false, + reason: 'presentation_failed', + message: 'Desktop could not open the system browser', + }); + assert.equal(cancelledAttemptId, startedAttemptId); + assertNoUnexpectedClientCalls(); +}); 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 f1e34826f9..9a91007a48 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -22,6 +22,7 @@ import { decodeRuntimePolicyEntityId, type ConnectionCatalogEntry, } from '@maka/core/runtime-policy'; +import type { SubscriptionActionFailureReason } from '@maka/core/oauth-subscription'; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import { OAUTH_LOGIN_PROVIDERS, @@ -41,10 +42,12 @@ import { handleReconnectableRead, type ReconnectableReadIpcMain, } from './ipc-reconnect-policy.js'; -import type { - OAuthExternalPresentation, - OAuthPresentationExpectation, - RuntimeHostOAuthPresentation, +import { + OAuthPresentationError, + type OAuthExternalPresentation, + type OAuthPresentationExpectation, + OAuthLoginInProgressError, + type RuntimeHostOAuthPresentation, } from './runtime-host-oauth-presentation.js'; const OAUTH_POLL_INTERVAL_MS = 250; @@ -140,16 +143,7 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void error instanceof Error && error.message.trim().length > 0 ? error.message : 'Unable to start OAuth authorization'; - // 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', - ); + return actionFailure(detail, oauthStartFailureReason(error)); } }); handleReconnectableRead(deps.ipcMain, channel('get-enrollment-state'), async () => { @@ -443,19 +437,19 @@ async function configuredOAuthAccountConnections( return configured.filter(({ status }) => status?.configured).map(({ connection }) => connection); } -function actionFailure( - message: string, - reason: - | 'authorization_pending' - | 'authorization_cancelled' - | 'authorization_denied' - | 'refresh_failed' - | 'experimental_disabled' - | 'unknown' = 'unknown', -) { +function actionFailure(message: string, reason: SubscriptionActionFailureReason = 'unknown') { return { ok: false as const, reason, message }; } +function oauthStartFailureReason(error: unknown): SubscriptionActionFailureReason { + if (error instanceof OAuthPresentationError) return 'presentation_failed'; + if (error instanceof OAuthLoginInProgressError) return 'login_in_progress'; + if (error instanceof RuntimeHostOperationError && error.code === 'operation_unavailable') { + return 'experimental_disabled'; + } + return 'unknown'; +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/apps/desktop/src/main/runtime-host-oauth-presentation.ts b/apps/desktop/src/main/runtime-host-oauth-presentation.ts index 55f4e726a2..c63b92e1b3 100644 --- a/apps/desktop/src/main/runtime-host-oauth-presentation.ts +++ b/apps/desktop/src/main/runtime-host-oauth-presentation.ts @@ -31,6 +31,15 @@ export interface OAuthPresentationExpectation { cancel(reason?: unknown): void; } +/** Never crosses the Host protocol: raised and consumed inside the main process. */ +export class OAuthPresentationError extends Error { + name = 'OAuthPresentationError'; +} + +export class OAuthLoginInProgressError extends Error { + name = 'OAuthLoginInProgressError'; +} + /** Bridges a Host-owned OAuth attempt to Desktop-owned system-browser presentation. */ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { #pending: PendingPresentation | undefined; @@ -38,7 +47,7 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { constructor(private readonly openSystemBrowser: (url: string) => Promise) {} expect(attemptId: string, expectedStateHint?: string): OAuthPresentationExpectation { - if (this.#pending) throw new Error('Another OAuth login is already in progress'); + if (this.#pending) throw new OAuthLoginInProgressError('Another OAuth login is already in progress'); let resolvePresented!: (presentation: OAuthExternalPresentation) => void; let rejectPresented!: (reason?: unknown) => void; let presentedSettled = false; @@ -52,7 +61,7 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { const expire = () => { if (this.#pending !== pending) return; this.#pending = undefined; - rejectPresented(new Error('Runtime Host did not present OAuth authorization')); + rejectPresented(new OAuthPresentationError('Runtime Host did not present OAuth authorization')); }; let timer = setTimeout(expire, PRESENTATION_TIMEOUT_MS); const pending: PendingPresentation = { @@ -92,18 +101,25 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { signal.throwIfAborted(); const pending = this.#pending; if (!pending || !stateHint) { - throw new Error('Desktop has no matching OAuth presentation request'); + throw new OAuthPresentationError('Desktop has no matching OAuth presentation request'); } if (pending.expectedStateHint !== undefined && pending.expectedStateHint !== stateHint) { - throw new Error('Desktop OAuth presentation belongs to another attempt'); + throw new OAuthPresentationError('Desktop OAuth presentation belongs to another attempt'); } + let opened = false; try { await this.openSystemBrowser(url); + opened = true; signal.throwIfAborted(); pending.resolve({ stateHint }); } catch (error) { - pending.reject(error); - throw error; + // A browser that will not open is a Desktop-owned presentation failure; + // anything after it opened (an abort) keeps its own shape. + const failure = opened + ? error + : new OAuthPresentationError('Desktop could not open the system browser'); + pending.reject(failure); + throw failure; } } 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 e6d081b622..048240f710 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 @@ -21,7 +21,12 @@ import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/reda import type { SubscriptionActionCode, SubscriptionActionFailureReason } from '@maka/core/oauth-subscription'; import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; -type SubscriptionResultCode = SubscriptionActionCode | Extract; +type SubscriptionResultCode = + | SubscriptionActionCode + | Extract< + SubscriptionActionFailureReason, + 'experimental_disabled' | 'login_in_progress' | 'presentation_failed' + >; type WidenCopy = T extends string ? string @@ -250,8 +255,6 @@ const zhCopy = { loggedOut: '已退出登录', credentialsCleared: '本地凭据已清除。', logoutFailed: '退出失败', logoutFailedRetry: '退出登录失败,请稍后重试。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', logoutTitle: (name: string) => `退出 ${name} 登录?`, - loginConflict: '上一轮浏览器登录仍在进行或已切换,请再点一次登录,或稍后再试。', - browserPresentFailed: '无法打开系统浏览器完成登录,请检查是否拦截了弹窗后重试。', resultCodes: { copilot_classic_pat_unsupported: 'GitHub Copilot 不支持 classic PAT;请使用兼容 OAuth 登录或具有 Copilot Requests 权限的 fine-grained PAT。', copilot_credential_type_unsupported: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', @@ -263,6 +266,8 @@ const zhCopy = { copilot_subscription_check_failed: '暂时无法验证 GitHub Copilot 订阅状态,请稍后重试。', copilot_import_commit_failed: 'GitHub Copilot 登录未能写入 Runtime Host。', experimental_disabled: '本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。', + login_in_progress: '上一轮登录仍在进行,等它结束后再点登录。', + presentation_failed: '无法打开系统浏览器完成登录,请检查是否拦截了弹窗后重试。', } satisfies Record, }, oauthSection: { @@ -429,8 +434,6 @@ const zhTwCopy = { logoutDescription: '將刪除本機儲存的訂閱憑據,之後需要重新登入才能繼續使用這些 OAuth 模型。', logout: '退出登入', cancel: '取消', loggedOut: '已退出登入', credentialsCleared: '本地憑據已清除。', logoutFailed: '退出失敗', logoutFailedRetry: '退出登入失敗,請稍後重試。', serviceUnavailable: '登入服務暫時不可用,請檢查網路後重試。', - loginConflict: '上一輪瀏覽器登入仍在進行或已切換,請再按一次登入,或稍後再試。', - browserPresentFailed: '無法開啟系統瀏覽器完成登入,請檢查是否封鎖了彈出式視窗後再試。', resultCodes: { copilot_classic_pat_unsupported: 'GitHub Copilot 不支援 classic PAT;請使用相容 OAuth 登入或具有 Copilot Requests 權限的 fine-grained PAT。', copilot_credential_type_unsupported: '目前的 GitHub 憑據類型不受支援;請使用相容 OAuth 登入或 fine-grained PAT。', @@ -442,6 +445,8 @@ const zhTwCopy = { copilot_subscription_check_failed: '暫時無法驗證 GitHub Copilot 訂閱狀態,請稍後重試。', copilot_import_commit_failed: 'GitHub Copilot 登入未能寫入 Runtime Host。', experimental_disabled: '本機未啟用該帳號登入方式;可改用匯入相容憑據,或由管理員啟用後重試。', + login_in_progress: '上一輪登入仍在進行,等它結束後再按登入。', + presentation_failed: '無法開啟系統瀏覽器完成登入,請檢查是否封鎖了彈出式視窗後再試。', }, logoutTitle: (name: string) => `退出 ${name} 登入?`, }, @@ -611,8 +616,6 @@ const enCopy: ProviderSettingsCopy = { loggedOut: 'Signed out', credentialsCleared: 'Local credentials cleared.', logoutFailed: 'Sign-out failed', logoutFailedRetry: 'Sign-out failed. Try again later.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', logoutTitle: (name: string) => `Sign out of ${name}?`, - loginConflict: 'A previous browser login is still running or was superseded. Try logging in again shortly.', - browserPresentFailed: 'Could not open the system browser for login. Check popup blockers and try again.', resultCodes: { copilot_classic_pat_unsupported: 'GitHub Copilot does not accept classic PATs. Use a compatible OAuth login or a fine-grained PAT with the Copilot Requests permission.', copilot_credential_type_unsupported: 'This GitHub credential type is not supported. Use a compatible OAuth login or a fine-grained PAT.', @@ -624,6 +627,8 @@ const enCopy: ProviderSettingsCopy = { copilot_subscription_check_failed: 'Could not verify the GitHub Copilot subscription right now. Try again later.', copilot_import_commit_failed: 'The GitHub Copilot login could not be committed to Runtime Host.', experimental_disabled: 'This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it.', + login_in_progress: 'A previous login is still running. Start again after it settles.', + presentation_failed: 'Could not open the system browser for login. Check popup blockers and try again.', }, }, oauthSection: { @@ -679,11 +684,10 @@ export function subscriptionResultMessage(input: SubscriptionResultInput, fallba if (mapped) return mapped; const raw = redactSecrets(message ?? '').trim(); if (!raw) return fallback; - // Stable Host messages, matched before the coarse keyword classifier turns - // "authorization" into a generic auth failure that does not tell the user what to do. + // Prose fallback for Hosts older than the typed reasons; every Desktop-owned + // producer now sends a code, so nothing local depends on this wording. if (/enrollment is disabled for this provider/i.test(raw)) return copy.resultCodes.experimental_disabled; - if (/already in progress|superseded by a new attempt/i.test(raw)) return copy.loginConflict; - if (/did not present OAuth|no matching OAuth presentation/i.test(raw)) return copy.browserPresentFailed; + if (/already in progress/i.test(raw)) return copy.resultCodes.login_in_progress; const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); return classified || fallback; } diff --git a/packages/core/src/oauth-subscription.ts b/packages/core/src/oauth-subscription.ts index 1675882e30..babac840b1 100644 --- a/packages/core/src/oauth-subscription.ts +++ b/packages/core/src/oauth-subscription.ts @@ -63,6 +63,8 @@ export type SubscriptionActionFailureReason = | 'token_exchange_failed' // /oauth/token returned non-200 | 'refresh_failed' // refresh attempt errored | 'storage_failed' // shared credential store write failed + | 'login_in_progress' + | 'presentation_failed' // PR-OAUTH-SUBSCRIPTION-0 (kenji `45b31e16`): the experimental // env flag is OFF. Distinct from `provider_rejected` so the user // doesn't think Anthropic rejected their account — this is