From 66f94be623a28c93819f349b2f0549519f7cd485 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Tue, 18 Aug 2026 10:27:58 +0800 Subject: [PATCH 1/8] feat(runtime): retire the Claude subscription OAuth provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maka could sign in with a Claude Pro/Max subscription and send inference through it. Anthropic's Consumer Terms permit programmatic access to the consumer Claude services only through an API key or explicit permission, and this path had neither: it presented itself as Claude Code — borrowing that client_id, its User-Agent, its beta header set and an `x-app: cli` marker — to get requests accepted. The account carrying that risk is the user's, not Maka's. Remove the capability rather than gate it. `claude-subscription` keeps its registry entry so a stored connection still decodes and renders, and is marked `retired`, which is distinct from a provider that was never wired: both have no Runtime adapter, but only one used to work. Retirement is refused at each authority that could otherwise admit the connection, so no single revert makes it sendable again: - the auth contract hides every action, which is what makes the storage layer refuse a model fetch or a connection test - the readiness gate reports `provider_retired` before the send is admitted, instead of letting it fail inside model construction - the model catalog resolves every model to `provider_removed`, so the pickers stop offering them - the interactive-login allow list and the Host wire enum no longer name it - `getAIModel` and `resolveModelRuntime` throw as the last backstop Settings explains the state instead of pointing at a sign-in that no longer exists, and stops offering "set as default" and "test connection" for a connection that cannot perform either. Deleting the connection is what clears the credential this machine still holds. The impersonation code goes with it: the cloaked request builder, the Claude token endpoint and its client identity, the cloaked model-fetch headers, and the subscription usage/quota path that needed that same identity to read. `RUNTIME_HOST_COMPATIBILITY_EPOCH` moves to 23: the OAuth login provider enum and the account-usage operation both changed. Generated-by: Claude Code --- .../src/main/__tests__/chat-readiness.test.ts | 24 +- .../provider-connection-status.test.ts | 70 +++ .../runtime-host-account-connection.test.ts | 22 +- .../runtime-host-oauth-ipc-main.test.ts | 49 +- apps/desktop/src/main/chat-readiness.ts | 2 + .../src/main/oauth-connection-identities.ts | 1 - apps/desktop/src/main/runtime-host-client.ts | 6 - .../src/main/runtime-host-oauth-ipc-main.ts | 25 +- apps/desktop/src/preload/bridge-contract.d.ts | 16 - apps/desktop/src/preload/preload.ts | 52 +- .../src/renderer/locales/conversation-copy.ts | 4 + .../src/renderer/locales/onboarding-copy.ts | 24 +- .../locales/settings-provider-copy.ts | 52 +- .../src/renderer/onboarding-hero-copy.ts | 16 +- apps/desktop/src/renderer/onboarding-hero.tsx | 7 +- .../settings/claude-subscription-card.tsx | 494 ------------------ .../settings/provider-connection-detail.tsx | 27 +- .../settings/provider-connection-status.ts | 11 + .../settings/provider-oauth-section.tsx | 42 +- .../src/renderer/settings/providers-panel.tsx | 13 +- .../settings/use-connection-detail.ts | 9 +- .../settings/provider-settings.stories.tsx | 13 - .../settings/settings-pages.stories.tsx | 7 - docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 - .../__tests__/connection-readiness.test.ts | 31 ++ .../src/__tests__/oauth-subscription.test.ts | 33 -- .../core/src/__tests__/onboarding.test.ts | 24 +- .../core/src/__tests__/provider-auth.test.ts | 5 +- .../provider-catalog-contract.test.ts | 92 +++- packages/core/src/connection-error-copy.ts | 2 + packages/core/src/connection-readiness.ts | 27 +- packages/core/src/model-catalog.ts | 6 +- packages/core/src/model-web-search.ts | 2 - packages/core/src/oauth-subscription.ts | 146 +----- packages/core/src/onboarding.ts | 14 +- packages/core/src/provider-auth.ts | 24 + packages/core/src/provider-registry.ts | 28 +- .../core/src/task-submission-readiness.ts | 3 + .../execution-model-composition.test.ts | 85 ++- .../src/__tests__/oauth-coordinator.test.ts | 187 +------ .../oauth-execution-authority.test.ts | 179 +------ .../src/__tests__/oauth-protocol.test.ts | 74 ++- .../__tests__/oauth-two-client-uds.test.ts | 38 +- .../session-catalog-coordinator.test.ts | 90 +++- packages/runtime-host/src/protocol/index.ts | 2 +- packages/runtime-host/src/protocol/oauth.ts | 101 +--- .../runtime-host/src/protocol/operations.ts | 1 - .../src/server/execution-composition.ts | 5 - .../src/server/execution-model-authority.ts | 5 - .../src/server/execution-model-composition.ts | 2 - .../src/server/oauth-coordinator.ts | 160 +----- .../src/server/oauth-execution-authority.ts | 18 - .../src/server/session-catalog-coordinator.ts | 10 + packages/runtime/package.json | 1 - .../claude-subscription-runtime.test.ts | 38 +- .../claude-subscription-usage.test.ts | 30 -- .../__tests__/computer-use-model-loop.test.ts | 1 - .../__tests__/model-factory-thinking.test.ts | 7 +- .../runtime/src/__tests__/oauth-login.test.ts | 79 +-- .../src/__tests__/provider-contract-matrix.ts | 19 +- .../subscription-credentials.test.ts | 53 +- .../subscription-model-fetch.test.ts | 85 --- .../runtime/src/claude-subscription-usage.ts | 64 --- packages/runtime/src/model-factory.ts | 17 +- packages/runtime/src/model-fetcher.ts | 19 +- packages/runtime/src/model-runtime.ts | 17 +- packages/runtime/src/oauth-login.ts | 95 +--- .../runtime/src/oauth-provider-contracts.ts | 17 +- packages/runtime/src/subscription-auth.ts | 13 - .../src/subscription-cloaked-request.ts | 143 ----- .../runtime/src/subscription-credentials.ts | 45 +- .../runtime/src/subscription-model-fetch.ts | 71 --- packages/runtime/src/test-connection.ts | 45 +- .../__tests__/runtime-policy-stores.test.ts | 176 ++++++- .../connection-catalog-document.ts | 35 +- .../storage/src/runtime-policy/coordinator.ts | 15 +- .../storage/src/runtime-policy/operations.ts | 8 +- 78 files changed, 1107 insertions(+), 2370 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/provider-connection-status.test.ts delete mode 100644 apps/desktop/src/renderer/settings/claude-subscription-card.tsx delete mode 100644 packages/runtime/src/__tests__/claude-subscription-usage.test.ts delete mode 100644 packages/runtime/src/claude-subscription-usage.ts delete mode 100644 packages/runtime/src/subscription-cloaked-request.ts diff --git a/apps/desktop/src/main/__tests__/chat-readiness.test.ts b/apps/desktop/src/main/__tests__/chat-readiness.test.ts index f43a60a0a1..f4b4dfd871 100644 --- a/apps/desktop/src/main/__tests__/chat-readiness.test.ts +++ b/apps/desktop/src/main/__tests__/chat-readiness.test.ts @@ -59,18 +59,34 @@ describe('chat readiness guard', () => { }, { name: 'OAuth provider requires login token', - slug: 'claude-subscription', + slug: 'codex-subscription', deps: deps({ connection: connection({ - slug: 'claude-subscription', - name: 'Claude OAuth', - providerType: 'claude-subscription', + slug: 'codex-subscription', + name: 'Codex OAuth', + providerType: 'openai-codex', }), apiKey: null, }), includes: '等待完成 OAuth 登录', reason: 'missing_api_key', }, + { + // Retirement outranks the missing credential: telling this user to sign + // in again would point at a sign-in that no longer exists. + name: 'retired provider cannot send even with a stored credential', + slug: 'claude-subscription', + deps: deps({ + connection: connection({ + slug: 'claude-subscription', + name: 'Claude Subscription', + providerType: 'claude-subscription', + }), + apiKey: 'stored-oauth-token', + }), + includes: '登录方式已从 Maka 移除', + reason: 'provider_retired', + }, ]; for (const entry of table) { 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/chat-readiness.ts b/apps/desktop/src/main/chat-readiness.ts index 1aa1616225..b468f29fd8 100644 --- a/apps/desktop/src/main/chat-readiness.ts +++ b/apps/desktop/src/main/chat-readiness.ts @@ -143,6 +143,8 @@ function messageForReason( } case 'fake_backend': return FAKE_BACKEND_MESSAGE; + case 'provider_retired': + return `模型连接 "${connection.name}" 的登录方式已从 Maka 移除,无法用于发送。请到 设置 · 模型 改用其他连接。`; case 'missing_default_connection': case 'connection_missing': // These reasons are handled before we reach isConnectionReady, 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 f5d735316a..1717e72735 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -399,12 +399,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 cd63d7fb33..8d4421f509 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -60,7 +60,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'; @@ -848,21 +847,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 8b40845337..a802349911 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -97,7 +97,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'; @@ -2102,53 +2101,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/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 33e28af1fe..e085d9ee61 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -384,6 +384,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: '此回答尚无可复制的内容' }, @@ -547,6 +548,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}" 已禁用,发送会失败。请到 设置 · 模型 启用它或选择其他连接。` }, @@ -579,6 +581,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' }, @@ -746,6 +749,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 cb09b62e64..c980867120 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -54,6 +54,9 @@ const zhCopy = { oauthLoadingDetail: '正在读取本机 OAuth 登录状态,读取完成前不会把未知状态显示成未登录。', oauthUnknownDetail: '暂时无法读取本机 OAuth 登录状态;请刷新页面或重新打开设置。', oauthWaitingDetail: '请到账号连接完成登录;登录成功后会自动出现在模型连接里。', + oauthRetired: '此登录方式已停用', + oauthRetiredDetail: + '这条连接使用的登录方式已从 Maka 移除,无法再登录,也无法用于对话。改用 Anthropic API Key 连接即可继续使用 Claude 模型;删除这条连接会一并清除本机保存的登录凭据。', credentialLoadingDetail: '正在读取模型凭据状态,读取完成前暂不测试连接或刷新模型。', credentialUnknownDetail: '模型凭据状态暂时没刷新成功,已避免把未知状态显示成未登录或未配置。', testConnection: '测试连接', @@ -104,7 +107,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': '连接测试失败', @@ -155,10 +158,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 订阅账号。', @@ -169,23 +172,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; @@ -204,6 +190,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', @@ -252,7 +241,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', @@ -303,10 +292,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.', @@ -317,23 +306,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/onboarding-hero-copy.ts b/apps/desktop/src/renderer/onboarding-hero-copy.ts index e6338b64d1..94d704b4ac 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; @@ -78,6 +82,4 @@ function assertNever(state: never): never { 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 b26d63d197..0000000000 --- a/apps/desktop/src/renderer/settings/claude-subscription-card.tsx +++ /dev/null @@ -1,494 +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() { - 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(); - } 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 ? ( -