@stage-labs/metro 0.1.0-beta.307 → 0.1.0-beta.308
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/agent.js +23 -28
- package/dist/child.js +33 -0
- package/dist/claude-env.js +104 -0
- package/dist/cli.js +7 -21
- package/dist/lock.js +62 -0
- package/dist/memory.js +12 -27
- package/dist/root-release-io.js +1 -2
- package/dist/root-release.js +1 -1
- package/dist/route.js +0 -1
- package/hooks/guard.mjs +1 -5
- package/hooks/session-start.mjs +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -8
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/dist/background.js +0 -141
- package/dist/claude.js +0 -204
- package/dist/mcp-config.js +0 -27
- package/dist/native-recovery-scan.js +0 -246
- package/dist/native-recovery-state.js +0 -154
- package/dist/native-recovery.js +0 -164
- package/dist/onboarding.js +0 -44
- package/dist/voice.js +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
- 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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
...CLAUDE_ENV,
|
|
28
28
|
DISABLE_BUG_COMMAND: '1',
|
|
29
29
|
ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
|
|
30
30
|
};
|
|
@@ -2,7 +2,6 @@ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:
|
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { cameThroughProxy } from '@metro-labs/http/api-http';
|
|
4
4
|
import { agentIdForKey } from '../agents/keys.js';
|
|
5
|
-
import { sdkAllowed, sdkConnectionAllowed, SDK_NEEDS_KEY } from '../claude/runner.js';
|
|
6
5
|
import {
|
|
7
6
|
assertBedrockReady,
|
|
8
7
|
bedrockBase,
|
|
@@ -98,11 +97,6 @@ const keyOf = (req: IncomingMessage): string => {
|
|
|
98
97
|
return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
|
|
99
98
|
};
|
|
100
99
|
|
|
101
|
-
const fromSdkRunner = (req: IncomingMessage): boolean => {
|
|
102
|
-
const raw = req.headers['x-metro-runner'];
|
|
103
|
-
return (Array.isArray(raw) ? raw[0] : raw)?.trim() === 'sdk';
|
|
104
|
-
};
|
|
105
|
-
|
|
106
100
|
const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
|
|
107
101
|
|
|
108
102
|
function modelsBody(cfg: ModelConfig): Record<string, unknown> {
|
|
@@ -299,15 +293,9 @@ interface Request {
|
|
|
299
293
|
cfg: ModelConfig;
|
|
300
294
|
}
|
|
301
295
|
|
|
302
|
-
function checkSdkRoute(req: IncomingMessage, route: Route, deps: GatewayDeps): void {
|
|
303
|
-
if (fromSdkRunner(req) && (!sdkAllowed(undefined, deps.config()) || !sdkConnectionAllowed(route.connection)))
|
|
304
|
-
throw new GatewayError(403, 'permission_error', SDK_NEEDS_KEY);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
296
|
async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
|
|
308
297
|
const { path, body } = ask;
|
|
309
298
|
const conn = route.connection;
|
|
310
|
-
checkSdkRoute(req, route, deps);
|
|
311
299
|
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
312
300
|
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
313
301
|
if (conn.provider === 'bedrock') {
|
|
@@ -391,6 +379,6 @@ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse,
|
|
|
391
379
|
}
|
|
392
380
|
if (cameThroughProxy(req)) sendError(res, 401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address');
|
|
393
381
|
else if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
|
|
394
|
-
else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start
|
|
382
|
+
else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start the agent with metro agent');
|
|
395
383
|
return true;
|
|
396
384
|
}
|
|
@@ -5,9 +5,7 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
5
5
|
import { log } from '@metro-labs/core/log';
|
|
6
6
|
import { openrouterModels, openrouterZdrModels } from './openrouter.js';
|
|
7
7
|
import { anthropicModels, bedrockModels } from './provider-models.js';
|
|
8
|
-
import {
|
|
9
|
-
import { harnessRunner, runnerModel, sdkAllowed } from '../claude/runner.js';
|
|
10
|
-
import { sessionLive, sessionRunning, stopSession } from '../claude/session.js';
|
|
8
|
+
import { runnerAlive } from '../claude/session.js';
|
|
11
9
|
import { forgetOne, forgetReported } from './usage.js';
|
|
12
10
|
import {
|
|
13
11
|
addConnection,
|
|
@@ -16,6 +14,7 @@ import {
|
|
|
16
14
|
readModelConfig,
|
|
17
15
|
readModelSnapshot,
|
|
18
16
|
removeConnection,
|
|
17
|
+
routedConnection,
|
|
19
18
|
setFallbacks,
|
|
20
19
|
setRoute,
|
|
21
20
|
updateConnection,
|
|
@@ -45,40 +44,29 @@ const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
|
45
44
|
|
|
46
45
|
export type { ModelApiDeps } from './model-store.js';
|
|
47
46
|
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return
|
|
47
|
+
function runnerModel(cfg: ModelConfig): string | null {
|
|
48
|
+
const conn = routedConnection(cfg);
|
|
49
|
+
if (conn === null || conn.model === '') return null;
|
|
50
|
+
return conn.provider === 'anthropic' ? conn.model : `${conn.provider}:${conn.model}`;
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
function switchLive(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
|
|
55
54
|
const [was, now] = [runnerModel(before), runnerModel(next)];
|
|
56
55
|
if (was === now) return;
|
|
57
56
|
if (deps.switchModel?.(now) === true) log.info({ was, now }, 'model-api: the model changed, so the Agent SDK session switches to it live, with no restart');
|
|
58
|
-
else if (
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function followModel(before: ModelConfig, next: ModelConfig, deps: ModelApiDeps): void {
|
|
62
|
-
const restart = deps.restartSession ?? restartRunningSession;
|
|
63
|
-
const agents = deps.setup?.agents;
|
|
64
|
-
if (harnessRunner(agents) === 'sdk') {
|
|
65
|
-
if (sdkAllowed(agents, next)) switchLive(before, next, deps);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (routeOf(next) !== routeOf(before) && restart())
|
|
69
|
-
log.info({ was: routeOf(before), now: routeOf(next) }, 'model-api: the model changed, so the Claude session restarts on it');
|
|
57
|
+
else if (runnerIsUp(deps)) throw new ApiError('The running Agent SDK could not be told about this model change. Nothing was saved. Let its work finish, stop it, then save and start it again.', 409);
|
|
58
|
+
else log.info({ was, now }, 'model-api: the model changed while no Agent SDK runner is up; its next start loads it');
|
|
70
59
|
}
|
|
71
60
|
|
|
72
61
|
function kept(store: Store, next: ModelConfig, deps: ModelApiDeps, note: string, fields: Record<string, unknown>): unknown {
|
|
73
62
|
const before = store.read();
|
|
74
63
|
store.write(next);
|
|
75
64
|
try {
|
|
76
|
-
|
|
65
|
+
switchLive(before, next, deps);
|
|
77
66
|
} catch (err) {
|
|
78
67
|
store.write(before);
|
|
79
68
|
throw err;
|
|
80
69
|
}
|
|
81
|
-
syncAvailableModelsQuietly(deps.setup ?? {}, next);
|
|
82
70
|
log.info(fields, note);
|
|
83
71
|
return settingsBody(next);
|
|
84
72
|
}
|
|
@@ -218,14 +206,12 @@ function routeFor(path: string, method: string | undefined): Route | number {
|
|
|
218
206
|
return codexRoute(path.slice(CODEX.length), method);
|
|
219
207
|
}
|
|
220
208
|
|
|
221
|
-
const
|
|
209
|
+
const runnerIsUp = (deps: ModelApiDeps): boolean => (deps.runnerAlive ?? (() => runnerAlive()))();
|
|
222
210
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
throw new ApiError('The Agent SDK session is running, and this change would leave a model or fallback it may not use. Let it finish its work, stop it, then save the change.', 409);
|
|
228
|
-
}
|
|
211
|
+
const storeFor = (path: string, deps: ModelApiDeps): Store => ({
|
|
212
|
+
read: deps.read ?? (path === SNAPSHOT ? readModelSnapshot : readModelConfig),
|
|
213
|
+
write: deps.write ?? writeModelConfig,
|
|
214
|
+
});
|
|
229
215
|
|
|
230
216
|
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
231
217
|
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
@@ -234,13 +220,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
|
|
|
234
220
|
res.writeHead(204, cors(req)).end();
|
|
235
221
|
return true;
|
|
236
222
|
}
|
|
237
|
-
const store
|
|
238
|
-
read: deps.read ?? (path === SNAPSHOT ? readModelSnapshot : readModelConfig),
|
|
239
|
-
write: (cfg) => {
|
|
240
|
-
checkRunnerRoute(store.read(), cfg, deps);
|
|
241
|
-
(deps.write ?? writeModelConfig)(cfg);
|
|
242
|
-
},
|
|
243
|
-
};
|
|
223
|
+
const store = storeFor(path, deps);
|
|
244
224
|
if (handleOpenRouterSignIn(req, res, deps, store)) return true;
|
|
245
225
|
const route = routeFor(path, req.method);
|
|
246
226
|
if (typeof route === 'number') {
|
|
@@ -27,8 +27,7 @@ export interface ModelApiDeps {
|
|
|
27
27
|
anthropicBase?: string;
|
|
28
28
|
bedrockControlBase?: string;
|
|
29
29
|
setup?: SetupDeps;
|
|
30
|
-
|
|
31
|
-
sessionRunning?: () => boolean;
|
|
30
|
+
runnerAlive?: () => boolean;
|
|
32
31
|
switchModel?: (model: string | null) => boolean;
|
|
33
32
|
claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
|
|
34
33
|
claudeLoginsRoot?: string;
|
|
@@ -2,7 +2,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import {
|
|
3
3
|
BodyTooLargeError,
|
|
4
4
|
readBody as readBodyBuffer,
|
|
5
|
-
VOICE_MCP_PATH,
|
|
6
5
|
} from '../routes/http.js';
|
|
7
6
|
import {
|
|
8
7
|
authenticate,
|
|
@@ -97,9 +96,7 @@ async function serveGet(
|
|
|
97
96
|
if (served) session.replayMissed();
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
let activeSlots: SessionSlot[] = [];
|
|
99
|
+
let activeSlot: SessionSlot | undefined;
|
|
103
100
|
let stopToolNotices: (() => void) | undefined;
|
|
104
101
|
|
|
105
102
|
const MODEL_NOTICE = 'notifications/metro/model';
|
|
@@ -118,13 +115,12 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
118
115
|
setLiveEvents: (on: boolean) => void;
|
|
119
116
|
switchModel: (model: string | null) => boolean;
|
|
120
117
|
}> {
|
|
121
|
-
await
|
|
118
|
+
await activeSlot?.close();
|
|
122
119
|
const slot = new SessionSlot(options.liveEvents);
|
|
123
|
-
|
|
124
|
-
activeSlots = [slot, voiceSlot];
|
|
120
|
+
activeSlot = slot;
|
|
125
121
|
stopToolNotices?.();
|
|
126
122
|
stopToolNotices = onConnectorToolsChanged(() => {
|
|
127
|
-
|
|
123
|
+
slot.current?.toolsChanged();
|
|
128
124
|
});
|
|
129
125
|
connectCallBridge(() => slot.current);
|
|
130
126
|
connectServerApprovals(() => slot.current);
|
|
@@ -142,13 +138,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
142
138
|
const parsed = await readOrReject(req, res);
|
|
143
139
|
if (!parsed.ok) return;
|
|
144
140
|
await runWithIdentity(identity, async () => {
|
|
145
|
-
const session = await resolveSession(
|
|
146
|
-
isVoicePath(req) ? voiceSlot : slot,
|
|
147
|
-
req,
|
|
148
|
-
parsed.body,
|
|
149
|
-
identity,
|
|
150
|
-
res,
|
|
151
|
-
);
|
|
141
|
+
const session = await resolveSession(slot, req, parsed.body, identity, res);
|
|
152
142
|
if (!session) return;
|
|
153
143
|
if (isStandaloneGet(req)) {
|
|
154
144
|
await serveGet(session, req, res);
|
|
@@ -19,14 +19,3 @@ export const MCP_INSTRUCTIONS =
|
|
|
19
19
|
'prompts are relayed to the chat; the owner answers "yes <id>"/"no <id>". Connectors ' +
|
|
20
20
|
'(third-party tools like Notion, n8n, Gmail) are reached only through `tool_search` then ' +
|
|
21
21
|
'`tool_call`, from a worker.';
|
|
22
|
-
|
|
23
|
-
export const VOICE_MCP_INSTRUCTIONS =
|
|
24
|
-
"Metro's chat tools, opened for a live voice call. The caller hears what you say, so do not " +
|
|
25
|
-
'answer them with `send` or `reply` and do not post the call in a chat; post, react or act on ' +
|
|
26
|
-
'a channel only when the caller asks for it, once. `read` with a `line` (passed verbatim) ' +
|
|
27
|
-
'shows that chat; `group_info` and `list_members` show who is in it; `get_profile` with a ' +
|
|
28
|
-
'`from` names a sender. The owner may block a tool on an account or make it need approval ' +
|
|
29
|
-
'(`list_accounts` shows each `policy`). A blocked call errors. A call needing approval must ' +
|
|
30
|
-
'run in a background worker: on the main thread it is refused, so delegate that exact call ' +
|
|
31
|
-
'and keep talking; the worker waits while the owner is asked in the chat of the call, where ' +
|
|
32
|
-
'they answer "yes <id>"/"no <id>". Connectors are reached through `tool_search` then `tool_call`.';
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { currentBusSeq } from '@metro-labs/core/events';
|
|
3
3
|
import { errMsg } from '@metro-labs/core/log';
|
|
4
4
|
import { newReplayLedger } from '../channels/relay.js';
|
|
5
|
-
import { McpSession, channelLog
|
|
5
|
+
import { McpSession, channelLog } from './session.js';
|
|
6
6
|
import { allowedAgents, type RequestIdentity } from './request-identity.js';
|
|
7
7
|
import { diagnosticTag, type SessionDiagnostic, type SlotDiagnostic } from './diagnostics.js';
|
|
8
8
|
|
|
@@ -25,10 +25,7 @@ export class SessionSlot {
|
|
|
25
25
|
private changing: Promise<void> = Promise.resolve();
|
|
26
26
|
private readonly replacements: SlotReplacement[] = [];
|
|
27
27
|
|
|
28
|
-
constructor(
|
|
29
|
-
live = true,
|
|
30
|
-
private readonly kind: SessionKind = 'chat',
|
|
31
|
-
) {
|
|
28
|
+
constructor(live = true) {
|
|
32
29
|
this.live = live;
|
|
33
30
|
}
|
|
34
31
|
|
|
@@ -111,7 +108,6 @@ export class SessionSlot {
|
|
|
111
108
|
if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
|
|
112
109
|
const id = adoptId ?? randomUUID();
|
|
113
110
|
const session = await McpSession.open({
|
|
114
|
-
kind: this.kind,
|
|
115
111
|
id,
|
|
116
112
|
scope: allowedAgents(identity),
|
|
117
113
|
adopted: adoptId !== undefined,
|
|
@@ -129,7 +125,7 @@ export class SessionSlot {
|
|
|
129
125
|
this.session = session;
|
|
130
126
|
this.recordReplacement(session, adoptId, previous);
|
|
131
127
|
if (this.started && this.live) session.startChannel();
|
|
132
|
-
channelLog('session: opened', 'id', id, '
|
|
128
|
+
channelLog('session: opened', 'id', id, 'adopted', adoptId !== undefined);
|
|
133
129
|
return session;
|
|
134
130
|
}
|
|
135
131
|
|
|
@@ -8,7 +8,7 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
8
8
|
import { allowlistForLine, mayApprove, senderPermitted } from '../agents/map.js';
|
|
9
9
|
import { accountStationNames, stationByName } from '../stations/registry.js';
|
|
10
10
|
import { eventInScope } from '../agents/scope.js';
|
|
11
|
-
import { MCP_INSTRUCTIONS
|
|
11
|
+
import { MCP_INSTRUCTIONS } from './instructions.js';
|
|
12
12
|
import { BoundedEventStore } from './event-store.js';
|
|
13
13
|
import { registerPermissionRelay } from './permission-relay.js';
|
|
14
14
|
import { registerToolHandlers, toolSchemaSignature } from './tool-dispatch.js';
|
|
@@ -55,12 +55,7 @@ function makeTransport(
|
|
|
55
55
|
return t;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export type SessionKind = 'chat' | 'voice';
|
|
59
|
-
|
|
60
|
-
const CHANNEL_CAPABILITIES = { experimental: { 'claude/channel': {}, 'claude/channel/permission': {} } };
|
|
61
|
-
|
|
62
58
|
export interface SessionInit {
|
|
63
|
-
kind?: SessionKind;
|
|
64
59
|
id: string;
|
|
65
60
|
scope: Set<string>;
|
|
66
61
|
adopted: boolean;
|
|
@@ -84,7 +79,6 @@ export class McpSession {
|
|
|
84
79
|
private issuedSchema: string | undefined;
|
|
85
80
|
private announcing = false;
|
|
86
81
|
private readonly onClosed: (session: McpSession) => void;
|
|
87
|
-
private readonly kind: SessionKind;
|
|
88
82
|
private readonly openedAt = new Date().toISOString();
|
|
89
83
|
private clientInitializedAt: string | null = null;
|
|
90
84
|
private lastStreamAttachedAt: string | null = null;
|
|
@@ -92,20 +86,15 @@ export class McpSession {
|
|
|
92
86
|
|
|
93
87
|
private constructor(init: SessionInit) {
|
|
94
88
|
this.id = init.id;
|
|
95
|
-
this.kind = init.kind ?? 'chat';
|
|
96
89
|
this.scope = init.scope;
|
|
97
90
|
this.live = init.live;
|
|
98
91
|
this.issuedSchema = init.adopted ? undefined : toolSchemaSignature();
|
|
99
92
|
this.onClosed = init.onClosed;
|
|
100
93
|
this.eventStore = new BoundedEventStore();
|
|
101
94
|
this.transport = makeTransport(init.id, this.eventStore, init.adopted);
|
|
102
|
-
const voice = init.kind === 'voice';
|
|
103
95
|
this.server = new Server(
|
|
104
96
|
{ name: 'metro', version: '0.1.0' },
|
|
105
|
-
{
|
|
106
|
-
capabilities: { ...(voice ? {} : CHANNEL_CAPABILITIES), tools: { listChanged: true } },
|
|
107
|
-
instructions: voice ? VOICE_MCP_INSTRUCTIONS : MCP_INSTRUCTIONS,
|
|
108
|
-
},
|
|
97
|
+
{ capabilities: { tools: { listChanged: true } }, instructions: MCP_INSTRUCTIONS },
|
|
109
98
|
);
|
|
110
99
|
this.server.oninitialized = (): void => {
|
|
111
100
|
this.clientInitializedAt = new Date().toISOString();
|
|
@@ -130,16 +119,15 @@ export class McpSession {
|
|
|
130
119
|
approves,
|
|
131
120
|
noteDirect,
|
|
132
121
|
answerPermission,
|
|
133
|
-
callMeta: (event) =>
|
|
122
|
+
callMeta: (event) => sharedCalls.bindChat(event, this.scope),
|
|
123
|
+
});
|
|
124
|
+
registerPermissionRelay({
|
|
125
|
+
mcp: this.server,
|
|
126
|
+
relay: this.relay,
|
|
127
|
+
inScope: (line) => this.inScope(line),
|
|
128
|
+
live: this.live,
|
|
129
|
+
log: channelLog,
|
|
134
130
|
});
|
|
135
|
-
if (!voice)
|
|
136
|
-
registerPermissionRelay({
|
|
137
|
-
mcp: this.server,
|
|
138
|
-
relay: this.relay,
|
|
139
|
-
inScope: (line) => this.inScope(line),
|
|
140
|
-
live: this.live,
|
|
141
|
-
log: channelLog,
|
|
142
|
-
});
|
|
143
131
|
this.channel = new ChannelRelay({
|
|
144
132
|
relay: this.relay,
|
|
145
133
|
log: channelLog,
|
|
@@ -214,7 +202,7 @@ export class McpSession {
|
|
|
214
202
|
}
|
|
215
203
|
|
|
216
204
|
private announceCallState(): void {
|
|
217
|
-
if (
|
|
205
|
+
if (!this.streamAttached || this.server.getClientVersion()?.name !== 'metro-sdk-runner') return;
|
|
218
206
|
const active = sharedCalls.snapshot();
|
|
219
207
|
const route = active !== null && this.scope.has(active.agentId) && eventInScope(this.scope, active.line) ? active : null;
|
|
220
208
|
this.server.notification({ method: CALL_STATE, params: { route, ...(route === null ? {} : { meta: { line: route.line } }) } }).catch((err: unknown) => {
|
|
@@ -264,7 +252,7 @@ export class McpSession {
|
|
|
264
252
|
async close(): Promise<void> {
|
|
265
253
|
if (this.closed) return;
|
|
266
254
|
this.closed = true;
|
|
267
|
-
|
|
255
|
+
sharedCalls.revoke();
|
|
268
256
|
this.onClosed(this);
|
|
269
257
|
forgetPromptsOf(this.server);
|
|
270
258
|
this.dropStream();
|
|
@@ -6,9 +6,6 @@ import { bearerSession, clientId, jwksUrl, SigningKeys, workosBase, type KeyStor
|
|
|
6
6
|
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
7
7
|
|
|
8
8
|
const KEYS_FILE = '.jwks';
|
|
9
|
-
const OPERATOR_USER = 'user_01M2TN3K6NRVX675M617GR4WVE';
|
|
10
|
-
|
|
11
|
-
export const isOperatorUser = (user: string): boolean => user === OPERATOR_USER;
|
|
12
9
|
|
|
13
10
|
export function jwksStore(dir: string): KeyStore {
|
|
14
11
|
const path = join(dir, KEYS_FILE);
|
|
@@ -28,7 +25,7 @@ export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys)
|
|
|
28
25
|
const held = owner();
|
|
29
26
|
if (held === null || session.organization === null || session.organization !== held)
|
|
30
27
|
throw new ApiError('this machine belongs to another organization', 403);
|
|
31
|
-
return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member'
|
|
28
|
+
return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member' };
|
|
32
29
|
};
|
|
33
30
|
}
|
|
34
31
|
|
|
@@ -38,7 +38,6 @@ import { applyMcpCors, handleMcpPreflight } from '@metro-labs/http/cors';
|
|
|
38
38
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
39
39
|
|
|
40
40
|
const LRU_CAP = 2_000;
|
|
41
|
-
export const VOICE_MCP_PATH = '/mcp/voice';
|
|
42
41
|
|
|
43
42
|
function dedupKey(
|
|
44
43
|
e: Pick<MetroEvent, 'station' | 'line' | 'messageId'>,
|
|
@@ -214,7 +213,7 @@ export async function startWebhookServer(
|
|
|
214
213
|
|
|
215
214
|
function isMcpPath(req: IncomingMessage): boolean {
|
|
216
215
|
const path = (req.url ?? '').split('?')[0];
|
|
217
|
-
return path === '/' || path === '/mcp' || path ===
|
|
216
|
+
return path === '/' || path === '/mcp' || path === MCP_STATUS_PATH;
|
|
218
217
|
}
|
|
219
218
|
|
|
220
219
|
function flatHeaders(req: IncomingMessage): Record<string, string> {
|