Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/desktop/src/main/__tests__/oauth-result-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeHostOAuthIpcDeps['ipcMain']['handle']>[1];
Expand Down Expand Up @@ -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<OAuthClient>): {
readonly client: OAuthClient;
assertNoUnexpectedClientCalls(): void;
Expand Down
51 changes: 27 additions & 24 deletions apps/desktop/src/main/runtime-host-oauth-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 };
}

Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src/main/runtime-host-oauth-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubscriptionActionFailureReason, 'experimental_disabled'>;
type SubscriptionResultCode =
| SubscriptionActionCode
| Extract<SubscriptionActionFailureReason, 'experimental_disabled'>
| 'login_in_progress'
| 'presentation_failed';

type WidenCopy<T> = T extends string
? string
Expand Down Expand Up @@ -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。',
Expand All @@ -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<SubscriptionResultCode, string>,
},
oauthSection: {
Expand Down Expand Up @@ -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。',
Expand All @@ -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} 登入?`,
},
Expand Down Expand Up @@ -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.',
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
}
Loading