@stage-labs/metro 0.1.0-beta.307 → 0.1.0-beta.309

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/agent.js +23 -28
  2. package/dist/child.js +33 -0
  3. package/dist/claude-env.js +104 -0
  4. package/dist/cli.js +7 -21
  5. package/dist/lock.js +62 -0
  6. package/dist/memory.js +12 -27
  7. package/dist/root-release-io.js +1 -2
  8. package/dist/root-release.js +1 -1
  9. package/dist/route.js +0 -1
  10. package/hooks/guard.mjs +1 -5
  11. package/hooks/session-start.mjs +1 -1
  12. package/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
  14. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -9
  16. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +14 -12
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +39 -118
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  24. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
  26. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
  32. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  33. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +37 -77
  34. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +86 -220
  35. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
  36. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
  37. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
  38. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
  39. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
  40. package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
  41. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
  42. package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
  43. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
  44. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
  45. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  46. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  47. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  48. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  49. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  50. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  51. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
  52. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  53. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  54. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  55. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  56. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  57. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  58. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  59. package/runtime/runtime.json +1 -1
  60. package/runtime/sdk-runner/runner.json +1 -1
  61. package/dist/background.js +0 -141
  62. package/dist/claude.js +0 -204
  63. package/dist/mcp-config.js +0 -27
  64. package/dist/native-recovery-scan.js +0 -246
  65. package/dist/native-recovery-state.js +0 -154
  66. package/dist/native-recovery.js +0 -164
  67. package/dist/onboarding.js +0 -44
  68. package/dist/voice.js +0 -56
  69. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  70. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  71. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  72. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  73. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  74. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  75. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
  76. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
  77. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
  78. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
  79. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
  80. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
  81. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
  82. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
  83. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
  84. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -83
  85. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
  86. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
  87. package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
  88. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  89. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  90. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  91. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -1,8 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
- import { log } from '@metro-labs/core/log';
3
2
  import { isRecord } from '@metro-labs/core/is-record';
4
3
  import { ApiError } from '@metro-labs/http/api-error';
5
- import { readJsonBody, type ApiSession } from '@metro-labs/http/api-http';
4
+ import { readJsonBody } from '@metro-labs/http/api-http';
6
5
  import {
7
6
  claudeSetupStatus,
8
7
  ensureClaudeSetup,
@@ -19,12 +18,10 @@ import {
19
18
  type PermissionMode,
20
19
  type SetupDeps,
21
20
  } from './setup.js';
22
- import { harnessRunner, isHarnessRunner, sdkAllowed, sdkOnLogin, SDK_NEEDS_KEY, setHarnessRunner, setSdkOnLogin, type HarnessRunner } from './runner.js';
23
- import { assertSdkDrained, assertSessionStopped, prepareSessionRunner, setAutostart, withSessionChange, type SessionDeps } from './session.js';
21
+ import { assertSessionStopped, type SessionDeps } from './session.js';
24
22
  import { tryMemoryJob } from './memory-routine.js';
25
23
 
26
24
  const SETUP_BODY_MAX = SYSTEM_PROMPT_MAX * 2;
27
- const OPERATOR_ONLY = 'only the Metro operator can allow the Agent SDK on a Claude login';
28
25
 
29
26
  export interface SetupApiDeps {
30
27
  session?: SessionDeps;
@@ -36,8 +33,6 @@ export interface SetupApiDeps {
36
33
  interface SetupChange {
37
34
  privacy?: boolean;
38
35
  permissionMode?: PermissionMode;
39
- runner?: HarnessRunner;
40
- sdkOnLogin?: boolean;
41
36
  systemPrompt?: string;
42
37
  liveEvents?: boolean;
43
38
  memoryRoutine?: boolean;
@@ -62,19 +57,11 @@ function modeChange(raw: unknown): PermissionMode | undefined {
62
57
  return raw;
63
58
  }
64
59
 
65
- function runnerChange(raw: unknown): HarnessRunner | undefined {
66
- if (raw === undefined) return undefined;
67
- if (!isHarnessRunner(raw)) throw new ApiError('runner must be cli or sdk', 400);
68
- return raw;
69
- }
70
-
71
60
  function setupChange(body: unknown): SetupChange {
72
61
  if (!isRecord(body)) throw new ApiError('a body is required', 400);
73
62
  const change: SetupChange = {
74
63
  privacy: flagChange(body.privacy, 'privacy'),
75
64
  permissionMode: modeChange(body.permissionMode),
76
- runner: runnerChange(body.runner),
77
- sdkOnLogin: flagChange(body.sdkOnLogin, 'sdkOnLogin'),
78
65
  systemPrompt: promptChange(body.systemPrompt),
79
66
  liveEvents: flagChange(body.liveEvents, 'liveEvents'),
80
67
  memoryRoutine: flagChange(body.memoryRoutine, 'memoryRoutine'),
@@ -84,44 +71,11 @@ function setupChange(body: unknown): SetupChange {
84
71
  return given;
85
72
  }
86
73
 
87
- function checkRunnerChange(change: SetupChange, session: ApiSession, agents: string | undefined): void {
88
- if (change.sdkOnLogin !== undefined && session.operator !== true) throw new ApiError(OPERATOR_ONLY, 403);
89
- if (change.runner === 'sdk' && !sdkAllowed(agents, undefined, change.sdkOnLogin ?? sdkOnLogin(agents))) throw new ApiError(SDK_NEEDS_KEY, 403);
90
- }
91
-
92
- function nextRunner(change: SetupChange, agents: string | undefined): HarnessRunner {
93
- const runner = change.runner ?? harnessRunner(agents);
94
- return runner === 'sdk' && change.sdkOnLogin === false && !sdkAllowed(agents, undefined, false) ? 'cli' : runner;
95
- }
96
-
97
74
  function changesSession(change: SetupChange, agents: string | undefined): boolean {
98
- return nextRunner(change, agents) !== harnessRunner(agents)
99
- || (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents))
75
+ return (change.permissionMode !== undefined && change.permissionMode !== permissionMode(agents))
100
76
  || (change.systemPrompt !== undefined && change.systemPrompt.trim() !== systemPrompt(agents));
101
77
  }
102
78
 
103
- function saveSessionSettings(change: SetupChange, runner: HarnessRunner, agents: string | undefined): void {
104
- if (change.sdkOnLogin !== undefined && change.sdkOnLogin !== sdkOnLogin(agents)) {
105
- setSdkOnLogin(change.sdkOnLogin, agents);
106
- log.info({ on: change.sdkOnLogin }, 'claude-setup: the operator changed whether the Agent SDK may run on this box’s Claude login');
107
- }
108
- if (runner !== harnessRunner(agents)) {
109
- setAutostart(false, agents);
110
- setHarnessRunner(runner, agents);
111
- }
112
- if (change.permissionMode !== undefined) setPermissionMode(change.permissionMode, agents);
113
- if (change.systemPrompt !== undefined) setSystemPrompt(change.systemPrompt, agents);
114
- }
115
-
116
- function checkStoppedChange(change: SetupChange, deps: SetupApiDeps): HarnessRunner {
117
- const agents = deps.setup?.agents;
118
- const session = { ...deps.session, agents: deps.session?.agents ?? agents };
119
- const runner = nextRunner(change, agents);
120
- if (changesSession(change, agents)) assertSessionStopped(session);
121
- if (runner !== harnessRunner(agents) && harnessRunner(agents) === 'sdk') assertSdkDrained(session);
122
- return runner;
123
- }
124
-
125
79
  function applyLiveEvents(on: boolean, deps: SetupApiDeps): void {
126
80
  const agents = deps.setup?.agents;
127
81
  if (on !== liveEvents(agents)) setLiveEvents(on, agents);
@@ -133,29 +87,28 @@ function applyMemoryRoutine(on: boolean, deps: SetupApiDeps): void {
133
87
  (deps.memoryJob ?? tryMemoryJob)(on);
134
88
  }
135
89
 
136
- async function applySetupChange(change: SetupChange, session: ApiSession, deps: SetupApiDeps, req: IncomingMessage): Promise<void> {
90
+ function saveSessionSettings(change: SetupChange, deps: SetupApiDeps): void {
91
+ const agents = deps.setup?.agents;
92
+ if (changesSession(change, agents)) assertSessionStopped({ ...deps.session, agents: deps.session?.agents ?? agents });
93
+ if (change.permissionMode !== undefined) setPermissionMode(change.permissionMode, agents);
94
+ if (change.systemPrompt !== undefined) setSystemPrompt(change.systemPrompt, agents);
95
+ }
96
+
97
+ function applySetupChange(change: SetupChange, deps: SetupApiDeps): void {
137
98
  const setup = deps.setup ?? {};
138
- checkRunnerChange(change, session, setup.agents);
139
- const runner = checkStoppedChange(change, deps);
140
- if (runner !== harnessRunner(setup.agents)) {
141
- await prepareSessionRunner(runner, deps.session);
142
- checkRunnerChange(change, session, setup.agents);
143
- if (checkStoppedChange(change, deps) !== runner) throw new ApiError('The model setup changed during preparation. Choose the runner again.', 409);
144
- }
145
- if (req.socket.destroyed) throw new ApiError('The request disconnected during preparation. The runner choice was not changed.', 409);
99
+ saveSessionSettings(change, deps);
146
100
  if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
147
101
  if (change.liveEvents !== undefined) applyLiveEvents(change.liveEvents, deps);
148
102
  if (change.memoryRoutine !== undefined) applyMemoryRoutine(change.memoryRoutine, deps);
149
- saveSessionSettings(change, runner, setup.agents);
150
103
  ensureClaudeSetup(setup);
151
104
  }
152
105
 
153
- export async function setupAnswer(req: IncomingMessage, deps: SetupApiDeps, session: ApiSession): Promise<unknown> {
106
+ export async function setupAnswer(req: IncomingMessage, deps: SetupApiDeps): Promise<unknown> {
154
107
  const setup = deps.setup ?? {};
155
108
  const method = req.method ?? 'GET';
156
109
  if (method === 'GET') return claudeSetupStatus(setup);
157
110
  if (method !== 'POST') throw new ApiError('method not allowed', 405);
158
111
  const change = setupChange(await readJsonBody(req, SETUP_BODY_MAX));
159
- await withSessionChange(() => applySetupChange(change, session, deps, req));
112
+ applySetupChange(change, deps);
160
113
  return claudeSetupStatus(setup);
161
114
  }
@@ -11,8 +11,6 @@ import { claudeDir } from './files.js';
11
11
  import { memoryJobStatus, type MemoryJob } from './memory-routine.js';
12
12
  import { cliPackageDir, hooksDir, skillsDir } from './cli-files.js';
13
13
  import { hasMetroGuard, withMetroHooks } from './hooks.js';
14
- import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
15
- import { harnessRunner, runnerModel, sdkAllowed, sdkOnLogin, type HarnessRunner } from './runner.js';
16
14
  import { readSetupState, writeSetupState } from './setup-state.js';
17
15
 
18
16
  export const PRIVACY_ENV: Record<string, string> = {
@@ -69,9 +67,6 @@ export interface SetupReport {
69
67
  export interface SetupStatus {
70
68
  privacy: boolean;
71
69
  permissionMode: PermissionMode;
72
- runner: HarnessRunner;
73
- runnerAllowed: boolean;
74
- sdkOnLogin: boolean;
75
70
  systemPrompt: string;
76
71
  liveEvents: boolean;
77
72
  memoryRoutine: boolean;
@@ -218,16 +213,23 @@ interface SettingsApplied {
218
213
  guard: boolean;
219
214
  }
220
215
 
221
- function applySettings(dir: string, privacy: boolean, hooks: string | null): SettingsApplied {
216
+ const MODELS_KEYS = new Set(['availableModels', 'enforceAvailableModels']);
217
+
218
+ const withoutMetroModels = (settings: Record<string, unknown>, metroWrote: boolean): Record<string, unknown> =>
219
+ metroWrote ? Object.fromEntries(Object.entries(settings).filter(([key]) => !MODELS_KEYS.has(key))) : settings;
220
+
221
+ function applySettings(dir: string, agents: string, privacy: boolean, hooks: string | null): SettingsApplied {
222
+ const metroModels = readSetupState(agents).modelsByMetro === true;
222
223
  let guard = false;
223
224
  const settings = mergeSettings(dir, (current) => {
224
- const next = withPrivacy(current, privacy);
225
+ const next = withPrivacy(withoutMetroModels(current, metroModels), privacy);
225
226
  const hooked = hooks === null ? null : withMetroHooks(next, hooks);
226
227
  if (hooks !== null && hooked === null)
227
228
  log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: the hooks in settings.json are not in the shape Claude Code reads, so metro did not add its own');
228
229
  guard = hasMetroGuard(hooked ?? next);
229
230
  return hooked ?? next;
230
231
  });
232
+ if (metroModels && settings !== 'unreadable') writeSetupState(agents, { modelsByMetro: false });
231
233
  return { settings, guard };
232
234
  }
233
235
 
@@ -280,9 +282,8 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
280
282
  const skill = placeSkill(dir, skills, METRO_SKILL);
281
283
  const stage = placeSkill(dir, skills, STAGE_SKILL);
282
284
  const memory = placeSkill(dir, skills, MEMORY_SKILL);
283
- const applied = applySettings(dir, privacy, hooks);
285
+ const applied = applySettings(dir, agents, privacy, hooks);
284
286
  const report: SetupReport = { privacy, guard: applied.guard, worker, skill, stage, memory, settings: applied.settings };
285
- syncAvailableModelsQuietly({ ...deps, dir, agents });
286
287
  if ([report.worker, report.skill, report.stage, report.memory].some(written) || report.settings === 'written')
287
288
  log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
288
289
  if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings and metro hooks were not written');
@@ -299,9 +300,6 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
299
300
  return {
300
301
  privacy: privacyEnabled(agents),
301
302
  permissionMode: permissionMode(agents),
302
- runner: harnessRunner(agents),
303
- runnerAllowed: sdkAllowed(agents),
304
- sdkOnLogin: sdkOnLogin(agents),
305
303
  systemPrompt: systemPrompt(agents),
306
304
  liveEvents: liveEvents(agents),
307
305
  memoryRoutine: memoryRoutine(agents),
@@ -323,33 +321,3 @@ export function tryClaudeSetup(deps: SetupDeps = {}): void {
323
321
  log.warn({ err: errMsg(err) }, 'claude-setup: could not apply the Claude Code setup');
324
322
  }
325
323
  }
326
-
327
- const MODELS_KEY = 'availableModels';
328
- const ENFORCE_KEY = 'enforceAvailableModels';
329
-
330
- export const routeOf = (cfg: ModelConfig): string | null => (routedConnection(cfg)?.provider === 'anthropic' ? null : runnerModel(cfg));
331
-
332
- function withAvailableModels(settings: Record<string, unknown>, route: string | null, metroWrote: boolean): Record<string, unknown> {
333
- if (route !== null) return { ...settings, [MODELS_KEY]: [route], [ENFORCE_KEY]: true };
334
- if (!metroWrote) return settings;
335
- return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== MODELS_KEY && key !== ENFORCE_KEY));
336
- }
337
-
338
- export function syncAvailableModels(cfg: ModelConfig, deps: SetupDeps = {}): SettingsOutcome {
339
- const dir = deps.dir ?? claudeDir();
340
- const agents = deps.agents ?? agentsDir();
341
- const route = routeOf(cfg);
342
- const metroWrote = readSetupState(agents).modelsByMetro === true;
343
- return mergeSettings(dir, (current) => {
344
- writeSetupState(agents, { modelsByMetro: route !== null });
345
- return withAvailableModels(current, route, metroWrote);
346
- });
347
- }
348
-
349
- export function syncAvailableModelsQuietly(deps: SetupDeps = {}, cfg?: ModelConfig): void {
350
- try {
351
- syncAvailableModels(cfg ?? readModelConfig(deps.agents ?? agentsDir()), deps);
352
- } catch (err) {
353
- log.warn({ err: errMsg(err) }, 'claude-setup: could not sync the model allowlist');
354
- }
355
- }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { errMsg, log } from '@metro-labs/core/log';
4
- import { agentCommand, agentUser, agentUserExpected, claudeBin } from '../agent-user/user.js';
4
+ import { agentCommand, agentUser, agentUserExpected, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
5
5
  import { runningAsRoot } from '../metro-user/privilege.js';
6
6
  import { PRIVACY_ENV } from './setup.js';
7
7
 
@@ -24,7 +24,7 @@ const ARGS = [
24
24
  const BLOCKS_USAGE = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC';
25
25
  const PROBE_ENV: Record<string, string> = {
26
26
  ...Object.fromEntries(Object.entries(PRIVACY_ENV).filter(([name]) => name !== BLOCKS_USAGE)),
27
- DISABLE_AUTOUPDATER: '1',
27
+ ...CLAUDE_ENV,
28
28
  DISABLE_BUG_COMMAND: '1',
29
29
  ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
30
30
  };
@@ -1,7 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
4
- import { errorFrame, GatewayError, providerStatus, relayFrames, sendError, upstreamMessage, type Watch } from './forward.js';
4
+ import { errorFrame, providerStatus, relayFrames, sendError, upstreamMessage, type Watch } from './forward.js';
5
5
  import type { Connection } from './model-config.js';
6
6
  import { estimateTokens } from './subscription.js';
7
7
  import { UsageScanner } from './usage.js';
@@ -226,8 +226,3 @@ export async function bedrockCount(
226
226
  res.writeHead(200, { 'content-type': 'application/json' });
227
227
  res.end(JSON.stringify({ input_tokens: counted ?? estimateTokens(rewritten.body) }));
228
228
  }
229
-
230
- export function assertBedrockReady(settings: Connection): void {
231
- if (settings.apiKey === '' || settings.region === '')
232
- throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key and a region: add them on the Model page.');
233
- }
@@ -5,13 +5,14 @@ import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { errMsg, log } from '@metro-labs/core/log';
6
6
  import { ApiError } from '@metro-labs/http/api-error';
7
7
  import { readFileSync } from '../agent-user/agent-fs.js';
8
- import { removeHome } from '../agent-user/home-fs.js';
8
+ import { moveHome, removeHome } from '../agent-user/home-fs.js';
9
9
  import { claudeHome } from '../agent-user/user.js';
10
10
  import { claudeAccountIn, type ClaudeAccount, type LoginTarget } from '../claude/login.js';
11
11
  import { readClaudeUsage } from '../claude/usage-probe.js';
12
12
  import { currentOf, reach, refreshed, type TokenSource, type TokenState } from './subscription.js';
13
- import { forgetReported } from './usage.js';
14
- import { addConnection, connectionOf, readModelConfig, setClaudeLogin, writeModelConfig, type ModelConfig } from './model-config.js';
13
+ import { forgetOne, forgetReported } from './usage.js';
14
+ import { readClaudeLogins, writeClaudeLogins, type ClaudeLogin } from './claude-store.js';
15
+ import { loginRef } from './model-config.js';
15
16
 
16
17
  const CREDENTIALS = '.credentials.json';
17
18
  const RENEW_MARGIN_MS = 60_000;
@@ -22,8 +23,7 @@ export interface ClaudeLoginDeps {
22
23
  root?: string;
23
24
  probe?: (env: Record<string, string>) => Promise<unknown>;
24
25
  account?: (env: Record<string, string>) => ClaudeAccount;
25
- read?: () => ModelConfig;
26
- write?: (cfg: ModelConfig) => void;
26
+ dir?: string;
27
27
  }
28
28
 
29
29
  interface ClaudeTokens {
@@ -110,56 +110,80 @@ export function withClaudeLogin(login: string, deps: ClaudeLoginDeps, send: (tok
110
110
  );
111
111
  }
112
112
 
113
- function dropClaudeLogin(login: string, deps: ClaudeLoginDeps): void {
113
+ const folderOf = (login: string, deps: ClaudeLoginDeps): string => claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '';
114
+
115
+ function forgetTokens(login: string): void {
114
116
  seen.delete(login);
115
117
  state.delete(login);
118
+ }
119
+
120
+ function dropFolder(login: string, deps: ClaudeLoginDeps): void {
121
+ forgetTokens(login);
116
122
  try {
117
- removeHome(claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '', true);
123
+ removeHome(folderOf(login, deps), true);
118
124
  } catch (err) {
119
- log.warn({ err: errMsg(err) }, 'claude-logins: could not remove a Claude login that is no longer used');
125
+ log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of a Claude login that is no longer used');
120
126
  }
121
127
  }
122
128
 
123
- export function dropUnusedClaudeLogins(before: ModelConfig, after: ModelConfig, deps: ClaudeLoginDeps = {}): void {
124
- const kept = new Set(after.connections.flatMap((c) => (c.claude === null ? [] : [c.claude.id])));
125
- for (const c of before.connections) if (c.claude !== null && !kept.has(c.claude.id)) dropClaudeLogin(c.claude.id, deps);
129
+ export function removeClaudeLogin(login: string, deps: ClaudeLoginDeps = {}): void {
130
+ const logins = readClaudeLogins(deps.dir);
131
+ if (!logins.some((l) => l.id === login)) throw new ApiError('no such Claude login on this box', 404);
132
+ writeClaudeLogins(logins.filter((l) => l.id !== login), deps.dir);
133
+ dropFolder(login, deps);
134
+ forgetOne(loginRef(login));
135
+ log.info({ login }, 'claude-logins: a Claude login was removed from this box');
136
+ }
137
+
138
+ function takeOver(fresh: string, replaced: string, deps: ClaudeLoginDeps): void {
139
+ const aside = `${folderOf(replaced, deps)}.old-${newId()}`;
140
+ moveHome(folderOf(replaced, deps), aside);
141
+ try {
142
+ moveHome(folderOf(fresh, deps), folderOf(replaced, deps));
143
+ } catch (err) {
144
+ moveHome(aside, folderOf(replaced, deps));
145
+ throw err;
146
+ }
147
+ forgetTokens(replaced);
148
+ try {
149
+ removeHome(aside, true);
150
+ } catch (err) {
151
+ log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of the login that was signed in again');
152
+ }
126
153
  }
127
154
 
128
- function keep(login: string, connection: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
129
- const before = (deps.read ?? readModelConfig)();
130
- const cfg = connection === null ? addConnection(before, { provider: 'anthropic' }) : before;
131
- const id = connection ?? cfg.connections.at(-1)?.id ?? '';
132
- if (connectionOf(cfg, id)?.provider !== 'anthropic') return 'that connection was removed during the sign-in';
133
- const next = setClaudeLogin(cfg, id, { id: login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() });
134
- (deps.write ?? writeModelConfig)(next);
135
- dropUnusedClaudeLogins(before, next, deps);
136
- forgetReported(id);
137
- log.info({ connection: id, plan: account.plan, added: connection === null }, 'claude-logins: the Claude login was kept for its own connection');
155
+ function keep(login: string, replacing: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
156
+ const logins = readClaudeLogins(deps.dir);
157
+ if (replacing !== null && !logins.some((l) => l.id === replacing)) return 'that Claude login was removed during the sign-in';
158
+ if (replacing !== null) takeOver(login, replacing, deps);
159
+ const entry: ClaudeLogin = { id: replacing ?? login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() };
160
+ writeClaudeLogins(replacing === null ? [...logins, entry] : logins.map((l) => (l.id === replacing ? entry : l)), deps.dir);
161
+ forgetReported(loginRef(entry.id));
162
+ log.info({ login: entry.id, plan: account.plan, added: replacing === null }, 'claude-logins: the Claude login was kept on this box');
138
163
  return null;
139
164
  }
140
165
 
141
- function settle(ok: boolean, login: string, connection: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
166
+ function settle(ok: boolean, login: string, replacing: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
142
167
  if (!ok) {
143
- dropClaudeLogin(login, deps);
168
+ dropFolder(login, deps);
144
169
  return Promise.resolve(null);
145
170
  }
146
171
  let problem: string | null;
147
172
  try {
148
173
  const account = (deps.account ?? claudeAccountIn)(claudeLoginEnv(login, deps));
149
- problem = account.signedIn ? keep(login, connection, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
174
+ problem = account.signedIn ? keep(login, replacing, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
150
175
  } catch (err) {
151
176
  problem = errMsg(err);
152
177
  }
153
- if (problem !== null) dropClaudeLogin(login, deps);
178
+ if (problem !== null) dropFolder(login, deps);
154
179
  return Promise.resolve(problem);
155
180
  }
156
181
 
157
182
  export function claudeLoginTarget(asked: string, deps: ClaudeLoginDeps = {}): LoginTarget {
158
- const connection = asked === NEW ? null : asked;
159
- if (connection !== null && connectionOf((deps.read ?? readModelConfig)(), connection)?.provider !== 'anthropic')
160
- throw new ApiError('no such Anthropic connection', 404);
183
+ const replacing = asked === NEW ? null : asked;
184
+ if (replacing !== null && !readClaudeLogins(deps.dir).some((l) => l.id === replacing)) throw new ApiError('no such Claude login on this box', 404);
161
185
  const login = newId();
162
- return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, connection, deps) };
186
+ return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, replacing, deps) };
163
187
  }
164
188
 
165
189
  export function forgetClaudeTokens(): void {
@@ -0,0 +1,55 @@
1
+ import { join } from 'node:path';
2
+ import { ID_RE, newId } from '@metro-labs/core/ids';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
5
+ import { agentsDir } from '../agents/files.js';
6
+
7
+ export interface ClaudeLogin {
8
+ id: string;
9
+ email: string | null;
10
+ plan: string | null;
11
+ savedAt: string;
12
+ }
13
+
14
+ const MODEL_FILE = 'model.json';
15
+
16
+ const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
17
+ const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
18
+
19
+ export function claudeLoginOf(raw: unknown): ClaudeLogin | null {
20
+ if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
21
+ return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
22
+ }
23
+
24
+ export const modelPath = (dir: string): string => join(dir, MODEL_FILE);
25
+
26
+ export const readModelFile = (dir = agentsDir()): unknown =>
27
+ readJson<unknown>(modelPath(dir), null, { warn: 'model.json is unreadable, so the Claude logins of this box are not seen until it is fixed' });
28
+
29
+ export const isV2 = (raw: unknown): raw is Record<string, unknown> & { connections: unknown[] } =>
30
+ isRecord(raw) && raw.version !== 3 && Array.isArray(raw.connections);
31
+
32
+ const unique = (logins: ClaudeLogin[]): ClaudeLogin[] => logins.filter((l, at) => logins.findIndex((o) => o.id === l.id) === at);
33
+
34
+ export function claudeLoginsIn(raw: unknown): ClaudeLogin[] {
35
+ if (isV2(raw)) return unique(raw.connections.flatMap((c: unknown) => (isRecord(c) && c.provider === 'anthropic' ? (claudeLoginOf(c.claude) ?? []) : [])));
36
+ if (!isRecord(raw) || !Array.isArray(raw.claude)) return [];
37
+ return unique(raw.claude.flatMap((c: unknown) => claudeLoginOf(c) ?? []));
38
+ }
39
+
40
+ export const readClaudeLogins = (dir = agentsDir()): ClaudeLogin[] => claudeLoginsIn(readModelFile(dir));
41
+
42
+ export const v3Text = (logins: ClaudeLogin[]): string => JSON.stringify({ version: 3, claude: logins }, null, 2);
43
+
44
+ function v2With(raw: Record<string, unknown> & { connections: unknown[] }, logins: ClaudeLogin[]): Record<string, unknown> {
45
+ const holds = (c: unknown): c is Record<string, unknown> => isRecord(c) && c.provider === 'anthropic' && claudeLoginOf(c.claude) !== null;
46
+ const held = new Map(raw.connections.filter(holds).map((c) => [claudeLoginOf(c.claude)?.id ?? '', c]));
47
+ const others = raw.connections.filter((c) => !holds(c));
48
+ const logged = logins.map((login) => ({ ...(held.get(login.id) ?? { id: newId(), provider: 'anthropic', label: 'Anthropic', model: '' }), claude: login }));
49
+ return { ...raw, connections: [...others, ...logged] };
50
+ }
51
+
52
+ export function writeClaudeLogins(logins: ClaudeLogin[], dir = agentsDir()): void {
53
+ const raw = readModelFile(dir);
54
+ writeSecure(modelPath(dir), isV2(raw) ? JSON.stringify(v2With(raw, logins), null, 2) : v3Text(logins));
55
+ }
@@ -2,54 +2,40 @@ import { randomUUID } from 'node:crypto';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import { arch, platform, release } from 'node:os';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
+ import { errMsg, log } from '@metro-labs/core/log';
5
6
  import { stringOf } from '@metro-labs/http/api-http';
6
- import { OPENAI_API, CODEX_SIGN_IN, refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
7
- import { refreshCodeTokens } from './codex-device.js';
8
7
  import { codexVersion, learnCodexVersion } from './codex-version.js';
9
8
  import { CodexEventTranslator, withUsageLink } from './codex-stream.js';
10
9
  import { ToolNames, toResponsesRequest } from './codex-translate.js';
11
10
  import { GatewayError, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
12
11
  import { parseEvent, SseParser, type SseEvent } from './frames.js';
13
- import type { Connection } from './model-config.js';
14
- import { listCache } from './model-lists.js';
15
- import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
12
+ import { CODEX_SIGN_IN, type CodexAccess, type Connection } from './model-config.js';
13
+ import { answerWhole, errorKind, relayTranslated, sessionHeader } from './subscription.js';
16
14
  import { noteUsageHeaders } from './usage.js';
17
15
 
18
16
  const CODEX_BACKEND = 'https://chatgpt.com/backend-api/codex';
17
+ const OPENAI_API = 'https://api.openai.com/v1';
19
18
  const INVALID = [400, 404, 422];
19
+ const RENEW_FAILED = "Codex refused its sign-in and api.metro.box could not renew it: sign in again on the organization's Models page.";
20
20
 
21
21
  export interface CodexDeps {
22
22
  base?: string;
23
- issuer?: string;
24
23
  fetchImpl?: typeof fetch;
25
- save: (id: string, tokens: CodexTokens) => void;
24
+ refresh: (conn: Connection) => Promise<Connection | null>;
26
25
  }
27
26
 
28
- export type CodexState = TokenState<CodexTokens>;
29
-
30
- export const sharedCodexState: CodexState = new Map();
31
-
32
- const codexLists = listCache<string>('codex');
33
-
34
- const sourceOf = (deps: CodexDeps): TokenSource<CodexTokens> => ({
35
- label: 'Codex',
36
- stale: (tokens) => tokensStale(tokens),
37
- refresh: (tokens) => (tokens.method === 'code' ? refreshCodeTokens(tokens, deps.issuer, deps.fetchImpl) : refreshTokens(tokens, deps.issuer, deps.fetchImpl)),
38
- save: deps.save,
39
- });
40
-
41
27
  const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
42
28
 
43
29
  export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
44
30
 
45
- const baseFor = (tokens: CodexTokens, deps: Pick<CodexDeps, 'base'>): string => deps.base ?? (tokens.method === 'code' ? CODEX_BACKEND : OPENAI_API);
31
+ const baseFor = (access: CodexAccess, deps: Pick<CodexDeps, 'base'>): string => deps.base ?? (access.method === 'code' ? CODEX_BACKEND : OPENAI_API);
46
32
 
47
- function headersFor(tokens: CodexTokens, accept: string, sessionId: string): Record<string, string> {
48
- const plain = { authorization: `Bearer ${tokens.accessToken}`, 'content-type': 'application/json', accept };
49
- if (tokens.method === 'chatgpt') return plain;
33
+ function headersFor(access: CodexAccess, sessionId: string): Record<string, string> {
34
+ const plain = { authorization: `Bearer ${access.accessToken}`, 'content-type': 'application/json', accept: 'text/event-stream' };
35
+ if (access.method === 'chatgpt') return plain;
50
36
  return {
51
37
  ...plain,
52
- 'chatgpt-account-id': tokens.accountId,
38
+ 'chatgpt-account-id': access.accountId ?? '',
53
39
  originator: 'codex_cli_rs',
54
40
  'user-agent': userAgent(),
55
41
  'openai-beta': 'responses=experimental',
@@ -57,9 +43,6 @@ function headersFor(tokens: CodexTokens, accept: string, sessionId: string): Rec
57
43
  };
58
44
  }
59
45
 
60
- export const currentTokens = (conn: Connection, deps: CodexDeps, state: CodexState): Promise<CodexTokens> =>
61
- currentOf(state, conn.id, conn.codex, sourceOf(deps), CODEX_SIGN_IN);
62
-
63
46
  interface Call {
64
47
  body: Record<string, unknown>;
65
48
  model: string;
@@ -69,11 +52,11 @@ interface Call {
69
52
  names: ToolNames;
70
53
  }
71
54
 
72
- function send(call: Call, tokens: CodexTokens): Promise<Response> {
55
+ function send(call: Call, access: CodexAccess): Promise<Response> {
73
56
  const request = toResponsesRequest(call.body, call.model, { promptCacheKey: call.sessionId, names: call.names });
74
- return (call.deps.fetchImpl ?? fetch)(`${baseFor(tokens, call.deps)}/responses`, {
57
+ return (call.deps.fetchImpl ?? fetch)(`${baseFor(access, call.deps)}/responses`, {
75
58
  method: 'POST',
76
- headers: headersFor(tokens, 'text/event-stream', call.sessionId),
59
+ headers: headersFor(access, call.sessionId),
77
60
  body: JSON.stringify(request),
78
61
  signal: call.watch.signal,
79
62
  redirect: 'manual',
@@ -91,22 +74,33 @@ function refusalOf(text: string, status: number): string {
91
74
  return withUsageLink(code, upstreamMessage(text, `OpenAI answered ${String(status)}`));
92
75
  }
93
76
 
94
- export async function codexMessages(
95
- req: IncomingMessage,
96
- res: ServerResponse,
97
- body: Record<string, unknown>,
98
- model: string,
99
- conn: Connection,
100
- deps: CodexDeps,
101
- state: CodexState,
102
- watch: Watch,
103
- ): Promise<void> {
77
+ let learning: Promise<unknown> | null = null;
78
+
79
+ function learnVersion(access: CodexAccess): void {
80
+ if (access.method !== 'code' || learning !== null) return;
81
+ learning = learnCodexVersion()
82
+ .catch((err: unknown) => {
83
+ log.warn({ err: errMsg(err) }, 'gateway: the latest Codex version could not be learned');
84
+ })
85
+ .finally(() => {
86
+ learning = null;
87
+ });
88
+ }
89
+
90
+ async function reach(call: Call, conn: Connection): Promise<Response> {
91
+ if (conn.codex === null) throw new GatewayError(400, 'invalid_request_error', CODEX_SIGN_IN);
92
+ learnVersion(conn.codex);
93
+ const upstream = await send(call, conn.codex);
94
+ if (upstream.status !== 401) return upstream;
95
+ await upstream.body?.cancel();
96
+ const fresh = await call.deps.refresh(conn);
97
+ if (fresh?.codex === null || fresh?.codex === undefined) throw new GatewayError(403, 'permission_error', RENEW_FAILED);
98
+ return send(call, fresh.codex);
99
+ }
100
+
101
+ export async function codexMessages(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, model: string, conn: Connection, deps: CodexDeps, watch: Watch): Promise<void> {
104
102
  const call: Call = { body, model, sessionId: sessionHeader(req) || randomUUID(), watch, deps, names: new ToolNames() };
105
- const upstream = await reach(
106
- () => currentTokens(conn, deps, state),
107
- (tokens) => refreshed(state, conn.id, tokens, sourceOf(deps)),
108
- (tokens) => send(call, tokens),
109
- );
103
+ const upstream = await reach(call, conn);
110
104
  noteUsageHeaders('codex', conn.id, upstream.headers);
111
105
  if (!upstream.ok) {
112
106
  const text = await upstream.text();
@@ -125,19 +119,3 @@ export async function codexMessages(
125
119
  const frames = new SseParser().push(await upstream.text()).map(onEvent).join('');
126
120
  answerWhole(res, frames + translator.close(), conn.id);
127
121
  }
128
-
129
- export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
130
- const base = baseFor(tokens, deps);
131
- const fetchImpl = deps.fetchImpl ?? fetch;
132
- if (tokens.method === 'chatgpt') return codexLists.get(`${base}:${tokens.clientId}`, () => listCodex(tokens, `${base}/models`, fetchImpl));
133
- const version = await learnCodexVersion();
134
- return codexLists.get(`${base}:${tokens.accountId}:${version}`, () => listCodex(tokens, `${base}/models?client_version=${version}`, fetchImpl));
135
- }
136
-
137
- async function listCodex(tokens: CodexTokens, url: string, fetchImpl: typeof fetch): Promise<string[]> {
138
- const res = await fetchImpl(url, { headers: headersFor(tokens, 'application/json', randomUUID()), redirect: 'manual' });
139
- if (!res.ok) throw new GatewayError(res.status, errorKind(res.status, INVALID), `OpenAI would not list the models of this ChatGPT plan (${String(res.status)})`);
140
- const body: unknown = await res.json();
141
- const models: unknown[] = isRecord(body) && Array.isArray(body.models) ? body.models : [];
142
- return models.filter(isRecord).flatMap((m) => ((m.visibility ?? 'list') === 'list' && stringOf(m.slug) !== '' ? [stringOf(m.slug)] : []));
143
- }