diff --git a/apps/desktop/src/main/__tests__/command-palette-retired.test.ts b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts new file mode 100644 index 0000000000..41107921f9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { buildCommandList } from '../../renderer/command-palette-commands.js'; + +function connection(overrides: Partial = {}): LlmConnection { + return { + slug: 'openai-live', + name: 'OpenAI Live', + providerType: 'openai', + defaultModel: 'gpt-4.1', + enabled: true, + models: [{ id: 'gpt-4.1' }], + modelSource: 'fetched', + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +// A retained retired row is still enabled: retirement keeps the connection so +// the credential stays visible and deletable, and nothing flips its flag. +const retired = connection({ + slug: 'claude-subscription', + name: 'Claude Subscription', + providerType: 'claude-subscription', + defaultModel: 'claude-opus-5', + models: [{ id: 'claude-opus-5' }], +}); + +function commandIds(connections: LlmConnection[], defaultSlug: string | null): string[] { + return buildCommandList({ + locale: 'en', + activeSessionId: undefined, + themePref: 'auto', + connections, + defaultSlug, + onNewChat: () => {}, + onOpenSettings: () => {}, + onOpenSettingsSection: () => {}, + onOpenShortcuts: () => {}, + onSetTheme: () => {}, + onTestConnection: () => {}, + onSetDefaultConnection: () => {}, + }).map((command) => command.id); +} + +test('a retained retired connection gets no palette commands', () => { + // Settings hides "set as default" and "test connection" for a retired row, + // but the palette used to filter on `enabled` alone — offering commands the + // storage default-target gate and the hidden auth actions then refuse. + const live = connection({ slug: 'anthropic-live', name: 'Anthropic Live' }); + const ids = commandIds([connection(), live, retired], 'openai-live'); + // Positive control: a live non-default connection keeps both commands, so an + // over-broad filter cannot pass this test by dropping everything. + assert.ok(ids.includes(`connection:set-default:${live.slug}`)); + assert.ok(ids.includes(`connection:test:${live.slug}`)); + assert.ok(!ids.includes(`connection:set-default:${retired.slug}`)); + assert.ok(!ids.includes(`connection:test:${retired.slug}`)); +}); + +test('a stale in-memory default pointing at a retired connection is not testable', () => { + // The catalog releases such a default on load; this covers the window where + // the renderer still holds the old slug. + const ids = commandIds([retired, connection()], retired.slug); + assert.ok(!ids.includes('diag:test-default')); +}); diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index d02308a327..e04aa1d316 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -141,4 +141,55 @@ describe('config-transfer-service', () => { assert.deepEqual(setCreds, [{ slug: 'deepseek-main', kind: 'api_key', value: 'sk-new' }]); assert.deepEqual(result.credentials, { applied: 1, skipped: 0 }); }); + it('restores the whole bundle when it carries a retained retired connection', async () => { + // A backup taken before the retirement still lists the connection, and the + // catalog refuses to create one. Before this was planned as skipped, the + // refusal threw mid-import: a fresh profile got whichever connections + // happened to be saved first and no settings, credentials, or memory at + // all. The live connection is ordered first here on purpose, so a restored + // abort would look like a partial success rather than a clean failure. + const { deps, saved, setCreds, writtenMemory, updatedSettings } = makeDeps({ + connectionStore: { + list: async () => [], + save: async (c) => { + if (c.providerType === 'claude-subscription') { + throw new Error('"claude-subscription" is retired and cannot be added'); + } + saved.push(c); + return c; + }, + }, + }); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['connections', 'settings', 'credentials', 'memory'] as const, + data: { + connections: [ + conn('deepseek-main'), + { ...conn('claude-subscription'), providerType: 'claude-subscription' }, + ], + settings: { theme: 'light' }, + credentials: [ + { slug: 'deepseek-main', kind: 'api_key', value: 'sk-live' }, + { slug: 'claude-subscription', kind: 'oauth_token', value: 'retired-secret' }, + ], + memory: '# imported memory', + }, + }; + + const result = await applyConfigImport(bundle as any, 'skip', deps); + + assert.deepEqual(result.connections, { created: 1, overwritten: 0, skipped: 1 }); + assert.deepEqual(saved.map((c) => c.slug), ['deepseek-main']); + // The rest of the bundle still lands — the point of the whole fix. + assert.equal(result.settings?.applied, true); + assert.equal(updatedSettings.length, 1); + assert.deepEqual(writtenMemory, ['# imported memory']); + // The retired connection's secret is skipped with it: only a created or + // overwritten slug gets one written. + assert.deepEqual(setCreds, [{ slug: 'deepseek-main', kind: 'api_key', value: 'sk-live' }]); + assert.deepEqual(result.credentials, { applied: 1, skipped: 1 }); + }); }); diff --git a/apps/desktop/src/main/__tests__/provider-connection-status.test.ts b/apps/desktop/src/main/__tests__/provider-connection-status.test.ts new file mode 100644 index 0000000000..6bdbe55cae --- /dev/null +++ b/apps/desktop/src/main/__tests__/provider-connection-status.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { connectionChipStatus } from '../../renderer/settings/provider-connection-status.js'; + +function connection(overrides: Partial = {}): LlmConnection { + return { + slug: 'openai-live', + name: 'OpenAI Live', + providerType: 'openai', + defaultModel: 'gpt-4.1', + enabled: true, + models: [{ id: 'gpt-4.1' }], + modelSource: 'fetched', + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +const retired = connection({ + slug: 'claude-subscription', + name: 'Claude Subscription', + providerType: 'claude-subscription', + defaultModel: 'claude-opus-5', + models: [{ id: 'claude-opus-5' }], +}); + +test('a retired connection reads as broken rather than repairable', () => { + // Nothing else in the list marks this row, so without a status the only + // signal that it has to go is on the detail page the user has no reason to + // open. + assert.deepEqual(connectionChipStatus(retired, 'zh'), { + label: '已停用 · 请删除', + tone: 'error', + }); + assert.deepEqual(connectionChipStatus(retired, 'en'), { + label: 'Retired · delete it', + tone: 'error', + }); +}); + +test('retirement outranks every repairable state', () => { + // Each of these would otherwise render a "sign in again" or "it failed, try + // again" status, and for a retired provider both point at nothing. + for (const overrides of [ + { lastTestStatus: 'needs_reauth' as const }, + { lastTestStatus: 'error' as const }, + { lastTestStatus: 'verified' as const }, + { enabled: false }, + ]) { + assert.deepEqual( + connectionChipStatus({ ...retired, ...overrides }, 'zh'), + { label: '已停用 · 请删除', tone: 'error' }, + `retirement must win over ${JSON.stringify(overrides)}`, + ); + } +}); + +test('a live connection keeps its existing statuses', () => { + assert.equal(connectionChipStatus(connection({ lastTestStatus: 'verified' }), 'zh'), null); + assert.deepEqual(connectionChipStatus(connection({ lastTestStatus: 'needs_reauth' }), 'zh'), { + label: '需要重新登录', + tone: 'attention', + }); + assert.deepEqual(connectionChipStatus(connection({ enabled: false }), 'zh'), { + label: '暂不可用', + tone: 'neutral', + }); +}); 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 1f5166d83f..15a80b8196 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 @@ -18,12 +18,12 @@ function catalogWithoutDefault(): ConnectionCatalogSnapshot { { connectionId: CONNECTION_ID, revision: 2, - slug: 'claude-subscription', - name: 'Claude OAuth', - providerType: 'claude-subscription', + slug: 'codex-subscription', + name: 'Codex OAuth', + providerType: 'openai-codex', enabled: true, - enabledModelIds: ['claude-opus-5', 'claude-haiku-4-5'], - models: [{ id: 'claude-opus-5' }, { id: 'claude-haiku-4-5' }], + enabledModelIds: ['gpt-5-codex', 'gpt-5-codex-mini'], + models: [{ id: 'gpt-5-codex' }, { id: 'gpt-5-codex-mini' }], modelSource: 'fallback', modelsFetchedAt: 0, }, @@ -70,9 +70,9 @@ describe('synchronizeRuntimeHostAccountConnection', () => { }); const { client, selected } = accountClient(rejected); - await synchronizeRuntimeHostAccountConnection(client, 'claude-subscription'); + await synchronizeRuntimeHostAccountConnection(client, 'openai-codex'); - assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'claude-opus-5' }); + assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'gpt-5-codex' }); }); it('selects a default model when model discovery throws', async () => { @@ -81,9 +81,9 @@ describe('synchronizeRuntimeHostAccountConnection', () => { }; const { client, selected } = accountClient(throwing); - await synchronizeRuntimeHostAccountConnection(client, 'claude-subscription'); + await synchronizeRuntimeHostAccountConnection(client, 'openai-codex'); - assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'claude-opus-5' }); + assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'gpt-5-codex' }); }); it('leaves an existing default alone', async () => { @@ -93,10 +93,10 @@ describe('synchronizeRuntimeHostAccountConnection', () => { }); const { client, selectCalls } = accountClient(rejected, { ...catalogWithoutDefault(), - defaultTarget: { connectionId: CONNECTION_ID, modelId: 'claude-haiku-4-5' }, + defaultTarget: { connectionId: CONNECTION_ID, modelId: 'gpt-5-codex-mini' }, }); - await synchronizeRuntimeHostAccountConnection(client, 'claude-subscription'); + await synchronizeRuntimeHostAccountConnection(client, 'openai-codex'); assert.equal(selectCalls(), 0); }); 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 855cada63a..ebeca67399 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 @@ -45,7 +45,7 @@ test('presents both Host OAuth methods without exposing the authorization URL', }); test('adapts every Host OAuth provider through one Desktop flow', async () => { - const provider = 'claude-subscription' as const; + const provider = 'openai-codex' as const; const handlers = new Map< string, Parameters[1] @@ -67,8 +67,8 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { { connectionId: '00000000-0000-4000-8000-000000000001', revision: 1, - slug: 'claude-subscription', - name: 'Claude Code', + slug: 'openai-codex', + name: 'OpenAI Codex', providerType: provider, enabled: true, enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], @@ -86,14 +86,16 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { }, startOAuthLogin: async (nextAttemptId, connectionId) => { attemptId = nextAttemptId; + // Codex device login presents with `open_external`; the paste-code + // presentation this fixture used has no producer, so asserting it proved + // the desktop bridge against a flow no provider takes. void presentation - .requestAuthorizationCode( - 'https://claude.example/authorize', + .openExternal( + 'https://codex.example/authorize', 'STATE-HINT', new AbortController().signal, ) - .then((authorizationCode) => { - assert.equal(authorizationCode, 'authorization-code#state'); + .then(() => { phase = 'authenticated'; }); return oauthProjection(nextAttemptId, connectionId, 'awaiting_authorization'); @@ -136,14 +138,6 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { fetchedAt: 1, }; }, - fetchOAuthAccountUsage: async () => ({ - kind: 'available' as const, - provider, - quota: { - fiveHour: { utilization: 20, resetsAt: '2026-08-05T12:00:00.000Z' }, - fetchedAt: 1, - }, - }), setDefaultConnectionTarget: async (expectedCatalogRevision, target) => { assert.equal(expectedCatalogRevision, catalog.revision); catalog = { ...catalog, revision: catalog.revision + 1, defaultTarget: target }; @@ -188,19 +182,19 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { assert.deepEqual([...handlers.keys()].sort(), [...RUNTIME_HOST_OAUTH_IPC_CHANNELS].sort()); - for (const prefix of ['claude-subscription', 'openai-codex', 'xai-oauth']) { + for (const prefix of ['openai-codex', 'xai-oauth']) { assert.equal(handlers.has(`${prefix}:get-auth-url`), true); assert.equal(handlers.has(`${prefix}:complete-authorization`), true); assert.equal(handlers.has(`${prefix}:get-account-state`), true); assert.equal(handlers.has(`${prefix}:logout`), true); } - const authorization = await invoke(handlers, 'claude-subscription:get-auth-url'); + const authorization = await invoke(handlers, 'openai-codex:get-auth-url'); assert.deepEqual(authorization, { authRequestId: attemptId, stateHint: 'STATE-HINT' }); - assert.deepEqual(opened, ['https://claude.example/authorize']); + assert.deepEqual(opened, ['https://codex.example/authorize']); assert.deepEqual( await invoke( handlers, - 'claude-subscription:complete-authorization', + 'openai-codex:complete-authorization', attemptId, 'authorization-code#state', ), @@ -211,16 +205,11 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { connectionId: catalog.connections[0]?.connectionId, modelId, }); - assert.deepEqual(await invoke(handlers, 'claude-subscription:refresh-quota'), { - ok: true, - }); - assert.deepEqual(await invoke(handlers, 'claude-subscription:get-account-state'), { + // No quota: reporting it required the retired provider's own client identity, + // so the account state carries the runtime state alone. + assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), { provider, runtimeState: 'authenticated', - quota: { - fiveHour: { utilization: 20, resetsAt: '2026-08-05T12:00:00.000Z' }, - fetchedAt: 1, - }, }); }); @@ -285,10 +274,6 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn fetchConnectionModels: async () => { throw new Error('provider temporarily unavailable'); }, - fetchOAuthAccountUsage: async () => ({ - kind: 'unavailable' as const, - reason: 'provider_unavailable' as const, - }), setDefaultConnectionTarget: async () => { throw new Error('Default selection must not run after failed discovery'); }, @@ -327,7 +312,7 @@ function oauthProjection( return { attemptId, connectionId, - provider: 'claude-subscription' as const, + provider: 'openai-codex' as const, phase, }; } diff --git a/apps/desktop/src/main/__tests__/stale-sessions.test.ts b/apps/desktop/src/main/__tests__/stale-sessions.test.ts index 9e5a5e1017..e17a968fc0 100644 --- a/apps/desktop/src/main/__tests__/stale-sessions.test.ts +++ b/apps/desktop/src/main/__tests__/stale-sessions.test.ts @@ -38,6 +38,26 @@ test('derives stale rows from each Session Host readiness projection', () => { ); }); +test('a task bound to a retired provider is stale rather than quietly unusable', () => { + // Nothing else about the row looks wrong: the connection still exists and is + // still enabled, so without this the task reads as healthy until it is + // opened. `connection_disabled` stays out because the switch that repairs it + // is one the user can find; a retirement has no such switch. + assert.deepEqual( + [ + ...deriveStaleSessionIds({ + sessions: [{ id: 'retired' }, { id: 'disabled' }, { id: 'healthy' }], + sendOutcomes: { + retired: { kind: 'blocked', reason: 'provider_retired', connectionLocked: false }, + disabled: { kind: 'blocked', reason: 'connection_disabled', connectionLocked: false }, + healthy: { kind: 'ready' }, + }, + }), + ], + ['retired'], + ); +}); + test('a row whose readiness has not arrived yet is not called stale', () => { assert.deepEqual([...deriveStaleSessionIds({ sessions: [{ id: 'unknown' }], sendOutcomes: {} })], []); }); diff --git a/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts b/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts new file mode 100644 index 0000000000..08e2028b27 --- /dev/null +++ b/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import type { SubagentPreset } from '@maka/core/subagent-settings'; +import { + isSelectableSubagentConnection, + subagentPresetAvailability, +} from '../../renderer/settings/subagent-preset-presentation.js'; + +const preset: SubagentPreset = { + id: 'worker', + name: 'Worker', + description: '', + profile: 'local_read', + connectionSlug: 'claude-subscription', + model: 'claude-opus-5', + enabled: true, +}; + +function connection(overrides: Partial = {}): LlmConnection { + return { + slug: 'claude-subscription', + name: 'Claude Subscription', + providerType: 'claude-subscription', + defaultModel: 'claude-opus-5', + enabledModelIds: ['claude-opus-5'], + enabled: true, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +test('a preset routed through a retained retired connection reads retired, not available', () => { + // The retained row stays enabled, so the enabled/model checks alone would + // badge this preset 可用 while every run through it is refused. + assert.deepEqual(subagentPresetAvailability(preset, [connection()]), { + kind: 'provider_retired', + tone: 'destructive', + }); +}); + +test('retirement outranks disabled: there is no switch that repairs it', () => { + assert.deepEqual(subagentPresetAvailability(preset, [connection({ enabled: false })]), { + kind: 'provider_retired', + tone: 'destructive', + }); +}); + +test('the editor cannot route a preset through a retained retired connection', () => { + // The list badge alone was not enough: the editor's usableConnections / + // validConnection / connectionOptions all consumed `enabled`, so a retained + // retired row could still be selected and saved into a preset the runtime + // admission then refuses as provider_retired. + assert.equal(isSelectableSubagentConnection(connection()), false); + assert.equal(isSelectableSubagentConnection(connection({ enabled: false })), false); + assert.equal( + isSelectableSubagentConnection(connection({ slug: 'openai', providerType: 'openai' })), + true, + ); + assert.equal( + isSelectableSubagentConnection( + connection({ slug: 'openai', providerType: 'openai', enabled: false }), + ), + false, + ); +}); + +test('a live connection with the model enabled stays available', () => { + const live = connection({ + slug: 'openai', + providerType: 'openai', + enabledModelIds: ['gpt-5-mini'], + }); + assert.deepEqual( + subagentPresetAvailability({ ...preset, connectionSlug: 'openai', model: 'gpt-5-mini' }, [ + live, + ]), + { kind: 'available', tone: 'success' }, + ); +}); diff --git a/apps/desktop/src/main/oauth-connection-identities.ts b/apps/desktop/src/main/oauth-connection-identities.ts index fed4bbfb76..e8c88a6470 100644 --- a/apps/desktop/src/main/oauth-connection-identities.ts +++ b/apps/desktop/src/main/oauth-connection-identities.ts @@ -2,7 +2,6 @@ import type { OAuthLoginProvider } from '@maka/runtime-host/protocol'; /** Stable Desktop connection identities for Host-supported interactive OAuth providers. */ export const INTERACTIVE_OAUTH_CONNECTION_SLUGS = { - 'claude-subscription': 'claude-subscription', 'openai-codex': 'codex-subscription', 'xai-oauth': 'xai-oauth', } as const satisfies Readonly>; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index f980b12366..ae66a200b3 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -404,12 +404,6 @@ export class DesktopRuntimeHostClient { return this.request("oauth.login.cancel", { attemptId }); } - fetchOAuthAccountUsage( - connectionId: string, - ): Promise> { - return this.request("oauth.account.usage.fetch", { connectionId }); - } - async loadSkillCatalog( context: SkillCatalogWorkspaceContext, view: SkillCatalogView, 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 7aa94b437d..fac43aef03 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import type { QuotaSnapshot } from '@maka/core/oauth-subscription'; import { isOAuthEnrollmentProviderEnabled } from '@maka/runtime/oauth-provider-contracts'; import { OAUTH_LOGIN_PROVIDERS, @@ -39,14 +38,12 @@ export const RUNTIME_HOST_OAUTH_IPC_CHANNELS = Object.freeze([ ...OAUTH_LOGIN_PROVIDERS.flatMap((provider) => [ ...(provider === 'xai-oauth' ? [] : [`${provider}:is-experimental-enabled`]), ...SHARED_OAUTH_IPC_OPERATIONS.map((operation) => `${provider}:${operation}`), - ...(provider === 'claude-subscription' ? [`${provider}:refresh-quota`] : []), ]), ]); type OAuthClient = RuntimeHostAccountConnectionClient & Pick< DesktopRuntimeHostClient, | 'cancelOAuthLogin' - | 'fetchOAuthAccountUsage' | 'queryOAuthLogin' | 'startOAuthLogin' >; @@ -67,7 +64,6 @@ interface ActiveOAuthAttempt { /** Adapts the existing Desktop OAuth UI to the Host's provider-neutral OAuth operations. */ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void { const activeAttempts = new Map(); - const accountUsage = new Map(); const providerEnabled = deps.isProviderEnabled ?? isOAuthEnrollmentProviderEnabled; for (const provider of OAUTH_LOGIN_PROVIDERS) { @@ -168,7 +164,7 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void runtimeHostAccountCredential(connection), ); if (credential?.configured) { - return accountState(provider, 'authenticated', accountUsage.get(provider)); + return accountState(provider, 'authenticated'); } const authorizing = [...activeAttempts.values()].some( (attempt) => attempt.provider === provider, @@ -192,21 +188,6 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void ? { ok: true as const } : actionFailure('Unable to refresh OAuth account', 'refresh_failed'); }); - if (provider === 'claude-subscription') { - deps.ipcMain.handle(channel('refresh-quota'), async () => { - const connection = findRuntimeHostAccountConnection( - await deps.client.loadConnectionCatalog(), - provider, - ); - if (!connection) return actionFailure('OAuth account is not connected'); - const result = await deps.client.fetchOAuthAccountUsage(connection.connectionId); - if (result.kind !== 'available') { - return actionFailure(`OAuth account usage is unavailable: ${result.reason}`); - } - accountUsage.set(provider, result.quota); - return { ok: true as const }; - }); - } deps.ipcMain.handle(channel('logout'), async () => { await cancelProviderAttempts(deps, activeAttempts, provider); try { @@ -214,7 +195,6 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void } catch { return actionFailure('Unable to remove OAuth account'); } - accountUsage.delete(provider); deps.emitConnectionListChanged(); return { ok: true as const }; }); @@ -304,9 +284,8 @@ function isProviderAttempt( function accountState( provider: OAuthLoginProvider, runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated', - quota?: QuotaSnapshot, ) { - return { provider, runtimeState, ...(quota ? { quota } : {}) }; + return { provider, runtimeState }; } function providerDisabled() { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index adadbbe223..c1944224f6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -59,7 +59,6 @@ import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/cor import type { LocalMemoryState } from '@maka/core/local-memory'; import type { AuthorizationUrlPayload, - SubscriptionAccountState, SubscriptionActionResult, } from '@maka/core/oauth-subscription'; import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; @@ -918,21 +917,6 @@ export interface MakaBridge { | { ok: false; reason: SearchErrorReason; message: string } >; }; - claudeSubscription: { - isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise; - getAuthUrl(host?: DesktopRuntimeHostRef): Promise; - openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; - completeAuthorization( - authRequestId: string, - pasted: string, - host?: DesktopRuntimeHostRef, - ): Promise; - cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef): Promise; - refreshQuota(host?: DesktopRuntimeHostRef): Promise; - refreshTokens(host?: DesktopRuntimeHostRef): Promise; - logout(host?: DesktopRuntimeHostRef): Promise; - }; openAiCodex: { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise; getAuthUrl(host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e53e00d9fc..c32666b714 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -104,7 +104,6 @@ import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/cor import type { LocalMemoryState } from '@maka/core/local-memory'; import type { AuthorizationUrlPayload, - SubscriptionAccountState, SubscriptionActionResult, } from '@maka/core/oauth-subscription'; import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; @@ -2174,53 +2173,12 @@ const makaBridge = { ); }, }, - // PR-OAUTH-SUBSCRIPTION-0: Claude subscription OAuth bridge. - // NEVER returns raw OAuth credentials; renderer only sees account - // state + quota + action results (xuan G-X3 + the - // claude-subscription-ipc-boundary contract test enforces this). + // Browser-assisted Codex account bridge. NEVER returns raw OAuth + // credentials; the renderer only sees account state and action results. // - // kenji `1da909d5`/`45b31e16` hardening: `openAuthUrl` takes - // ONLY an `authRequestId`; the URL is held by main from the - // earlier `getAuthUrl` call. Renderer can never hand - // `shell.openExternal` an arbitrary URL. - // - // Whole feature is gated behind `MAKA_CLAUDE_SUBSCRIPTION_EXPERIMENTAL=1` - // until product/legal sign-off. `isExperimentalEnabled()` lets the - // Settings UI hide the card; even without that hide, all auth-flow - // handlers re-check the flag main-side (fail-closed via the - // `experimental_disabled` reason). - claudeSubscription: { - isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:is-experimental-enabled'); - }, - getAuthUrl(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:get-auth-url'); - }, - openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:open-auth-url', authRequestId); - }, - completeAuthorization(authRequestId: string, pasted: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:complete-authorization', authRequestId, pasted); - }, - cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { - return invokeSelectedRuntimeHost(host, 'claude-subscription:cancel-authorization', authRequestId); - }, - getAccountState(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:get-account-state'); - }, - refreshQuota(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:refresh-quota'); - }, - refreshTokens(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:refresh-tokens'); - }, - logout(host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'claude-subscription:logout'); - }, - }, - // Browser-assisted Codex account bridge. Same shape as - // `claudeSubscription`: no token-shaped fields cross preload, the - // authorization attempt stays opaque, and actions return envelopes. + // kenji `1da909d5`/`45b31e16` hardening: `openAuthUrl` takes ONLY an + // `authRequestId`; the URL is held by main from the earlier `getAuthUrl` + // call. Renderer can never hand `shell.openExternal` an arbitrary URL. openAiCodex: { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled'); diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index 18312694da..31c23de0d1 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -28,6 +28,7 @@ import { } from '@maka/ui/icons'; import type { ChatDefaultPermissionMode, SettingsSection, ThemePreference } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; +import { isRetiredProvider } from '@maka/core/provider-registry'; import type { PermissionMode } from '@maka/core/permission'; import type { SessionSummary } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; @@ -418,7 +419,9 @@ export function buildCommandList(args: { } if (args.onTestConnection && args.defaultSlug) { const defaultConnection = args.connections.find((c) => c.slug === args.defaultSlug); - if (defaultConnection) { + // Loading the catalog releases a default that points at a retired + // connection, so this is belt-and-braces for a stale in-memory default. + if (defaultConnection && !isRetiredProvider(defaultConnection.providerType)) { cmds.push({ id: 'diag:test-default', kind: 'action', @@ -437,7 +440,11 @@ export function buildCommandList(args: { // 账号 just to swap. if (args.onSetDefaultConnection || args.onTestConnection) { for (const connection of args.connections) { - if (!connection.enabled) continue; + // A retained retired connection can still be enabled — the row exists so + // the credential stays visible and deletable — but both commands below + // are refused downstream (the storage default-target gate, the hidden + // auth actions), so offering them is a dead entry point. + if (!connection.enabled || isRetiredProvider(connection.providerType)) continue; const isDefault = connection.slug === args.defaultSlug; // The workspace default is the pair {connection, model}. A connection // with no default model cannot supply half of it, so offering the command diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 7f62272903..4db569105f 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -388,6 +388,7 @@ const COPY = { model_not_enabled: '当前任务选择的模型未启用。请到 设置 · 模型 重新选择可用模型后再发送。', model_not_chat_capable: '当前任务选择的模型不能用于聊天。请到 设置 · 模型 重新选择支持聊天的模型后再发送。', fake_backend: '当前任务来自旧的本地模拟连接。请到 设置 · 模型 添加真实模型后新建任务。', + provider_retired: '当前任务绑定的连接,其登录方式已从 Maka 移除,无法用于发送。请到 设置 · 模型 改用其他连接后新建任务。', }, }, footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '复制', info: '详情' }, pending: '正在处理…', regenerateRunning: '当前回答仍在进行中,结束后再重新生成', regenerateAgain: '已重新生成过,再次点击将创建新的并行回答', regenerate: '让模型重新生成本轮回答', branchRunning: '当前回答仍在进行中,结束后再分支', branchAborted: '从中断前的上下文分支出新任务', branch: '基于此回答的上下文分支出新任务', copy: '复制回答到剪贴板', copyEmpty: '此回答尚无可复制的内容' }, @@ -554,6 +555,7 @@ const COPY = { health: { blocked: { fake_backend: { label: '任务已过期 · 请先配置真实模型', tooltip: () => '原任务使用旧的本地模拟连接,需要先到 设置 · 模型 添加并启用一个真实模型才能发送。' }, + provider_retired: { label: '登录方式已停用', tooltip: (name) => `任务绑定的连接 "${name}" 使用的登录方式已从 Maka 移除,发送会失败。请到 设置 · 模型 改用其他连接。` }, missing_default_connection: { label: '未配置可用模型', tooltip: () => '当前任务没有可用的模型连接,发送会失败。请到 设置 · 模型 添加并启用一个模型。' }, connection_missing: { label: '连接已删除', tooltip: () => '此任务依赖的模型连接已被删除,发送会失败。请到 设置 · 模型 检查连接配置。' }, connection_disabled: { label: '连接已禁用', tooltip: (name) => `任务绑定的连接 "${name}" 已禁用,发送会失败。请到 设置 · 模型 启用它或选择其他连接。` }, @@ -586,6 +588,7 @@ const COPY = { model_not_enabled: 'The model selected for this task is disabled. Choose an enabled model in Settings · Models.', model_not_chat_capable: 'The model selected for this task cannot chat. Choose a chat-capable model in Settings · Models.', fake_backend: 'This task used the retired local simulation. Add a real model in Settings · Models, then start a new task.', + provider_retired: 'The sign-in this task\u2019s connection uses was removed from Maka, so it cannot send. Switch to another connection in Settings · Models, then start a new task.', }, }, footer: { labels: { regenerate: 'Regenerate', branch: 'Branch', copy: 'Copy', info: 'Details' }, pending: 'Working…', regenerateRunning: 'Wait for the current response to finish before regenerating', regenerateAgain: 'A regenerated response already exists; click again to create another parallel response', regenerate: 'Generate another response to this turn', branchRunning: 'Wait for the current response to finish before branching', branchAborted: 'Branch from the context before the interruption', branch: 'Branch a new task from this response', copy: 'Copy response to clipboard', copyEmpty: 'This response has no content to copy' }, @@ -756,6 +759,7 @@ const COPY = { health: { blocked: { fake_backend: { label: 'Stale task · Configure a real model', tooltip: () => 'This task used the retired local simulation. Add and enable a real model in Settings · Models before sending.' }, + provider_retired: { label: 'Sign-in retired', tooltip: (name) => `The sign-in that connection "${name}" uses was removed from Maka, so sending fails. Switch to another connection in Settings · Models.` }, missing_default_connection: { label: 'No model configured', tooltip: () => 'This task has no available model connection. Add and enable one in Settings · Models.' }, connection_missing: { label: 'Connection deleted', tooltip: () => 'The model connection used by this task was deleted. Check Settings · Models.' }, connection_disabled: { label: 'Connection disabled', tooltip: (name) => `Connection "${name}" is disabled. Enable it or choose another connection in Settings · Models.` }, diff --git a/apps/desktop/src/renderer/locales/onboarding-copy.ts b/apps/desktop/src/renderer/locales/onboarding-copy.ts index d0c5461575..00c90a660a 100644 --- a/apps/desktop/src/renderer/locales/onboarding-copy.ts +++ b/apps/desktop/src/renderer/locales/onboarding-copy.ts @@ -2,7 +2,11 @@ import type { OnboardingState } from '@maka/core/onboarding'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import type { OnboardingHeroCopy } from '../onboarding-hero-copy.js'; -type VisibleOnboardingKind = Exclude; +// Blocked states are keyed by reason, not just by kind: a new blocked reason +// then cannot ship without its own copy. +type VisibleOnboardingKind = + | Exclude + | `blocked:${Extract['reason']}`; type LocalizedOnboardingHeroCopy = Omit< OnboardingHeroCopy, 'kind' | 'connectionSlug' | 'cta' @@ -48,13 +52,20 @@ const ONBOARDING_COPY_BY_LOCALE: UiCatalog = { body: '启用一个可用于对话的模型,新任务就可以开始了。', cta: { label: '选择可用模型' }, }, - blocked: { + 'blocked:all_connections_unhealthy': { eyebrow: '连接需要处理', title: '模型连接暂时不可用。', body: '现有连接都没有通过验证。检查凭据、登录状态或网络后重新测试。', cta: { label: '修复模型连接' }, tone: 'destructive', }, + 'blocked:all_connections_retired': { + eyebrow: '连接需要处理', + title: '现有连接的登录方式已停用。', + body: '这些连接使用的登录方式已从 Maka 移除,无法再登录,也无法用于对话。添加一个新的模型连接即可继续。', + cta: { label: '添加模型连接' }, + tone: 'destructive', + }, }, needsConnection: { pickLabel: '选择常用服务商', @@ -90,13 +101,20 @@ const ONBOARDING_COPY_BY_LOCALE: UiCatalog = { body: 'Enable a conversation-capable model and your first task can begin.', cta: { label: 'Choose an available model' }, }, - blocked: { + 'blocked:all_connections_unhealthy': { eyebrow: 'Connection needs attention', title: 'Model connections are temporarily unavailable.', body: 'No existing connection passed verification. Check credentials, sign-in status, or network access, then test again.', cta: { label: 'Fix model connections' }, tone: 'destructive', }, + 'blocked:all_connections_retired': { + eyebrow: 'Connection needs attention', + title: 'The sign-in your connections use is retired.', + body: 'The sign-in these connections use was removed from Maka. They can no longer be signed into or used in a conversation. Add a new model connection to continue.', + cta: { label: 'Add a model connection' }, + tone: 'destructive', + }, }, needsConnection: { pickLabel: 'Choose a common provider', diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 7a55ead23d..4110545424 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -62,6 +62,9 @@ const zhCopy = { oauthLoadingDetail: '正在读取本机 OAuth 登录状态,读取完成前不会把未知状态显示成未登录。', oauthUnknownDetail: '暂时无法读取本机 OAuth 登录状态;请刷新页面或重新打开设置。', oauthWaitingDetail: '请到账号连接完成登录;登录成功后会自动出现在模型连接里。', + oauthRetired: '此登录方式已停用', + oauthRetiredDetail: + '这条连接使用的登录方式已从 Maka 移除,无法再登录,也无法用于对话。改用 Anthropic API Key 连接即可继续使用 Claude 模型;删除这条连接会一并清除本机保存的登录凭据。', credentialLoadingDetail: '正在读取模型凭据状态,读取完成前暂不测试连接或刷新模型。', credentialUnknownDetail: '模型凭据状态暂时没刷新成功,已避免把未知状态显示成未登录或未配置。', testConnection: '测试连接', @@ -112,7 +115,7 @@ const zhCopy = { timeout: '请求超时,请检查网络或代理后重试。', unavailable: '模型服务暂时不可用,请稍后重试。', network: '网络错误,请检查服务地址或代理设置后重试。', statusUnavailable: '连接测试状态暂时无法显示,请重新测试。', categories: { oauth: 'OAuth', domestic: '国内', overseas: '海外', local: '本地', custom: 'Custom' }, - connectionStatuses: { reauth: '需要重新登录', disabledFailed: '暂不可用 · 上次连接失败', disabled: '暂不可用', failed: '上次连接失败' }, + connectionStatuses: { retired: '已停用 · 请删除', reauth: '需要重新登录', disabledFailed: '暂不可用 · 上次连接失败', disabled: '暂不可用', failed: '上次连接失败' }, lastTest: { '连接已验证': '连接已验证', '鉴权失败': '鉴权失败', '请求超时': '请求超时', '网络错误': '网络错误', '模型服务返回错误': '模型服务返回错误', '连接测试失败': '连接测试失败', 'connection verified': '连接已验证', 'authentication failed': '鉴权失败', 'request timed out': '请求超时', 'network error': '网络错误', 'provider returned an error': '模型服务返回错误', 'connection test failed': '连接测试失败', @@ -163,10 +166,10 @@ const zhCopy = { logoutTitle: (name: string) => `退出 ${name} 登录?`, }, oauthSection: { - signedIn: '已登录', claudeDescription: 'Claude Pro / Max 订阅账号登录。', codexDescription: 'ChatGPT Plus / Pro 订阅账号登录。', xaiDescription: 'SuperGrok / X Premium 账号登录。', + signedIn: '已登录', codexDescription: 'ChatGPT Plus / Pro 订阅账号登录。', xaiDescription: 'SuperGrok / X Premium 账号登录。', copilotDescription: '导入兼容 GitHub 凭据连接 Copilot 订阅。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', aria: 'OAuth 登录', - staleState: 'OAuth 登录状态暂时没刷新成功,已保留上一次状态。', claudeSubtitle: '登录 Claude Pro / Max 后,会同步成模型连接。', + staleState: 'OAuth 登录状态暂时没刷新成功,已保留上一次状态。', codexDetail: '点击下方按钮打开设备授权页,并在页面中输入这里显示的登录码。', xaiDetail: '点击下方按钮打开浏览器登录,授权完成后会自动回写。', deviceCode: '登录码:', stateHint: '提示:state 以', startsWith: '开头。', openingBrowser: '打开浏览器…', logout: '退出登录', loggingOut: '退出中…', copilotSubtitle: '导入兼容的 GitHub 登录;token 不会暴露给渲染进程。', copilotImported: '已导入 GitHub Copilot 订阅账号。', @@ -177,23 +180,6 @@ const zhCopy = { connectTitle: (name: string) => `连接 ${name}`, login: (name: string) => `登录 ${name}`, signedOut: (name: string) => `${name} 尚未登录。`, storageFailed: (name: string) => `${name} 本地凭据读取失败,请重新登录。`, providerUnavailable: (name: string) => `${name} 已登录,但当前 provider 状态不可用。`, }, - claude: { - refreshFailed: '刷新登录状态失败', gateReadFailed: '读取 Claude 登录开关失败', title: 'Claude 订阅 (Pro / Max)', - gateUnknown: '无法确认 Claude OAuth 是否可用。没有登录动作会被执行。', readFailed: '读取失败', gateError: 'Claude 登录开关读取失败:', retry: '重试', - startFailed: '无法开始登录', retryLater: '请稍后再试。', startFailedRetry: '无法开始登录,请稍后再试。', openFailed: '无法打开浏览器', openFailedRetry: '无法打开浏览器,请稍后重试。', - loginSuccess: '登录成功', bound: '已绑定 Claude 订阅。', submitFailed: '授权码提交失败', submitFailedRetry: '授权码提交失败,请重新登录后再试。', - cancelFailed: '取消登录失败', logoutTitle: '退出 Claude Code 登录?', logoutDescription: '将删除本机保存的订阅凭据,之后需要重新登录才能继续使用 Claude OAuth 模型。', - logout: '退出登录', cancel: '取消', loggedOut: '已退出登录', cleared: '本地凭据已清除。', logoutFailed: '退出失败', logoutFailedRetry: '退出登录失败,请稍后重试。', - quotaFailed: '刷新配额失败', loading: '加载中…', section: '订阅', subtitle: '通过 Anthropic 官方 OAuth 登录使用订阅配额。', - fiveHour: '5 小时窗口', sevenDay: '7 天窗口', updated: '数据更新于 ', openingBrowser: '打开浏览器…', loggingIn: '登录中…', relogin: '重新登录', loginSubscription: '登录订阅', - refreshing: '刷新中…', refreshQuota: '刷新配额', loggingOut: '退出中…', pasteAria: '粘贴授权码', pasteHelpBefore: '在 Claude.ai 完成登录后,会跳转到 Anthropic 控制台显示一段授权码(含', - pasteHelpAfter: '分隔符),把它粘贴到下面:', stateHint: '提示:你的 state 以', startsWith: '开头。', codePlaceholder: '粘贴授权码(格式:xxx#yyy)', codeAria: '授权码', - submitting: '提交中…', submitCode: '提交授权码', cancelling: '取消中…', signedOut: '未登录', signedOutDetail: '使用 Claude 订阅配额前需要先登录。', - authorizing: '登录中…', authorizingDetail: '请在弹出的浏览器窗口完成登录并粘贴授权码。', signedIn: '已登录', signedInDetail: '已绑定 Claude 订阅,并会同步到“模型连接”。', - tokenRefreshing: '刷新中…', tokenRefreshingDetail: '正在刷新访问令牌。', tokenRefreshFailed: '刷新失败', tokenRefreshFailedDetail: '令牌刷新失败,请重新登录。', - storageFailed: '凭据读取失败', storageFailedDetail: '本地 OAuth 凭据读取失败,请重新登录。', quotaUnavailable: '等待获取配额', quotaUnavailableDetail: '已登录;配额接口当前没有返回可用数据。', - providerRejected: '订阅 API 拒绝', providerRejectedDetail: '订阅端点拒绝了请求,可能需要重新登录。', unknown: '未知状态', - }, } as const; export type ProviderSettingsCopy = WidenCopy; @@ -212,6 +198,9 @@ const enCopy: ProviderSettingsCopy = { oauthLoadingDetail: 'Reading the local OAuth status. An unknown state will not be shown as signed out.', oauthUnknownDetail: 'The local OAuth status is temporarily unavailable. Refresh the page or reopen Settings.', oauthWaitingDetail: 'Complete sign-in under account connections. The model connection appears automatically afterward.', + oauthRetired: 'This sign-in path is retired', + oauthRetiredDetail: + 'The sign-in this connection uses was removed from Maka. It can no longer be signed into or used in a conversation. Add an Anthropic API key connection to keep using Claude models; deleting this connection also clears the sign-in credential stored on this machine.', credentialLoadingDetail: 'Reading model credential status. Connection tests and model refresh are paused until it finishes.', credentialUnknownDetail: 'Model credential status could not be refreshed, so the connection is not being mislabeled as signed out or unconfigured.', testConnection: 'Test connection', @@ -260,7 +249,7 @@ const enCopy: ProviderSettingsCopy = { timeout: 'The request timed out. Check the network or proxy and try again.', unavailable: 'The model service is temporarily unavailable. Try again later.', network: 'Network error. Check the service URL or proxy settings and try again.', statusUnavailable: 'The connection test status is temporarily unavailable. Test again.', categories: { oauth: 'OAuth', domestic: 'China', overseas: 'Global', local: 'Local', custom: 'Custom' }, - connectionStatuses: { reauth: 'Sign-in required', disabledFailed: 'Unavailable · last connection failed', disabled: 'Unavailable', failed: 'Last connection failed' }, + connectionStatuses: { retired: 'Retired · delete it', reauth: 'Sign-in required', disabledFailed: 'Unavailable · last connection failed', disabled: 'Unavailable', failed: 'Last connection failed' }, lastTest: { '连接已验证': 'Connection verified', '鉴权失败': 'Authentication failed', '请求超时': 'Request timed out', '网络错误': 'Network error', '模型服务返回错误': 'Model service returned an error', '连接测试失败': 'Connection test failed', 'connection verified': 'Connection verified', 'authentication failed': 'Authentication failed', 'request timed out': 'Request timed out', 'network error': 'Network error', 'provider returned an error': 'Model service returned an error', 'connection test failed': 'Connection test failed', @@ -311,10 +300,10 @@ const enCopy: ProviderSettingsCopy = { logoutTitle: (name: string) => `Sign out of ${name}?`, }, oauthSection: { - signedIn: 'Signed in', claudeDescription: 'Sign in with a Claude Pro / Max subscription.', codexDescription: 'Sign in with a ChatGPT Plus / Pro subscription.', xaiDescription: 'Sign in with SuperGrok or X Premium.', + signedIn: 'Signed in', codexDescription: 'Sign in with a ChatGPT Plus / Pro subscription.', xaiDescription: 'Sign in with SuperGrok or X Premium.', copilotDescription: 'Import compatible GitHub credentials to connect a Copilot subscription.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', aria: 'OAuth sign-in', - staleState: 'OAuth sign-in status could not be refreshed. The last known state is preserved. ', claudeSubtitle: 'After Claude Pro / Max sign-in, the account is synchronized as a model connection.', + staleState: 'OAuth sign-in status could not be refreshed. The last known state is preserved. ', codexDetail: 'Open the device page below and enter the sign-in code shown here.', xaiDetail: 'Open the browser below to sign in. Authorization is written back automatically.', deviceCode: 'Sign-in code:', stateHint: 'Tip: state begins with', startsWith: '.', openingBrowser: 'Opening browser…', 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.', @@ -325,23 +314,6 @@ const enCopy: ProviderSettingsCopy = { connectTitle: (name: string) => `Connect ${name}`, login: (name: string) => `Sign in to ${name}`, signedOut: (name: string) => `${name} is signed out.`, storageFailed: (name: string) => `Could not read local credentials for ${name}. Sign in again.`, providerUnavailable: (name: string) => `${name} is signed in, but the provider status is currently unavailable.`, }, - claude: { - refreshFailed: 'Failed to refresh sign-in status', gateReadFailed: 'Failed to read the Claude sign-in setting', title: 'Claude subscription (Pro / Max)', - gateUnknown: 'Claude OAuth availability could not be confirmed. No sign-in action will run.', readFailed: 'Read failed', gateError: 'Failed to read the Claude sign-in setting: ', retry: 'Retry', - startFailed: 'Could not start sign-in', retryLater: 'Try again later.', startFailedRetry: 'Could not start sign-in. Try again later.', openFailed: 'Could not open browser', openFailedRetry: 'Could not open the browser. Try again.', - loginSuccess: 'Signed in', bound: 'Claude subscription connected.', submitFailed: 'Authorization code submission failed', submitFailedRetry: 'Authorization code submission failed. Sign in again and retry.', - cancelFailed: 'Failed to cancel sign-in', logoutTitle: 'Sign out of Claude Code?', logoutDescription: 'This removes locally stored subscription credentials. You must sign in again to use Claude OAuth models.', - logout: 'Sign out', cancel: 'Cancel', loggedOut: 'Signed out', cleared: 'Local credentials cleared.', logoutFailed: 'Sign-out failed', logoutFailedRetry: 'Sign-out failed. Try again later.', - quotaFailed: 'Failed to refresh quota', loading: 'Loading…', section: 'Subscription', subtitle: 'Use subscription quota through official Anthropic OAuth sign-in.', - fiveHour: '5-hour window', sevenDay: '7-day window', updated: 'Updated ', openingBrowser: 'Opening browser…', loggingIn: 'Signing in…', relogin: 'Sign in again', loginSubscription: 'Sign in to subscription', - refreshing: 'Refreshing…', refreshQuota: 'Refresh quota', loggingOut: 'Signing out…', pasteAria: 'Paste authorization code', pasteHelpBefore: 'After signing in on Claude.ai, the Anthropic console shows an authorization code containing a', - pasteHelpAfter: 'separator. Paste it below:', stateHint: 'Tip: your state begins with', startsWith: '.', codePlaceholder: 'Paste authorization code (format: xxx#yyy)', codeAria: 'Authorization code', - submitting: 'Submitting…', submitCode: 'Submit authorization code', cancelling: 'Cancelling…', signedOut: 'Signed out', signedOutDetail: 'Sign in before using Claude subscription quota.', - authorizing: 'Signing in…', authorizingDetail: 'Complete sign-in in the browser window and paste the authorization code.', signedIn: 'Signed in', signedInDetail: 'Claude subscription connected and synchronized to Model connections.', - tokenRefreshing: 'Refreshing…', tokenRefreshingDetail: 'Refreshing access token.', tokenRefreshFailed: 'Refresh failed', tokenRefreshFailedDetail: 'Token refresh failed. Sign in again.', - storageFailed: 'Credential read failed', storageFailedDetail: 'Could not read local OAuth credentials. Sign in again.', quotaUnavailable: 'Waiting for quota', quotaUnavailableDetail: 'Signed in, but the quota endpoint did not return usable data.', - providerRejected: 'Subscription API rejected', providerRejectedDetail: 'The subscription endpoint rejected the request. You may need to sign in again.', unknown: 'Unknown status', - }, }; const PROVIDER_SETTINGS_COPY = { zh: zhCopy, en: enCopy } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts index 3d26b4c94b..a6c4cf4559 100644 --- a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts @@ -24,6 +24,7 @@ export type SubagentSettingsCopy = { }; status: { missingConnection: string; + providerRetired: string; connectionDisabled: string; modelDisabled: string; }; @@ -95,6 +96,7 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { }, status: { missingConnection: '连接不存在', + providerRetired: '登录方式已移除 · 请改用其他连接', connectionDisabled: '连接已停用', modelDisabled: '模型未启用', }, @@ -176,6 +178,7 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { }, status: { missingConnection: 'Connection missing', + providerRetired: 'Sign-in retired · route to another connection', connectionDisabled: 'Connection disabled', modelDisabled: 'Model not enabled', }, diff --git a/apps/desktop/src/renderer/onboarding-hero-copy.ts b/apps/desktop/src/renderer/onboarding-hero-copy.ts index e6338b64d1..62dbcfbd50 100644 --- a/apps/desktop/src/renderer/onboarding-hero-copy.ts +++ b/apps/desktop/src/renderer/onboarding-hero-copy.ts @@ -58,13 +58,17 @@ export function getOnboardingHeroCopy( target: { kind: 'connection', connectionSlug: state.connectionSlug }, }, }; - case 'blocked': - acknowledgeBlockedReason(state.reason); + case 'blocked': { + // Retirement gets its own copy: the generic text tells the user to + // re-check credentials and sign-in, and for a retired provider both of + // those lead nowhere. + const blocked = copy.hero[`blocked:${state.reason}`]; return { kind: state.kind, - ...copy.hero.blocked, - cta: { ...copy.hero.blocked.cta, target: { kind: 'models' } }, + ...blocked, + cta: { ...blocked.cta, target: { kind: 'models' } }, }; + } case 'ready_empty': case 'ready_with_history': return null; @@ -77,7 +81,3 @@ function assertNever(state: never): never { void state; throw new Error('getOnboardingHeroCopy: unexhausted OnboardingState variant'); } - -function acknowledgeBlockedReason(reason: 'all_connections_unhealthy') { - void reason; -} diff --git a/apps/desktop/src/renderer/onboarding-hero.tsx b/apps/desktop/src/renderer/onboarding-hero.tsx index 2a2d83bd4e..d48a944ca8 100644 --- a/apps/desktop/src/renderer/onboarding-hero.tsx +++ b/apps/desktop/src/renderer/onboarding-hero.tsx @@ -333,6 +333,11 @@ function assertNever(value: never): never { throw new Error('OnboardingHero: unexhausted state'); } -function acknowledgeBlockedReason(reason: 'all_connections_unhealthy') { +// Listed as literals rather than the reason type: a future reason still has to +// be added here, which is the point — it forces a look at whether this card +// needs to react to it. +function acknowledgeBlockedReason( + reason: 'all_connections_unhealthy' | 'all_connections_retired', +) { void reason; } diff --git a/apps/desktop/src/renderer/settings/claude-subscription-card.tsx b/apps/desktop/src/renderer/settings/claude-subscription-card.tsx deleted file mode 100644 index 57812c5d6b..0000000000 --- a/apps/desktop/src/renderer/settings/claude-subscription-card.tsx +++ /dev/null @@ -1,495 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { type SubscriptionAccountState } from '@maka/core/oauth-subscription'; -import { type UiLocale } from '@maka/core/ui-locale'; -import { FieldStatus, ProgressBar, StatusDot } from '@astryxdesign/core'; -import { - Banner, - Button, - Divider, - HStack, - RelativeTime, - Text, - TextArea, - VStack, - useMountedRef, - useToast, - useUiLocale, -} from '@maka/ui'; -import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; -import { dotForStatus, type StatusSemantic } from '@maka/ui'; -import { - subscriptionActionErrorMessage, - subscriptionResultMessage, -} from './use-oauth-login-flow'; -import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js'; - -/** - * Claude Pro / Max subscription card: the paste-code OAuth flow (browser → - * copy the `#`-delimited authorization code back) behind the experimental - * gate. Extracted from provider-oauth-section.tsx (#1042); the browser - * loopback/PKCE flow used by the other OAuth providers lives in - * `useOAuthLoginFlow` — Claude deliberately keeps its own card because it - * needs the manual authorization-code step and the experimental gate. - */ -export function ClaudeSubscriptionCard(props: { onLoginSuccess(): void | Promise }) { - const host = useRuntimeHostSettingsTarget(); - const locale = useUiLocale(); - const copy = getProviderSettingsCopy(locale).claude; - const [experimentalEnabled, setExperimentalEnabled] = useState(null); - const [experimentalGateError, setExperimentalGateError] = useState(null); - const [state, setState] = useState(null); - const [pendingAction, setPendingAction] = useState(null); - const pendingActionRef = useRef(null); - const [authRequestId, setAuthRequestId] = useState(null); - const claudeAuthRequestIdRef = useRef(null); - const [stateHint, setStateHint] = useState(null); - const [pasteValue, setPasteValue] = useState(''); - const [pasteError, setPasteError] = useState(null); - const toast = useToast(); - // PR-FE-BUG-HUNT-1 (kenji bug-hunt 2026-06-24): ClaudeSubscriptionCard - // launches a browser OAuth flow that takes seconds-to-minutes to - // complete. Closing the Settings modal while a `startLogin` / - // `submitPaste` / `logout` / `refreshQuota` call was in flight - // would `setState` on an unmounted component (loud warning in dev, - // masks real bugs in prod). Mirror the `mountedRef` pattern other - // settings sub-cards in this file use. - const claudeCardMountedRef = useMountedRef(); - useEffect(() => { - return () => { - const pendingAuthRequestId = claudeAuthRequestIdRef.current; - claudeAuthRequestIdRef.current = null; - if (pendingAuthRequestId) void window.maka.claudeSubscription.cancelAuthorization(pendingAuthRequestId, host); - }; - }, []); - - const refresh = async () => { - try { - const next = await window.maka.claudeSubscription.getAccountState(host); - if (!claudeCardMountedRef.current) return; - setState(next); - setPasteError(null); - } catch (error) { - const message = subscriptionActionErrorMessage(error, locale); - if (!claudeCardMountedRef.current) return; - toast.error(copy.refreshFailed, message); - setPasteError(message); - } - }; - - const refreshExperimentalGate = async () => { - try { - const flag = await window.maka.claudeSubscription.isExperimentalEnabled(host); - if (!claudeCardMountedRef.current) return; - setExperimentalEnabled(flag); - setExperimentalGateError(null); - if (flag) void refresh(); - } catch (error) { - const message = subscriptionActionErrorMessage(error, locale); - if (!claudeCardMountedRef.current) return; - setExperimentalEnabled(null); - setExperimentalGateError(message); - toast.error(copy.gateReadFailed, message); - } - }; - - useEffect(() => { - // kenji `1da909d5` blocking concern: Anthropic does not permit - // third-party developers to offer Claude.ai login on behalf of - // users. Until product/legal sign-off, gate the whole UI behind - // `MAKA_CLAUDE_SUBSCRIPTION_EXPERIMENTAL=1`. Loading state also - // renders nothing — no teasing UI. - let cancelled = false; - void window.maka.claudeSubscription - .isExperimentalEnabled(host) - .then((flag) => { - if (cancelled) return; - setExperimentalEnabled(flag); - setExperimentalGateError(null); - if (flag) void refresh(); - }) - .catch((error) => { - if (cancelled) return; - const message = subscriptionActionErrorMessage(error, locale); - setExperimentalEnabled(null); - setExperimentalGateError(message); - toast.error(copy.gateReadFailed, message); - }); - return () => { - cancelled = true; - }; - }, []); - - if (experimentalGateError) { - // Astryx convergence (task #136): the gate-read failure was a hand-tinted - // `.settingsConnectionRow[data-status=error]` card; Banner is the one - // error surface Settings uses now. - return ( - void refreshExperimentalGate()} - label={copy.retry} - /> - } - /> - ); - } - - if (experimentalEnabled !== true) { - return null; - } - - function beginPendingAction(action: ClaudeSubscriptionPendingAction): boolean { - if (pendingActionRef.current !== null) return false; - pendingActionRef.current = action; - setPendingAction(action); - return true; - } - - function finishPendingAction() { - pendingActionRef.current = null; - setPendingAction(null); - } - - async function startLogin() { - if (!beginPendingAction('login')) return; - try { - // kenji `027c93c0` + xuan `2e5be5a`: getAuthUrl now returns - // a union — `AuthorizationUrlPayload` on success, or a - // `SubscriptionActionResult` envelope when fail-closed - // (e.g. experimental flag flipped off after the card - // mounted). Discriminate by checking for the `ok` field; the - // envelope variant has it, the success payload does not. - const payload = await window.maka.claudeSubscription.getAuthUrl(host); - if ('ok' in payload) { - if (!claudeCardMountedRef.current) return; - // Envelope variant. `ok: true` shouldn't happen for - // getAuthUrl (success returns the payload, not an envelope), - // so this branch is the failure case in practice. - toast.error(copy.startFailed, payload.ok ? copy.retryLater : subscriptionResultMessage(payload.message, copy.startFailedRetry, locale)); - return; - } - claudeAuthRequestIdRef.current = payload.authRequestId; - if (!claudeCardMountedRef.current) { - claudeAuthRequestIdRef.current = null; - void window.maka.claudeSubscription.cancelAuthorization(payload.authRequestId, host); - return; - } - setAuthRequestId(payload.authRequestId); - setStateHint(payload.stateHint); - setPasteValue(''); - setPasteError(null); - // kenji `1da909d5` hardening: pass the opaque authRequestId, - // NOT the URL. Main looks up the URL it generated. - const opened = await window.maka.claudeSubscription.openAuthUrl(payload.authRequestId, host); - if (!claudeCardMountedRef.current) return; - if (!opened.ok) { - toast.error(copy.openFailed, subscriptionResultMessage(opened.message, copy.openFailedRetry, locale)); - claudeAuthRequestIdRef.current = null; - void window.maka.claudeSubscription.cancelAuthorization(payload.authRequestId, host); - setAuthRequestId(null); - setStateHint(null); - } - await refresh(); - } catch (error) { - const pendingAuthRequestId = claudeAuthRequestIdRef.current; - claudeAuthRequestIdRef.current = null; - if (pendingAuthRequestId) void window.maka.claudeSubscription.cancelAuthorization(pendingAuthRequestId, host); - const message = subscriptionActionErrorMessage(error, locale); - if (!claudeCardMountedRef.current) return; - setAuthRequestId(null); - setStateHint(null); - toast.error(copy.startFailed, message); - setPasteError(message); - } finally { - if (claudeCardMountedRef.current) finishPendingAction(); - } - } - - async function submitPaste() { - if (!authRequestId) return; - if (!beginPendingAction('submit')) return; - setPasteError(null); - try { - const result = await window.maka.claudeSubscription.completeAuthorization( - authRequestId, - pasteValue, - host, - ); - if (!claudeCardMountedRef.current) return; - if (result.ok) { - toast.success(copy.loginSuccess, copy.bound); - claudeAuthRequestIdRef.current = null; - setAuthRequestId(null); - setStateHint(null); - setPasteValue(''); - await refresh(); - await props.onLoginSuccess(); - } else { - setPasteError(subscriptionResultMessage(result.message, copy.submitFailedRetry, locale)); - } - } catch (error) { - const message = subscriptionActionErrorMessage(error, locale); - if (!claudeCardMountedRef.current) return; - toast.error(copy.submitFailed, message); - setPasteError(message); - } finally { - if (claudeCardMountedRef.current) finishPendingAction(); - } - } - - async function cancelLogin() { - if (!authRequestId) return; - if (!beginPendingAction('cancel')) return; - try { - await window.maka.claudeSubscription.cancelAuthorization(authRequestId, host); - if (!claudeCardMountedRef.current) return; - claudeAuthRequestIdRef.current = null; - setAuthRequestId(null); - setStateHint(null); - setPasteValue(''); - setPasteError(null); - await refresh(); - } catch (error) { - if (!claudeCardMountedRef.current) return; - toast.error(copy.cancelFailed, subscriptionActionErrorMessage(error, locale)); - } finally { - if (claudeCardMountedRef.current) finishPendingAction(); - } - } - - async function logout() { - if (!beginPendingAction('logout')) return; - try { - const ok = await toast.confirm({ - title: copy.logoutTitle, - description: copy.logoutDescription, - confirmLabel: copy.logout, - cancelLabel: copy.cancel, - destructive: true, - }); - if (!ok) return; - const result = await window.maka.claudeSubscription.logout(host); - if (!claudeCardMountedRef.current) return; - if (result.ok) { - toast.success(copy.loggedOut, copy.cleared); - await refresh(); - } else { - toast.error(copy.logoutFailed, subscriptionResultMessage(result.message, copy.logoutFailedRetry, locale)); - } - } catch (error) { - if (!claudeCardMountedRef.current) return; - toast.error(copy.logoutFailed, subscriptionActionErrorMessage(error, locale)); - } finally { - if (claudeCardMountedRef.current) finishPendingAction(); - } - } - - async function refreshQuota() { - if (!beginPendingAction('quota')) return; - try { - await window.maka.claudeSubscription.refreshQuota(host); - if (!claudeCardMountedRef.current) return; - await refresh(); - } catch (error) { - if (!claudeCardMountedRef.current) return; - toast.error(copy.quotaFailed, subscriptionActionErrorMessage(error, locale)); - } finally { - if (claudeCardMountedRef.current) finishPendingAction(); - } - } - - // Closed-state render mapping per the runtime state enum. - const presentation = state ? presentSubscriptionState(state, locale) : { label: copy.loading, tone: 'neutral' as const, detail: '' }; - const canStartClaudeLogin = - state?.runtimeState === 'not_logged_in' || - state?.runtimeState === 'refresh_failed' || - state?.runtimeState === 'storage_failed'; - const claudeLoginPending = authRequestId !== null || state?.runtimeState === 'authorizing'; - const actionBusy = pendingAction !== null; - - // Deep-review fix: this panel renders under ProvidersPanel's RouteHeader, - // which already says 连接 Claude + subtitle — the SectionHeader + full-width - // Card + repeated title made it the one OAuth panel with its own chrome. - // It is a bare VStack now, the same shape as its sibling login panels, and - // runtime state reads as the shared StatusDot + text idiom. - return ( - - - - - {presentation.label} - - {state?.profile?.email ? ( - {state.profile.email} - ) : null} - - {presentation.detail} - {pasteError && !authRequestId && ( -
- -
- )} - - {state?.quota && (state.quota.fiveHour || state.quota.sevenDay) && ( - - {state.quota.fiveHour && ( - `${Math.round(value)}%`} - variant={quotaVariant(state.quota.fiveHour.utilization)} - /> - )} - {state.quota.sevenDay && ( - `${Math.round(value)}%`} - variant={quotaVariant(state.quota.sevenDay.utilization)} - /> - )} - - {copy.updated} - - - )} - - - {canStartClaudeLogin || claudeLoginPending ? ( -