@stage-labs/metro 0.1.0-beta.70 → 0.1.0-beta.72

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 (25) hide show
  1. package/dist/bedrock.js +4 -73
  2. package/dist/claude-settings.js +40 -0
  3. package/dist/claude.js +101 -9
  4. package/dist/hold.js +20 -6
  5. package/dist/provider-flags.js +9 -0
  6. package/package.json +1 -1
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +22 -4
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-settings.ts +117 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +9 -3
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -0
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/model-api.ts +192 -0
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +5 -0
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/terminal-ws.ts +18 -9
  14. package/runtime/node_modules/@metro-labs/mcp/src/gateway/bedrock.ts +252 -0
  15. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-auth.ts +231 -0
  16. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-device.ts +91 -0
  17. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-stream.ts +288 -0
  18. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-translate.ts +203 -0
  19. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex.ts +211 -0
  20. package/runtime/node_modules/@metro-labs/mcp/src/gateway/eventstream.ts +123 -0
  21. package/runtime/node_modules/@metro-labs/mcp/src/gateway/forward.ts +169 -0
  22. package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +193 -0
  23. package/runtime/node_modules/@metro-labs/mcp/src/gateway/model-config.ts +180 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/gateway/openrouter.ts +31 -0
  25. package/runtime/runtime.json +1 -1
package/dist/bedrock.js CHANGED
@@ -1,63 +1,10 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { randomBytes } from 'node:crypto';
3
- import { existsSync, readFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { claudeArgs } from './claude.js';
2
+ import { claudeArgs, runClaude } from './claude.js';
7
3
  import { bedrockConfigFromEnv, startBedrockProxy } from './bedrock-proxy.js';
8
- export const PROVIDER_FLAGS = [
9
- 'CLAUDE_CODE_USE_BEDROCK',
10
- 'CLAUDE_CODE_USE_MANTLE',
11
- 'CLAUDE_CODE_USE_VERTEX',
12
- 'CLAUDE_CODE_USE_FOUNDRY',
13
- 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
14
- 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
15
- 'CLAUDE_CODE_USE_GATEWAY',
16
- ];
17
- const CONFLICTING = [
18
- ...PROVIDER_FLAGS,
19
- 'ANTHROPIC_BASE_URL',
20
- 'ANTHROPIC_API_KEY',
21
- 'ANTHROPIC_AUTH_TOKEN',
22
- ];
4
+ import { settingsConflicts, settingsFiles } from './claude-settings.js';
5
+ import { PROVIDER_FLAGS } from './provider-flags.js';
6
+ export { PROVIDER_FLAGS, settingsConflicts, settingsFiles };
23
7
  const SCRUBBED = [...PROVIDER_FLAGS, 'ANTHROPIC_API_KEY', 'AWS_BEARER_TOKEN_BEDROCK'];
24
- export function settingsFiles(cwd = process.cwd(), env = process.env) {
25
- const explicit = env.CLAUDE_CONFIG_DIR?.trim() ?? '';
26
- const configDir = explicit === '' ? join(homedir(), '.claude') : explicit;
27
- return [
28
- ...new Set([
29
- join(configDir, 'settings.json'),
30
- join(cwd, '.claude', 'settings.json'),
31
- join(cwd, '.claude', 'settings.local.json'),
32
- ]),
33
- ];
34
- }
35
- function envBlock(file) {
36
- if (!existsSync(file))
37
- return {};
38
- try {
39
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
40
- const block = parsed.env;
41
- return typeof block === 'object' && block !== null
42
- ? block
43
- : {};
44
- }
45
- catch {
46
- return {};
47
- }
48
- }
49
- export function settingsConflicts(files) {
50
- const out = [];
51
- for (const file of files) {
52
- const block = envBlock(file);
53
- for (const key of CONFLICTING) {
54
- const value = block[key];
55
- if (typeof value === 'string' && value.trim() !== '')
56
- out.push(`${file}: ${key}`);
57
- }
58
- }
59
- return out;
60
- }
61
8
  export function firstPartyModelId(bedrockId) {
62
9
  return bedrockId.replace(/^(?:[a-z-]+\.)?anthropic\./, '').replace(/-v\d+:\d+$/, '');
63
10
  }
@@ -71,22 +18,6 @@ export function claudeEnv(base, port, token, pinned = null) {
71
18
  ANTHROPIC_AUTH_TOKEN: token,
72
19
  };
73
20
  }
74
- function runClaude(args, env) {
75
- return new Promise((resolve, reject) => {
76
- const leaveToChild = () => undefined;
77
- process.on('SIGINT', leaveToChild);
78
- process.on('SIGTERM', leaveToChild);
79
- const child = spawn('claude', args, { stdio: 'inherit', env });
80
- child.on('error', (err) => {
81
- reject(new Error(err.code === 'ENOENT'
82
- ? 'the `claude` command is not on PATH — install Claude Code first'
83
- : err.message));
84
- });
85
- child.on('exit', (code) => {
86
- resolve(code ?? 1);
87
- });
88
- });
89
- }
90
21
  export async function bedrock(argv) {
91
22
  const cfg = bedrockConfigFromEnv();
92
23
  const conflicts = settingsConflicts(settingsFiles());
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { PROVIDER_FLAGS } from './provider-flags.js';
5
+ const CONFLICTING = [...PROVIDER_FLAGS, 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
6
+ export function settingsFiles(cwd = process.cwd(), env = process.env) {
7
+ const explicit = env.CLAUDE_CONFIG_DIR?.trim() ?? '';
8
+ const configDir = explicit === '' ? join(homedir(), '.claude') : explicit;
9
+ return [
10
+ ...new Set([
11
+ join(configDir, 'settings.json'),
12
+ join(cwd, '.claude', 'settings.json'),
13
+ join(cwd, '.claude', 'settings.local.json'),
14
+ ]),
15
+ ];
16
+ }
17
+ function envBlock(file) {
18
+ if (!existsSync(file))
19
+ return {};
20
+ try {
21
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
22
+ const block = parsed.env;
23
+ return typeof block === 'object' && block !== null ? block : {};
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ export function settingsConflicts(files) {
30
+ const out = [];
31
+ for (const file of files) {
32
+ const block = envBlock(file);
33
+ for (const key of CONFLICTING) {
34
+ const value = block[key];
35
+ if (typeof value === 'string' && value.trim() !== '')
36
+ out.push(`${file}: ${key}`);
37
+ }
38
+ }
39
+ return out;
40
+ }
package/dist/claude.js CHANGED
@@ -1,15 +1,107 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
+ import { settingsConflicts, settingsFiles } from './claude-settings.js';
3
+ import { localAgents, pickLocalAgent } from './local.js';
4
+ import { PROVIDER_FLAGS } from './provider-flags.js';
5
+ import { localPort, localUrl } from './runtime.js';
2
6
  const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
7
+ const KEY_HEADER = 'x-metro-key';
8
+ const PROBE_MS = 3_000;
3
9
  export const claudeArgs = (extra) => [
4
10
  ...CHANNEL_FLAGS,
5
11
  ...extra,
6
12
  ];
7
- export function launchClaude(extra) {
8
- const leaveToChild = () => undefined;
9
- process.on('SIGINT', leaveToChild);
10
- process.on('SIGTERM', leaveToChild);
11
- const res = spawnSync('claude', claudeArgs(extra), { stdio: 'inherit' });
12
- if (res.error !== undefined)
13
- throw new Error('the `claude` command is not on PATH — install Claude Code first');
14
- return Promise.resolve(res.status ?? 1);
13
+ const set = (env, name) => (env[name] ?? '').trim() !== '';
14
+ export function pinnedBy(env) {
15
+ if (set(env, 'ANTHROPIC_BASE_URL'))
16
+ return 'ANTHROPIC_BASE_URL';
17
+ return PROVIDER_FLAGS.find((flag) => set(env, flag)) ?? null;
18
+ }
19
+ export function gatewayEnv(base, agentKey, port) {
20
+ if (agentKey === null || pinnedBy(base) !== null)
21
+ return base;
22
+ const own = (base.ANTHROPIC_CUSTOM_HEADERS ?? '').trim();
23
+ const mine = `${KEY_HEADER}: ${agentKey}`;
24
+ return {
25
+ ...base,
26
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${String(port)}/gateway`,
27
+ ANTHROPIC_CUSTOM_HEADERS: own === '' ? mine : `${own}\n${mine}`,
28
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
29
+ };
30
+ }
31
+ export function servingDaemon(body) {
32
+ if (typeof body !== 'object' || body === null)
33
+ return false;
34
+ const mode = body;
35
+ return mode.mode === 'local' && mode.stopped !== true;
36
+ }
37
+ async function daemonServing(base = localUrl()) {
38
+ try {
39
+ const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
40
+ return res.ok && servingDaemon(await res.json());
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ export function agentKey(agents, wanted) {
47
+ if (agents.length === 0)
48
+ return { skip: 'no agent lives on this machine yet, so Claude Code talks to Anthropic directly' };
49
+ try {
50
+ return { key: pickLocalAgent(agents, wanted).key };
51
+ }
52
+ catch {
53
+ return {
54
+ skip: wanted === undefined || wanted === ''
55
+ ? 'several agents live here; set METRO_AGENT=<name> to route through the daemon, Claude Code talks to Anthropic directly for now'
56
+ : `no agent named '${wanted}' lives here, so Claude Code talks to Anthropic directly`,
57
+ };
58
+ }
59
+ }
60
+ function localAgentList() {
61
+ try {
62
+ return localAgents();
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ async function verdict() {
69
+ const pinned = pinnedBy(process.env);
70
+ if (pinned !== null)
71
+ return { skip: `${pinned} is set, so Claude Code keeps talking to it` };
72
+ const conflicts = settingsConflicts(settingsFiles());
73
+ if (conflicts.length > 0)
74
+ return { skip: `a settings file pins the provider (${conflicts.join(', ')}), so Claude Code keeps it` };
75
+ const picked = agentKey(localAgentList(), process.env.METRO_AGENT);
76
+ if ('skip' in picked)
77
+ return picked;
78
+ if (await daemonServing())
79
+ return picked;
80
+ return { skip: 'the daemon is not serving here (stopped, or not running), so Claude Code talks to Anthropic directly' };
81
+ }
82
+ export function runClaude(args, env) {
83
+ return new Promise((resolve, reject) => {
84
+ const leaveToChild = () => undefined;
85
+ process.on('SIGINT', leaveToChild);
86
+ process.on('SIGTERM', leaveToChild);
87
+ const child = spawn('claude', args, { stdio: 'inherit', env });
88
+ child.on('error', (err) => {
89
+ reject(new Error(err.code === 'ENOENT'
90
+ ? 'the `claude` command is not on PATH — install Claude Code first'
91
+ : err.message));
92
+ });
93
+ child.on('exit', (code) => {
94
+ resolve(code ?? 1);
95
+ });
96
+ });
97
+ }
98
+ export async function launchClaude(extra) {
99
+ const decision = await verdict();
100
+ if ('skip' in decision) {
101
+ process.stderr.write(`metro claude: ${decision.skip}\n`);
102
+ return runClaude(claudeArgs(extra), process.env);
103
+ }
104
+ const port = localPort();
105
+ process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
106
+ return runClaude(claudeArgs(extra), gatewayEnv(process.env, decision.key, port));
15
107
  }
package/dist/hold.js CHANGED
@@ -145,8 +145,11 @@ async function listen(server, info) {
145
145
  }
146
146
  }
147
147
  }
148
+ const CLOSE_WAIT_MS = 2_000;
148
149
  const close = (server) => new Promise((resolve) => {
150
+ const timer = setTimeout(resolve, CLOSE_WAIT_MS);
149
151
  server.close(() => {
152
+ clearTimeout(timer);
150
153
  resolve();
151
154
  });
152
155
  server.closeAllConnections();
@@ -160,6 +163,13 @@ function releaseLock(lockFile) {
160
163
  return;
161
164
  }
162
165
  }
166
+ function armFunnel(info, log, ending) {
167
+ if (info.funnel === null || ending !== null)
168
+ return null;
169
+ const funnel = new HeldFunnel(info.funnel, info.port, log);
170
+ funnel.start();
171
+ return funnel;
172
+ }
163
173
  export const holdBanner = (info) => `metro is stopped. Holding http://${info.host}:${String(info.port)}${info.funnel === null ? '' : ' and the Funnel address'} ` +
164
174
  'until Start on the Server page; Ctrl-C or metro stop ends metro serve';
165
175
  export async function holdUntilStart(info, deps = {}) {
@@ -169,8 +179,12 @@ export async function holdUntilStart(info, deps = {}) {
169
179
  process.stderr.write(`${line}\n`);
170
180
  });
171
181
  let finish = () => undefined;
182
+ let ending = null;
172
183
  const ended = new Promise((resolve) => {
173
- finish = resolve;
184
+ finish = (end) => {
185
+ ending ??= end;
186
+ resolve(end);
187
+ };
174
188
  });
175
189
  const server = holdServer(info, () => {
176
190
  finish('start');
@@ -178,14 +192,14 @@ export async function holdUntilStart(info, deps = {}) {
178
192
  const onSignal = () => {
179
193
  finish('exit');
180
194
  };
195
+ signals.on('SIGINT', onSignal);
196
+ signals.on('SIGTERM', onSignal);
181
197
  if (info.lockFile !== null)
182
198
  writeFileSync(info.lockFile, String(process.pid));
183
199
  await listen(server, info);
184
- const funnel = info.funnel === null ? null : new HeldFunnel(info.funnel, info.port, log);
185
- funnel?.start();
186
- signals.on('SIGINT', onSignal);
187
- signals.on('SIGTERM', onSignal);
188
- log(holdBanner(info));
200
+ const funnel = armFunnel(info, log, ending);
201
+ if (ending === null)
202
+ log(holdBanner(info));
189
203
  const end = await ended;
190
204
  signals.off('SIGINT', onSignal);
191
205
  signals.off('SIGTERM', onSignal);
@@ -0,0 +1,9 @@
1
+ export const PROVIDER_FLAGS = [
2
+ 'CLAUDE_CODE_USE_BEDROCK',
3
+ 'CLAUDE_CODE_USE_MANTLE',
4
+ 'CLAUDE_CODE_USE_VERTEX',
5
+ 'CLAUDE_CODE_USE_FOUNDRY',
6
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
7
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
8
+ 'CLAUDE_CODE_USE_GATEWAY',
9
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.70",
3
+ "version": "0.1.0-beta.72",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,9 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from './log.js';
3
- import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
3
+ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http.js';
4
4
  import { ApiError } from './api-error.js';
5
+ import { isRecord } from './is-record.js';
6
+ import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './claude-settings.js';
5
7
  import {
6
8
  claudeDir,
7
9
  deleteClaudeSession,
@@ -13,6 +15,8 @@ import {
13
15
  } from './claude-files.js';
14
16
 
15
17
  const PREFIX = '/api/claude';
18
+ const BODY_MAX = SETTINGS_MAX + 4096;
19
+ const WRITABLE = new Set(['GET', 'DELETE', 'PUT']);
16
20
  const PAGE = 100;
17
21
  const PAGE_MAX = 500;
18
22
 
@@ -39,6 +43,7 @@ const COLLECTIONS: Record<string, Handler> = {
39
43
  projects: (_query, dir) => ({ projects: listClaudeProjects(dir) }),
40
44
  sessions: (query, dir) => ({ sessions: listClaudeSessions(projectOf(query), dir) }),
41
45
  memory: (query, dir) => listMemory(projectOf(query), dir),
46
+ settings: (_query, dir) => ({ files: listClaudeSettings(dir) }),
42
47
  };
43
48
 
44
49
  const ITEMS: Record<string, Handler> = {
@@ -49,8 +54,19 @@ const ITEMS: Record<string, Handler> = {
49
54
  memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
50
55
  };
51
56
 
57
+ const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/').filter(Boolean);
58
+
59
+ async function writeAnswer(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
60
+ const [head = '', item = ''] = parts(path);
61
+ if (head !== 'settings' || item === '') throw new ApiError('method not allowed', 405);
62
+ const body = await readJsonBody(req, BODY_MAX);
63
+ if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
64
+ const seenAt = 'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
65
+ return writeClaudeSettings(item, body.text, seenAt, dir);
66
+ }
67
+
52
68
  function answer(method: string, path: string, query: URLSearchParams, dir: string): unknown {
53
- const rest = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
69
+ const rest = parts(path);
54
70
  const [head = '', item] = rest;
55
71
  if (method === 'DELETE') {
56
72
  if (rest.length !== 2 || head !== 'sessions') throw new ApiError('method not allowed', 405);
@@ -73,7 +89,7 @@ export function handleClaudeRequest(
73
89
  res.writeHead(204, cors(req)).end();
74
90
  return true;
75
91
  }
76
- if (req.method !== 'GET' && req.method !== 'DELETE') {
92
+ if (!WRITABLE.has(req.method ?? '')) {
77
93
  sendJson(req, res, 405, { error: 'method not allowed' });
78
94
  return true;
79
95
  }
@@ -81,7 +97,9 @@ export function handleClaudeRequest(
81
97
  .then((session) => {
82
98
  if (!session) throw new ApiError('unauthorized', 401);
83
99
  deps.authorize(session.subject);
84
- return answer(req.method ?? 'GET', path, new URLSearchParams(search), (deps.dir ?? claudeDir)());
100
+ const dir = (deps.dir ?? claudeDir)();
101
+ if (req.method === 'PUT') return writeAnswer(req, path, dir);
102
+ return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
85
103
  })
86
104
  .then((body) => {
87
105
  sendJson(req, res, 200, body);
@@ -0,0 +1,117 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { ApiError } from './api-error.js';
4
+ import { claudeDir, listClaudeProjects } from './claude-files.js';
5
+ import { isRecord } from './is-record.js';
6
+ import { errMsg } from './log.js';
7
+
8
+ export const SETTINGS_MAX = 256 * 1024;
9
+ const USER_ID = 'user';
10
+ const LOCAL_SUFFIX = '.local';
11
+ const DEFAULT_MODE = 0o644;
12
+
13
+ export type SettingsScope = 'user' | 'project' | 'local';
14
+
15
+ export interface ClaudeSettingsFile {
16
+ id: string;
17
+ scope: SettingsScope;
18
+ label: string;
19
+ path: string;
20
+ exists: boolean;
21
+ editable: boolean;
22
+ text: string;
23
+ modifiedAt: string | null;
24
+ }
25
+
26
+ const missing = (id: string, scope: SettingsScope, label: string, path: string): ClaudeSettingsFile => ({
27
+ id,
28
+ scope,
29
+ label,
30
+ path,
31
+ exists: false,
32
+ editable: true,
33
+ text: '',
34
+ modifiedAt: null,
35
+ });
36
+
37
+ function entryOf(id: string, scope: SettingsScope, label: string, path: string): ClaudeSettingsFile {
38
+ if (!existsSync(path)) return missing(id, scope, label, path);
39
+ const stat = statSync(path);
40
+ const editable = stat.size <= SETTINGS_MAX;
41
+ return {
42
+ id,
43
+ scope,
44
+ label,
45
+ path,
46
+ exists: true,
47
+ editable,
48
+ text: editable ? readFileSync(path, 'utf8') : '',
49
+ modifiedAt: stat.mtime.toISOString(),
50
+ };
51
+ }
52
+
53
+ const PROJECT_FILES: [string, SettingsScope, string][] = [
54
+ ['', 'project', 'settings.json'],
55
+ [LOCAL_SUFFIX, 'local', 'settings.local.json'],
56
+ ];
57
+
58
+ function projectEntries(dir: string): ClaudeSettingsFile[] {
59
+ const out: ClaudeSettingsFile[] = [];
60
+ for (const project of listClaudeProjects(dir)) {
61
+ const cwd = project.cwd;
62
+ if (cwd === null) continue;
63
+ for (const [suffix, scope, name] of PROJECT_FILES) {
64
+ const path = join(cwd, '.claude', name);
65
+ if (existsSync(path)) out.push(entryOf(`${project.id}${suffix}`, scope, cwd, path));
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export function listClaudeSettings(dir = claudeDir()): ClaudeSettingsFile[] {
72
+ return [entryOf(USER_ID, 'user', 'This machine', join(dir, 'settings.json')), ...projectEntries(dir)];
73
+ }
74
+
75
+ function assertSettingsJson(text: string): void {
76
+ if (text.length > SETTINGS_MAX) throw new ApiError('that is more text than a settings file may hold', 413);
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(text);
80
+ } catch (err) {
81
+ throw new ApiError(`that is not valid JSON: ${errMsg(err)}`, 400);
82
+ }
83
+ if (!isRecord(parsed)) throw new ApiError('Claude Code settings must be a JSON object', 400);
84
+ }
85
+
86
+ function modeOf(path: string): number {
87
+ try {
88
+ return statSync(path).mode & 0o777;
89
+ } catch {
90
+ return DEFAULT_MODE;
91
+ }
92
+ }
93
+
94
+ function writeAtomic(path: string, text: string): void {
95
+ const mode = existsSync(path) ? modeOf(path) : DEFAULT_MODE;
96
+ mkdirSync(dirname(path), { recursive: true });
97
+ const tmp = `${path}.metro-${String(process.pid)}`;
98
+ writeFileSync(tmp, text, { mode });
99
+ chmodSync(tmp, mode);
100
+ renameSync(tmp, path);
101
+ }
102
+
103
+ export function writeClaudeSettings(
104
+ id: string,
105
+ text: string,
106
+ seenAt: string | null | undefined,
107
+ dir = claudeDir(),
108
+ ): ClaudeSettingsFile {
109
+ const target = listClaudeSettings(dir).find((file) => file.id === id);
110
+ if (target === undefined) throw new ApiError('no such settings file', 404);
111
+ if (!target.editable) throw new ApiError('that settings file is too large to edit here', 409);
112
+ assertSettingsJson(text);
113
+ if (seenAt !== undefined && seenAt !== target.modifiedAt)
114
+ throw new ApiError('that file changed on disk since you opened it; reload it before saving', 409);
115
+ writeAtomic(target.path, text);
116
+ return entryOf(target.id, target.scope, target.label, target.path);
117
+ }
@@ -1,4 +1,5 @@
1
1
  import { handleSessionApis, type SessionApis } from './session-apis.js';
2
+ import { handleGatewayRequest } from '../gateway/gateway.js';
2
3
  import { handleRelayRequest } from './relay.js';
3
4
  import {
4
5
  createServer,
@@ -348,6 +349,13 @@ function handleSignInRoutes(
348
349
  return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
349
350
  }
350
351
 
352
+ function handleEarlyRoutes(req: IncomingMessage, res: ServerResponse, apis: SessionApis): boolean {
353
+ if (handleHealth(req, res)) return true;
354
+ if (apis.gateway !== undefined && handleGatewayRequest(req, res, apis.gateway)) return true;
355
+ if (handleSignInRoutes(req, res, apis)) return true;
356
+ return handleSessionApis(req, res, apis);
357
+ }
358
+
351
359
  async function handlePreMcpRoutes(
352
360
  req: IncomingMessage,
353
361
  res: ServerResponse,
@@ -355,9 +363,7 @@ async function handlePreMcpRoutes(
355
363
  apis: SessionApis,
356
364
  monitorCall?: MonitorCall,
357
365
  ): Promise<boolean> {
358
- if (handleHealth(req, res)) return true;
359
- if (handleSignInRoutes(req, res, apis)) return true;
360
- if (handleSessionApis(req, res, apis)) return true;
366
+ if (handleEarlyRoutes(req, res, apis)) return true;
361
367
  if (handleUploadRequest(req, res)) return true;
362
368
  if (handleAttachRequest(req, res)) return true;
363
369
  if (apis.relayApi && handleRelayRequest(req, res, apis.relayApi)) return true;
@@ -41,6 +41,7 @@ import {
41
41
  readLocalAgentFile,
42
42
  } from '../db/file-admin.js';
43
43
  import { listAgentFiles, readAgentFile } from '../db/file-source.js';
44
+ import { readModelConfig } from '../gateway/model-config.js';
44
45
  import type { StationName } from '../db/stations.js';
45
46
 
46
47
  export interface LocalModeDeps {
@@ -177,6 +178,8 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
177
178
  updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
178
179
  controlApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart, stop: deps.stop },
179
180
  machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
181
+ modelApi: { authorize: (subject) => { assertLocalOwner(subject); } },
182
+ gateway: { config: readModelConfig },
180
183
  terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
181
184
  identity: { owner: localOwner },
182
185
  mode: localModeInfo,