Skip to content

Commit fd810dd

Browse files
committed
feat(runtime): retire the Claude subscription OAuth provider
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
1 parent 32e3cbb commit fd810dd

73 files changed

Lines changed: 745 additions & 2346 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/main/__tests__/chat-readiness.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,34 @@ describe('chat readiness guard', () => {
5959
},
6060
{
6161
name: 'OAuth provider requires login token',
62-
slug: 'claude-subscription',
62+
slug: 'codex-subscription',
6363
deps: deps({
6464
connection: connection({
65-
slug: 'claude-subscription',
66-
name: 'Claude OAuth',
67-
providerType: 'claude-subscription',
65+
slug: 'codex-subscription',
66+
name: 'Codex OAuth',
67+
providerType: 'openai-codex',
6868
}),
6969
apiKey: null,
7070
}),
7171
includes: '等待完成 OAuth 登录',
7272
reason: 'missing_api_key',
7373
},
74+
{
75+
// Retirement outranks the missing credential: telling this user to sign
76+
// in again would point at a sign-in that no longer exists.
77+
name: 'retired provider cannot send even with a stored credential',
78+
slug: 'claude-subscription',
79+
deps: deps({
80+
connection: connection({
81+
slug: 'claude-subscription',
82+
name: 'Claude Subscription',
83+
providerType: 'claude-subscription',
84+
}),
85+
apiKey: 'stored-oauth-token',
86+
}),
87+
includes: '登录方式已从 Maka 移除',
88+
reason: 'provider_retired',
89+
},
7490
];
7591

7692
for (const entry of table) {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import type { LlmConnection } from '@maka/core/llm-connections';
4+
import { connectionChipStatus } from '../../renderer/settings/provider-connection-status.js';
5+
6+
function connection(overrides: Partial<LlmConnection> = {}): LlmConnection {
7+
return {
8+
slug: 'openai-live',
9+
name: 'OpenAI Live',
10+
providerType: 'openai',
11+
defaultModel: 'gpt-4.1',
12+
enabled: true,
13+
models: [{ id: 'gpt-4.1' }],
14+
modelSource: 'fetched',
15+
createdAt: 1,
16+
updatedAt: 1,
17+
...overrides,
18+
};
19+
}
20+
21+
const retired = connection({
22+
slug: 'claude-subscription',
23+
name: 'Claude Subscription',
24+
providerType: 'claude-subscription',
25+
defaultModel: 'claude-opus-5',
26+
models: [{ id: 'claude-opus-5' }],
27+
});
28+
29+
test('a retired connection reads as broken rather than repairable', () => {
30+
// Nothing else in the list marks this row, so without a status the only
31+
// signal that it has to go is on the detail page the user has no reason to
32+
// open.
33+
assert.deepEqual(connectionChipStatus(retired, 'zh'), {
34+
label: '已停用 · 请删除',
35+
tone: 'error',
36+
});
37+
assert.deepEqual(connectionChipStatus(retired, 'en'), {
38+
label: 'Retired · delete it',
39+
tone: 'error',
40+
});
41+
});
42+
43+
test('retirement outranks every repairable state', () => {
44+
// Each of these would otherwise render a "sign in again" or "it failed, try
45+
// again" status, and for a retired provider both point at nothing.
46+
for (const overrides of [
47+
{ lastTestStatus: 'needs_reauth' as const },
48+
{ lastTestStatus: 'error' as const },
49+
{ lastTestStatus: 'verified' as const },
50+
{ enabled: false },
51+
]) {
52+
assert.deepEqual(
53+
connectionChipStatus({ ...retired, ...overrides }, 'zh'),
54+
{ label: '已停用 · 请删除', tone: 'error' },
55+
`retirement must win over ${JSON.stringify(overrides)}`,
56+
);
57+
}
58+
});
59+
60+
test('a live connection keeps its existing statuses', () => {
61+
assert.equal(connectionChipStatus(connection({ lastTestStatus: 'verified' }), 'zh'), null);
62+
assert.deepEqual(connectionChipStatus(connection({ lastTestStatus: 'needs_reauth' }), 'zh'), {
63+
label: '需要重新登录',
64+
tone: 'attention',
65+
});
66+
assert.deepEqual(connectionChipStatus(connection({ enabled: false }), 'zh'), {
67+
label: '暂不可用',
68+
tone: 'neutral',
69+
});
70+
});

apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ function catalogWithoutDefault(): ConnectionCatalogSnapshot {
1818
{
1919
connectionId: CONNECTION_ID,
2020
revision: 2,
21-
slug: 'claude-subscription',
22-
name: 'Claude OAuth',
23-
providerType: 'claude-subscription',
21+
slug: 'codex-subscription',
22+
name: 'Codex OAuth',
23+
providerType: 'openai-codex',
2424
enabled: true,
25-
enabledModelIds: ['claude-opus-5', 'claude-haiku-4-5'],
26-
models: [{ id: 'claude-opus-5' }, { id: 'claude-haiku-4-5' }],
25+
enabledModelIds: ['gpt-5-codex', 'gpt-5-codex-mini'],
26+
models: [{ id: 'gpt-5-codex' }, { id: 'gpt-5-codex-mini' }],
2727
modelSource: 'fallback',
2828
modelsFetchedAt: 0,
2929
},
@@ -70,9 +70,9 @@ describe('synchronizeRuntimeHostAccountConnection', () => {
7070
});
7171
const { client, selected } = accountClient(rejected);
7272

73-
await synchronizeRuntimeHostAccountConnection(client, 'claude-subscription');
73+
await synchronizeRuntimeHostAccountConnection(client, 'openai-codex');
7474

75-
assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'claude-opus-5' });
75+
assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'gpt-5-codex' });
7676
});
7777

7878
it('selects a default model when model discovery throws', async () => {
@@ -81,9 +81,9 @@ describe('synchronizeRuntimeHostAccountConnection', () => {
8181
};
8282
const { client, selected } = accountClient(throwing);
8383

84-
await synchronizeRuntimeHostAccountConnection(client, 'claude-subscription');
84+
await synchronizeRuntimeHostAccountConnection(client, 'openai-codex');
8585

86-
assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'claude-opus-5' });
86+
assert.deepEqual(selected(), { connectionId: CONNECTION_ID, modelId: 'gpt-5-codex' });
8787
});
8888

8989
it('leaves an existing default alone', async () => {
@@ -93,10 +93,10 @@ describe('synchronizeRuntimeHostAccountConnection', () => {
9393
});
9494
const { client, selectCalls } = accountClient(rejected, {
9595
...catalogWithoutDefault(),
96-
defaultTarget: { connectionId: CONNECTION_ID, modelId: 'claude-haiku-4-5' },
96+
defaultTarget: { connectionId: CONNECTION_ID, modelId: 'gpt-5-codex-mini' },
9797
});
9898

99-
await synchronizeRuntimeHostAccountConnection(client, 'claude-subscription');
99+
await synchronizeRuntimeHostAccountConnection(client, 'openai-codex');
100100

101101
assert.equal(selectCalls(), 0);
102102
});

apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ test('presents both Host OAuth methods without exposing the authorization URL',
4545
});
4646

4747
test('adapts every Host OAuth provider through one Desktop flow', async () => {
48-
const provider = 'claude-subscription' as const;
48+
const provider = 'openai-codex' as const;
4949
const handlers = new Map<
5050
string,
5151
Parameters<RuntimeHostOAuthIpcDeps['ipcMain']['handle']>[1]
@@ -67,8 +67,8 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
6767
{
6868
connectionId: '00000000-0000-4000-8000-000000000001',
6969
revision: 1,
70-
slug: 'claude-subscription',
71-
name: 'Claude Code',
70+
slug: 'openai-codex',
71+
name: 'OpenAI Codex',
7272
providerType: provider,
7373
enabled: true,
7474
enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels],
@@ -88,7 +88,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
8888
attemptId = nextAttemptId;
8989
void presentation
9090
.requestAuthorizationCode(
91-
'https://claude.example/authorize',
91+
'https://codex.example/authorize',
9292
'STATE-HINT',
9393
new AbortController().signal,
9494
)
@@ -136,14 +136,6 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
136136
fetchedAt: 1,
137137
};
138138
},
139-
fetchOAuthAccountUsage: async () => ({
140-
kind: 'available' as const,
141-
provider,
142-
quota: {
143-
fiveHour: { utilization: 20, resetsAt: '2026-08-05T12:00:00.000Z' },
144-
fetchedAt: 1,
145-
},
146-
}),
147139
setDefaultConnectionTarget: async (expectedCatalogRevision, target) => {
148140
assert.equal(expectedCatalogRevision, catalog.revision);
149141
catalog = { ...catalog, revision: catalog.revision + 1, defaultTarget: target };
@@ -188,19 +180,19 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
188180

189181
assert.deepEqual([...handlers.keys()].sort(), [...RUNTIME_HOST_OAUTH_IPC_CHANNELS].sort());
190182

191-
for (const prefix of ['claude-subscription', 'openai-codex', 'xai-oauth']) {
183+
for (const prefix of ['openai-codex', 'xai-oauth']) {
192184
assert.equal(handlers.has(`${prefix}:get-auth-url`), true);
193185
assert.equal(handlers.has(`${prefix}:complete-authorization`), true);
194186
assert.equal(handlers.has(`${prefix}:get-account-state`), true);
195187
assert.equal(handlers.has(`${prefix}:logout`), true);
196188
}
197-
const authorization = await invoke(handlers, 'claude-subscription:get-auth-url');
189+
const authorization = await invoke(handlers, 'openai-codex:get-auth-url');
198190
assert.deepEqual(authorization, { authRequestId: attemptId, stateHint: 'STATE-HINT' });
199-
assert.deepEqual(opened, ['https://claude.example/authorize']);
191+
assert.deepEqual(opened, ['https://codex.example/authorize']);
200192
assert.deepEqual(
201193
await invoke(
202194
handlers,
203-
'claude-subscription:complete-authorization',
195+
'openai-codex:complete-authorization',
204196
attemptId,
205197
'authorization-code#state',
206198
),
@@ -211,16 +203,11 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
211203
connectionId: catalog.connections[0]?.connectionId,
212204
modelId,
213205
});
214-
assert.deepEqual(await invoke(handlers, 'claude-subscription:refresh-quota'), {
215-
ok: true,
216-
});
217-
assert.deepEqual(await invoke(handlers, 'claude-subscription:get-account-state'), {
206+
// No quota: reporting it required the retired provider's own client identity,
207+
// so the account state carries the runtime state alone.
208+
assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), {
218209
provider,
219210
runtimeState: 'authenticated',
220-
quota: {
221-
fiveHour: { utilization: 20, resetsAt: '2026-08-05T12:00:00.000Z' },
222-
fetchedAt: 1,
223-
},
224211
});
225212
});
226213

@@ -285,10 +272,6 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn
285272
fetchConnectionModels: async () => {
286273
throw new Error('provider temporarily unavailable');
287274
},
288-
fetchOAuthAccountUsage: async () => ({
289-
kind: 'unavailable' as const,
290-
reason: 'provider_unavailable' as const,
291-
}),
292275
setDefaultConnectionTarget: async () => {
293276
throw new Error('Default selection must not run after failed discovery');
294277
},
@@ -327,7 +310,7 @@ function oauthProjection(
327310
return {
328311
attemptId,
329312
connectionId,
330-
provider: 'claude-subscription' as const,
313+
provider: 'openai-codex' as const,
331314
phase,
332315
};
333316
}

apps/desktop/src/main/chat-readiness.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ function messageForReason(
143143
}
144144
case 'fake_backend':
145145
return FAKE_BACKEND_MESSAGE;
146+
case 'provider_retired':
147+
return `模型连接 "${connection.name}" 的登录方式已从 Maka 移除,无法用于发送。请到 设置 · 模型 改用其他连接。`;
146148
case 'missing_default_connection':
147149
case 'connection_missing':
148150
// These reasons are handled before we reach isConnectionReady,

apps/desktop/src/main/oauth-connection-identities.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { OAuthLoginProvider } from '@maka/runtime-host/protocol';
22

33
/** Stable Desktop connection identities for Host-supported interactive OAuth providers. */
44
export const INTERACTIVE_OAUTH_CONNECTION_SLUGS = {
5-
'claude-subscription': 'claude-subscription',
65
'openai-codex': 'codex-subscription',
76
'xai-oauth': 'xai-oauth',
87
} as const satisfies Readonly<Record<OAuthLoginProvider, string>>;

apps/desktop/src/main/runtime-host-client.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -400,12 +400,6 @@ export class DesktopRuntimeHostClient {
400400
return this.request("oauth.login.cancel", { attemptId });
401401
}
402402

403-
fetchOAuthAccountUsage(
404-
connectionId: string,
405-
): Promise<OperationOutput<"oauth.account.usage.fetch">> {
406-
return this.request("oauth.account.usage.fetch", { connectionId });
407-
}
408-
409403
async loadSkillCatalog(
410404
context: SkillCatalogWorkspaceContext,
411405
view: SkillCatalogView,

apps/desktop/src/main/runtime-host-oauth-ipc-main.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { randomUUID } from 'node:crypto';
2-
import type { QuotaSnapshot } from '@maka/core/oauth-subscription';
32
import { isOAuthEnrollmentProviderEnabled } from '@maka/runtime/oauth-provider-contracts';
43
import {
54
OAUTH_LOGIN_PROVIDERS,
@@ -39,14 +38,12 @@ export const RUNTIME_HOST_OAUTH_IPC_CHANNELS = Object.freeze([
3938
...OAUTH_LOGIN_PROVIDERS.flatMap((provider) => [
4039
...(provider === 'xai-oauth' ? [] : [`${provider}:is-experimental-enabled`]),
4140
...SHARED_OAUTH_IPC_OPERATIONS.map((operation) => `${provider}:${operation}`),
42-
...(provider === 'claude-subscription' ? [`${provider}:refresh-quota`] : []),
4341
]),
4442
]);
4543

4644
type OAuthClient = RuntimeHostAccountConnectionClient & Pick<
4745
DesktopRuntimeHostClient,
4846
| 'cancelOAuthLogin'
49-
| 'fetchOAuthAccountUsage'
5047
| 'queryOAuthLogin'
5148
| 'startOAuthLogin'
5249
>;
@@ -67,7 +64,6 @@ interface ActiveOAuthAttempt {
6764
/** Adapts the existing Desktop OAuth UI to the Host's provider-neutral OAuth operations. */
6865
export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void {
6966
const activeAttempts = new Map<string, ActiveOAuthAttempt>();
70-
const accountUsage = new Map<OAuthLoginProvider, QuotaSnapshot>();
7167
const providerEnabled = deps.isProviderEnabled ?? isOAuthEnrollmentProviderEnabled;
7268

7369
for (const provider of OAUTH_LOGIN_PROVIDERS) {
@@ -168,7 +164,7 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
168164
runtimeHostAccountCredential(connection),
169165
);
170166
if (credential?.configured) {
171-
return accountState(provider, 'authenticated', accountUsage.get(provider));
167+
return accountState(provider, 'authenticated');
172168
}
173169
const authorizing = [...activeAttempts.values()].some(
174170
(attempt) => attempt.provider === provider,
@@ -192,29 +188,13 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
192188
? { ok: true as const }
193189
: actionFailure('Unable to refresh OAuth account', 'refresh_failed');
194190
});
195-
if (provider === 'claude-subscription') {
196-
deps.ipcMain.handle(channel('refresh-quota'), async () => {
197-
const connection = findRuntimeHostAccountConnection(
198-
await deps.client.loadConnectionCatalog(),
199-
provider,
200-
);
201-
if (!connection) return actionFailure('OAuth account is not connected');
202-
const result = await deps.client.fetchOAuthAccountUsage(connection.connectionId);
203-
if (result.kind !== 'available') {
204-
return actionFailure(`OAuth account usage is unavailable: ${result.reason}`);
205-
}
206-
accountUsage.set(provider, result.quota);
207-
return { ok: true as const };
208-
});
209-
}
210191
deps.ipcMain.handle(channel('logout'), async () => {
211192
await cancelProviderAttempts(deps, activeAttempts, provider);
212193
try {
213194
await disableRuntimeHostAccountConnection(deps.client, provider);
214195
} catch {
215196
return actionFailure('Unable to remove OAuth account');
216197
}
217-
accountUsage.delete(provider);
218198
deps.emitConnectionListChanged();
219199
return { ok: true as const };
220200
});
@@ -304,9 +284,8 @@ function isProviderAttempt(
304284
function accountState(
305285
provider: OAuthLoginProvider,
306286
runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated',
307-
quota?: QuotaSnapshot,
308287
) {
309-
return { provider, runtimeState, ...(quota ? { quota } : {}) };
288+
return { provider, runtimeState };
310289
}
311290

312291
function providerDisabled() {

0 commit comments

Comments
 (0)