@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.
- package/dist/bedrock.js +4 -73
- package/dist/claude-settings.js +40 -0
- package/dist/claude.js +101 -9
- package/dist/hold.js +20 -6
- package/dist/provider-flags.js +9 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +22 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-settings.ts +117 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +9 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/model-api.ts +192 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +5 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/terminal-ws.ts +18 -9
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/bedrock.ts +252 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-auth.ts +231 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-device.ts +91 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-stream.ts +288 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-translate.ts +203 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex.ts +211 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/eventstream.ts +123 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/forward.ts +169 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +193 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/model-config.ts +180 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/openrouter.ts +31 -0
- 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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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 =
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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,
|