From c9118be026e971b97d9f900c04b28537d2280238 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Tue, 8 Sep 2026 12:48:42 -0700 Subject: [PATCH] fix(desktop): carry typed OAuth failure reasons from producers get-auth-url maps the Host's operation_conflict to a typed login_in_progress reason and Desktop-owned presentation failures to presentation_failed, so the renderer stops matching English prose. The did-not-present / no-matching-request regex goes away; the enrollment-disabled and already-in-progress fallbacks stay for Host versions that predate the typed codes. Generated-by: Claude Code --- .../main/__tests__/oauth-result-copy.test.ts | 14 +++++ .../runtime-host-oauth-ipc-main.test.ts | 54 ++++++++++++++++++- .../src/main/runtime-host-oauth-ipc-main.ts | 51 +++++++++--------- .../main/runtime-host-oauth-presentation.ts | 9 +++- .../settings-provider-copy.ts | 25 +++++---- 5 files changed, 115 insertions(+), 38 deletions(-) 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..2e29bf25ec 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 @@ -32,7 +32,10 @@ import { registerRuntimeHostOAuthIpc, type RuntimeHostOAuthIpcDeps, } from '../runtime-host-oauth-ipc-main.js'; -import { RuntimeHostOAuthPresentation } from '../runtime-host-oauth-presentation.js'; +import { + OAuthPresentationError, + RuntimeHostOAuthPresentation, +} from '../runtime-host-oauth-presentation.js'; type OAuthClient = RuntimeHostOAuthIpcDeps['client']; type OAuthIpcHandler = Parameters[1]; @@ -697,6 +700,55 @@ test('projects the selected Host answer for whether a provider may enrol', async } }); +test('get-auth-url maps Host and presentation failures to typed reasons', async () => { + const cases = [ + { + label: 'presentation failure', + thrown: new OAuthPresentationError('Runtime Host did not present OAuth authorization'), + reason: 'presentation_failed', + }, + { + label: 'busy Host', + thrown: new RuntimeHostOperationError( + 'oauth.login.start', + 'operation_conflict', + 'Another OAuth login is already in progress', + ), + reason: 'login_in_progress', + }, + { + 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; 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..c5dde63bb4 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -41,12 +41,23 @@ import { handleReconnectableRead, type ReconnectableReadIpcMain, } from './ipc-reconnect-policy.js'; -import type { - OAuthExternalPresentation, - OAuthPresentationExpectation, - RuntimeHostOAuthPresentation, +import { + OAuthPresentationError, + type OAuthExternalPresentation, + type OAuthPresentationExpectation, + type RuntimeHostOAuthPresentation, } from './runtime-host-oauth-presentation.js'; +type DesktopOAuthActionFailureReason = + | 'authorization_pending' + | 'authorization_cancelled' + | 'authorization_denied' + | 'refresh_failed' + | 'experimental_disabled' + | 'login_in_progress' + | 'presentation_failed' + | 'unknown'; + const OAUTH_POLL_INTERVAL_MS = 250; const SHARED_OAUTH_IPC_OPERATIONS = [ 'get-auth-url', @@ -140,16 +151,17 @@ 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', - ); + // `unknown` keeps the raw detail visible to the renderer's message fallback. + let reason: DesktopOAuthActionFailureReason = 'unknown'; + if (error instanceof OAuthPresentationError) { + reason = 'presentation_failed'; + } else if (error instanceof RuntimeHostOperationError && error.code === 'operation_unavailable') { + // A remote Host may gate enrollment differently from this Desktop process. + reason = 'experimental_disabled'; + } else if (error instanceof RuntimeHostOperationError && error.code === 'operation_conflict') { + reason = 'login_in_progress'; + } + return actionFailure(detail, reason); } }); handleReconnectableRead(deps.ipcMain, channel('get-enrollment-state'), async () => { @@ -443,16 +455,7 @@ 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: DesktopOAuthActionFailureReason = 'unknown') { return { ok: false as const, reason, message }; } diff --git a/apps/desktop/src/main/runtime-host-oauth-presentation.ts b/apps/desktop/src/main/runtime-host-oauth-presentation.ts index 779e8e3a0e..5720fdc21e 100644 --- a/apps/desktop/src/main/runtime-host-oauth-presentation.ts +++ b/apps/desktop/src/main/runtime-host-oauth-presentation.ts @@ -30,6 +30,11 @@ 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'; +} + /** Bridges a Host-owned OAuth attempt to Desktop-owned system-browser presentation. */ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { #pending: PendingPresentation | undefined; @@ -51,7 +56,7 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { const timer = setTimeout(() => { if (this.#pending?.attemptId !== attemptId) return; this.#pending = undefined; - rejectPresented(new Error('Runtime Host did not present OAuth authorization')); + rejectPresented(new OAuthPresentationError('Runtime Host did not present OAuth authorization')); }, PRESENTATION_TIMEOUT_MS); const pending: PendingPresentation = { attemptId, @@ -84,7 +89,7 @@ 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'); } try { await this.openSystemBrowser(url); 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 13b6193d02..6c2e960e48 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,11 @@ 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 + | 'login_in_progress' + | 'presentation_failed'; type WidenCopy = T extends string ? string @@ -249,8 +253,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。', @@ -262,6 +264,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: { @@ -427,8 +431,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。', @@ -440,6 +442,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} 登入?`, }, @@ -608,8 +612,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.', @@ -621,6 +623,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: { @@ -676,11 +680,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 producers with no typed reason (old Hosts, and + // presentation.expect's own conflict): rewording these strings breaks the branch. 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; }