@stage-labs/metro 0.1.0-beta.169 → 0.1.0-beta.170

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.169",
3
+ "version": "0.1.0-beta.170",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.169",
3
+ "version": "0.1.0-beta.170",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -1,4 +1,3 @@
1
- import { webhookPort } from '../net/tunnel.js';
2
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
2
  import { parseId } from '@metro-labs/core/ids';
4
3
  import { log } from '@metro-labs/core/log';
@@ -63,20 +62,6 @@ export function target(path: string): Target {
63
62
  return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
64
63
  }
65
64
 
66
- const localMcpEndpoint = (): string => `http://127.0.0.1:${String(webhookPort())}/mcp`;
67
-
68
- interface KeyPayload {
69
- key: string | null;
70
- endpoint: string | null;
71
- }
72
-
73
- const credentials = (key: string): KeyPayload => ({ key, endpoint: `${localMcpEndpoint()}?token=${key}` });
74
-
75
- function keyPayload(agent: AgentSummary): KeyPayload {
76
- const value = agent.owned ? agent.key : null;
77
- return value === null ? { key: null, endpoint: null } : credentials(value);
78
- }
79
-
80
65
  function livenessPayload(
81
66
  agent: AgentSummary,
82
67
  live: Map<string, { connected: boolean; lastSeenAt: number }>,
@@ -100,7 +85,6 @@ function agentPayload(
100
85
  owned: agent.owned,
101
86
  connector_ids: connectors.get(agent.id) ?? [],
102
87
  ...livenessPayload(agent, live),
103
- ...keyPayload(agent),
104
88
  };
105
89
  }
106
90
 
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
- import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
3
+ import { apiFailure, apiSession, cors, readJsonBody, requireAdmin, sendJson } from '@metro-labs/http/api-http';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import { parseId } from '@metro-labs/core/ids';
@@ -14,14 +14,14 @@ const STATION_NAMES = new Set<string>(STATIONS);
14
14
 
15
15
  export interface AgentBundle {
16
16
  version: 1;
17
- agent: { id: string; name: string; key: string; stations: LoadedAccount[] };
17
+ agent: { id: string; name: string; stations: LoadedAccount[] };
18
18
  connectors: LoadedConnector[];
19
19
  }
20
20
 
21
21
  export const loadedAgentOf = (bundle: AgentBundle): LoadedAgent => ({
22
22
  id: bundle.agent.id,
23
23
  name: bundle.agent.name,
24
- key: bundle.agent.key,
24
+ key: null,
25
25
  accounts: bundle.agent.stations,
26
26
  connectors: [],
27
27
  });
@@ -67,11 +67,10 @@ function connectorOf(raw: unknown): LoadedConnector {
67
67
 
68
68
  function agentOf(raw: unknown): AgentBundle['agent'] {
69
69
  if (!isRecord(raw)) throw bad('not a v1 agent bundle');
70
- const { id, name, key, stations } = raw;
70
+ const { id, name, stations } = raw;
71
71
  if (typeof id !== 'string' || parseId(id) === null) throw bad('agent id is not an id');
72
- if (typeof key !== 'string' || key === '') throw bad('agent has no key');
73
72
  if (!Array.isArray(stations)) throw bad('agent has no station list');
74
- return { id, name: typeof name === 'string' ? name : '', key, stations: stations.map(stationOf) };
73
+ return { id, name: typeof name === 'string' ? name : '', stations: stations.map(stationOf) };
75
74
  }
76
75
 
77
76
  export function parseBundle(raw: unknown): AgentBundle {
@@ -106,6 +105,7 @@ export function handleBundleRequest(req: IncomingMessage, res: ServerResponse, d
106
105
  apiSession(req)
107
106
  .then((session) => {
108
107
  if (!session) throw new ApiError('unauthorized', 401);
108
+ requireAdmin(session);
109
109
  return answer(req, deps, session.subject, path);
110
110
  })
111
111
  .then((body) => {
@@ -174,15 +174,13 @@ export function assertLocalOwner(subject: string, dir = agentsDir()): void {
174
174
  if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
175
175
  }
176
176
 
177
- function assertImportable(agent: LoadedAgent): asserts agent is LoadedAgent & { key: string } {
177
+ function assertImportable(agent: LoadedAgent): void {
178
178
  const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
179
179
  if (immovable !== undefined)
180
180
  throw new AgentAdminError(
181
181
  `a ${immovable.station} endpoint needs a public url and cannot live on a local daemon`,
182
182
  400,
183
183
  );
184
- if (agent.key === null)
185
- throw new AgentAdminError('that agent has no key; reset it on metro.box first', 400);
186
184
  }
187
185
 
188
186
  interface ImportTarget {
@@ -190,7 +188,7 @@ interface ImportTarget {
190
188
  previous: AgentFile | undefined;
191
189
  }
192
190
 
193
- function importTarget(dir: string, agent: LoadedAgent & { key: string }): ImportTarget {
191
+ function importTarget(dir: string, agent: LoadedAgent): ImportTarget {
194
192
  const existing = storedAgents(dir);
195
193
  const same = existing.find((s) => s.file.id === agent.id);
196
194
  if (same === undefined && existing.length > 0)
@@ -210,15 +208,15 @@ export async function localImportAgent(
210
208
  assertLocalOwner(subject, dir);
211
209
  assertImportable(agent);
212
210
  const { path, previous } = importTarget(dir, agent);
213
- const file = fileFor(agent, localOwner(dir), path, previous, mode);
211
+ const key = previous?.key ?? agent.key ?? newApiKey();
212
+ const file = fileFor({ ...agent, key }, localOwner(dir), path, previous, mode);
214
213
  ensureSecureDir(join(path, '..'));
215
214
  save({ path, file });
216
- if (previous !== undefined && previous.key !== agent.key) unregisterAgentKey(agent.id);
217
- registerKey(agent.key, agent.id);
215
+ registerKey(key, agent.id);
218
216
  return Promise.resolve({
219
217
  id: agent.id,
220
218
  name: agent.name,
221
- key: agent.key,
219
+ key,
222
220
  stations: file.stations.length,
223
221
  });
224
222
  }
@@ -18,7 +18,6 @@ import {
18
18
  startWebhookServer,
19
19
  trainEventToMetroEvent,
20
20
  } from '../routes/http.js';
21
- import { localAgentKey } from '../stations/materialize.js';
22
21
  import { agentsDir, fileSource } from '../agents/files.js';
23
22
  import { syncPluginServers } from '../connectors/plugin-sync.js';
24
23
  import { readLocalConnectors } from '../connectors/store.js';
@@ -36,7 +35,6 @@ import {
36
35
  agentLiveness,
37
36
  createMetroMcp,
38
37
  } from '../mcp/index.js';
39
- import { metroCall } from '../mcp/ctx.js';
40
38
  import { gatherAccountsForAgents } from '../mcp/accounts.js';
41
39
  import {
42
40
  accountStationCapabilities,
@@ -80,15 +78,6 @@ function announceLocalEndpoint(): void {
80
78
  process.stderr.write(
81
79
  `\n${tunnel === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint()}`,
82
80
  );
83
- const key = localAgentKey();
84
- if (key === null) {
85
- log.info('no agent on this machine yet');
86
- return;
87
- }
88
- const url = `http://127.0.0.1:${String(webhookPort())}/mcp?token=${key}`;
89
- process.stderr.write(
90
- `\nConnect an agent on this machine:\n\n claude mcp add --transport http metro "${url}"\n\n`,
91
- );
92
81
  }
93
82
 
94
83
  async function syncStations(station: StationName): Promise<void> {
@@ -135,7 +124,7 @@ installBearerSessions(agentsDir(), localOwner);
135
124
  emit,
136
125
  sessionApis(),
137
126
  metroMcp.httpHandler,
138
- metroCall,
127
+ true,
139
128
  );
140
129
  metroMcp.startInbound();
141
130
  syncPluginServers(readLocalConnectors());
@@ -143,7 +143,7 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
143
143
  return createClaudeSkill(body.name, typeof body.text === 'string' ? body.text : undefined, dir);
144
144
  }
145
145
 
146
- const ADMIN_ONLY = /^\/(login|session|version|setup)(\/|$)/;
146
+ const ADMIN_ONLY = /^\/api\/claude\/(login|session|version|setup)(\/|$)/;
147
147
  const LOGIN = 'login';
148
148
  const SESSION = 'session';
149
149
  const SETUP = 'setup';
@@ -1,30 +1,21 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { stationByName } from '../stations/registry.js';
3
2
  import { errMsg, log } from '@metro-labs/core/log';
4
3
  import { subscribeEvents, type MetroEvent } from '@metro-labs/core/events';
5
- import { callTargetDenied, eventInScope } from '../agents/scope.js';
4
+ import { eventInScope } from '../agents/scope.js';
6
5
  import { hasAnyKey } from '../agents/keys.js';
7
6
  import {
8
7
  allowedAgents,
9
8
  authenticate,
10
9
  } from '../mcp/request-identity.js';
11
- import { METRO_VERSION } from '@metro-labs/core/version';
12
-
13
- export type MonitorCall = (
14
- train: string,
15
- action: string,
16
- args: Record<string, unknown>,
17
- ) => Promise<{ result: unknown }>;
18
10
 
19
11
  const KEEPALIVE_MS = 25_000;
20
- const CALL_BODY_MAX = 256 * 1024;
21
12
 
22
13
  const monitorEnabled = (): boolean => hasAnyKey();
23
14
 
24
15
  function cors(req: IncomingMessage): Record<string, string> {
25
16
  return {
26
17
  'access-control-allow-origin': req.headers.origin ?? '*',
27
- 'access-control-allow-methods': 'GET, POST, OPTIONS',
18
+ 'access-control-allow-methods': 'GET, OPTIONS',
28
19
  'access-control-allow-headers': 'Authorization, Content-Type',
29
20
  'access-control-max-age': '86400',
30
21
  vary: 'Origin',
@@ -41,33 +32,6 @@ function sendJson(
41
32
  res.end(JSON.stringify(body));
42
33
  }
43
34
 
44
- function parseCallArgs(raw: string): Record<string, unknown> {
45
- if (!raw) return {};
46
- const parsed = JSON.parse(raw) as { args?: unknown };
47
- const value =
48
- parsed && typeof parsed === 'object' && 'args' in parsed
49
- ? parsed.args
50
- : parsed;
51
- if (!value || typeof value !== 'object' || Array.isArray(value))
52
- throw new Error('call body must be a JSON object');
53
- return value as Record<string, unknown>;
54
- }
55
-
56
- async function readCallArgs(
57
- req: IncomingMessage,
58
- ): Promise<Record<string, unknown>> {
59
- const chunks: Buffer[] = [];
60
- let total = 0;
61
- for await (const chunk of req) {
62
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);
63
- total += buf.length;
64
- if (total > CALL_BODY_MAX)
65
- throw new Error(`request body exceeds ${CALL_BODY_MAX} bytes`);
66
- chunks.push(buf);
67
- }
68
- return parseCallArgs(Buffer.concat(chunks).toString('utf8').trim());
69
- }
70
-
71
35
  function startTailStream(
72
36
  req: IncomingMessage,
73
37
  res: ServerResponse,
@@ -122,67 +86,12 @@ function startTailStream(
122
86
  req.on('error', cleanup);
123
87
  }
124
88
 
125
- async function handleCall(
126
- req: IncomingMessage,
127
- res: ServerResponse,
128
- train: string,
129
- action: string,
130
- call: MonitorCall,
131
- allowed: Set<string>,
132
- ): Promise<void> {
133
- let args: Record<string, unknown>;
134
- try {
135
- args = await readCallArgs(req);
136
- } catch (err) {
137
- sendJson(res, req, 400, { error: `bad JSON body: ${errMsg(err)}` });
138
- return;
139
- }
140
- if (callTargetDenied(allowed, train, args)) {
141
- sendJson(res, req, 403, {
142
- error: 'metro: this account is outside your authorized scope',
143
- });
144
- return;
145
- }
146
- if (stationByName(train)?.hasTrain === false) {
147
- sendJson(res, req, 400, {
148
- error: `metro: station '${train}' runs in-core and takes no calls`,
149
- });
150
- return;
151
- }
152
- try {
153
- const { result } = await call(train, action, args);
154
- sendJson(res, req, 200, { result });
155
- } catch (err) {
156
- sendJson(res, req, 502, { error: errMsg(err) });
157
- }
158
- }
159
-
160
89
  function routeApi(
161
90
  req: IncomingMessage,
162
91
  res: ServerResponse,
163
92
  path: string,
164
- call: MonitorCall,
165
93
  allowed: Set<string>,
166
94
  ): void {
167
- const callMatch = /^\/api\/call\/([^/]+)\/([^/]+)$/.exec(path);
168
- if (callMatch) {
169
- if (req.method !== 'POST') {
170
- sendJson(res, req, 405, { error: 'method not allowed' });
171
- return;
172
- }
173
- handleCall(
174
- req,
175
- res,
176
- callMatch[1] ?? '',
177
- callMatch[2] ?? '',
178
- call,
179
- allowed,
180
- ).catch((err: unknown) => {
181
- log.warn({ err: errMsg(err) }, 'monitor: call handler error');
182
- if (!res.headersSent) sendJson(res, req, 500, { error: errMsg(err) });
183
- });
184
- return;
185
- }
186
95
  if (path === '/api/tail') {
187
96
  if (req.method !== 'GET') {
188
97
  sendJson(res, req, 405, { error: 'method not allowed' });
@@ -194,11 +103,7 @@ function routeApi(
194
103
  sendJson(res, req, 404, { error: 'not found' });
195
104
  }
196
105
 
197
- function preflight(
198
- req: IncomingMessage,
199
- res: ServerResponse,
200
- path: string,
201
- ): boolean {
106
+ function preflight(req: IncomingMessage, res: ServerResponse): boolean {
202
107
  if (!monitorEnabled()) {
203
108
  sendJson(res, req, 404, { error: 'not found' });
204
109
  return true;
@@ -207,31 +112,21 @@ function preflight(
207
112
  res.writeHead(204, cors(req)).end();
208
113
  return true;
209
114
  }
210
- if (req.method === 'GET' && path === '/api/health') {
211
- sendJson(res, req, 200, {
212
- ok: true,
213
- service: 'metro',
214
- version: METRO_VERSION,
215
- uptime_s: Math.round(process.uptime()),
216
- });
217
- return true;
218
- }
219
115
  return false;
220
116
  }
221
117
 
222
118
  export function handleMonitorRequest(
223
119
  req: IncomingMessage,
224
120
  res: ServerResponse,
225
- call: MonitorCall,
226
121
  ): boolean {
227
122
  const path = (req.url ?? '').split('?', 2)[0] ?? '';
228
123
  if (!path.startsWith('/api/')) return false;
229
- if (preflight(req, res, path)) return true;
124
+ if (preflight(req, res)) return true;
230
125
  const identity = authenticate(req);
231
126
  if (!identity) {
232
127
  sendJson(res, req, 401, { error: 'unauthorized' });
233
128
  return true;
234
129
  }
235
- routeApi(req, res, path, call, allowedAgents(identity));
130
+ routeApi(req, res, path, allowedAgents(identity));
236
131
  return true;
237
132
  }
@@ -38,7 +38,6 @@ import { handleModeRequest } from '@metro-labs/http/mode-api';
38
38
  import { handleUploadRequest } from '../files/upload-api.js';
39
39
  import {
40
40
  handleMonitorRequest,
41
- type MonitorCall,
42
41
  } from '../monitor/api.js';
43
42
  import { applyMcpCors, handleMcpPreflight } from '@metro-labs/http/cors';
44
43
  import { METRO_VERSION } from '@metro-labs/core/version';
@@ -181,11 +180,11 @@ export async function startWebhookServer(
181
180
  emit: Emit,
182
181
  apis: SessionApis = {},
183
182
  mcp?: McpHandler,
184
- monitorCall?: MonitorCall,
183
+ monitor = false,
185
184
  ): Promise<Server> {
186
185
  const port = webhookPort();
187
186
  const server = createServer((req, res) => {
188
- handleRequest(req, res, emit, apis, mcp, monitorCall).catch(
187
+ handleRequest(req, res, emit, apis, mcp, monitor).catch(
189
188
  (err: unknown) => {
190
189
  log.warn({ err: errMsg(err) }, 'webhook handler error');
191
190
  if (!res.headersSent) res.writeHead(500).end();
@@ -340,7 +339,7 @@ async function handlePreMcpRoutes(
340
339
  res: ServerResponse,
341
340
  emit: Emit,
342
341
  apis: SessionApis,
343
- monitorCall?: MonitorCall,
342
+ monitor = false,
344
343
  ): Promise<boolean> {
345
344
  if (handleEarlyRoutes(req, res, apis)) return true;
346
345
  if (handleUploadRequest(req, res)) return true;
@@ -348,7 +347,7 @@ async function handlePreMcpRoutes(
348
347
  if (apis.relayApi && handleRelayRequest(req, res, apis.relayApi)) return true;
349
348
  if (await handleWebhookRoute(req, res, emit)) return true;
350
349
  if (await handleThreemaCallback(req, res)) return true;
351
- return Boolean(monitorCall && handleMonitorRequest(req, res, monitorCall));
350
+ return monitor && handleMonitorRequest(req, res);
352
351
  }
353
352
 
354
353
  async function handleRequest(
@@ -357,9 +356,9 @@ async function handleRequest(
357
356
  emit: Emit,
358
357
  apis: SessionApis = {},
359
358
  mcp?: McpHandler,
360
- monitorCall?: MonitorCall,
359
+ monitor = false,
361
360
  ): Promise<void> {
362
- if (await handlePreMcpRoutes(req, res, emit, apis, monitorCall)) return;
361
+ if (await handlePreMcpRoutes(req, res, emit, apis, monitor)) return;
363
362
  if (mcp && isMcpPath(req)) {
364
363
  applyMcpCors(req, res);
365
364
  if (handleMcpPreflight(req, res)) return;
@@ -143,14 +143,13 @@ const relayApi: RelayApiDeps = {
143
143
  function bundleApi(deps: LocalModeDeps): BundleApiDeps {
144
144
  return {
145
145
  bundle: async (subject, agentId) => {
146
- const { agent } = await localOwnedAgentOrThrow(subject, agentId);
146
+ await localOwnedAgentOrThrow(subject, agentId);
147
147
  const file = readLocalAgentFile(agentId);
148
148
  const bundle: AgentBundle = {
149
149
  version: 1,
150
- agent: { id: file.id, name: file.name ?? '', key: file.key ?? '', stations: file.stations },
150
+ agent: { id: file.id, name: file.name ?? '', stations: file.stations },
151
151
  connectors: readLocalConnectors().map((c) => ({ id: c.id, name: c.name, url: c.url, transport: c.transport, config: { ...c.config } })),
152
152
  };
153
- if (bundle.agent.key === '') throw new ApiError(`agent ${agent.id} has no key to bundle`, 400);
154
153
  return bundle;
155
154
  },
156
155
  restore: async (subject, bundle, mode) => {
@@ -200,7 +200,6 @@ const stationLabels = (m: Map<StationName, number>): string[] =>
200
200
  [...m].map(([station, n]) => `${station}(${n})`);
201
201
 
202
202
  function applyKeyMap(list: LoadedAgent[]): void {
203
- lastKey = list.find((a) => a.key !== null)?.key ?? null;
204
203
  setKeyMap(
205
204
  list.flatMap((agent) =>
206
205
  agent.key === null ? [] : [{ key: agent.key, agentId: agent.id }],
@@ -208,12 +207,6 @@ function applyKeyMap(list: LoadedAgent[]): void {
208
207
  );
209
208
  }
210
209
 
211
- let lastKey: string | null = null;
212
-
213
- export function localAgentKey(): string | null {
214
- return lastKey;
215
- }
216
-
217
210
  async function loadAndWrite(
218
211
  source: StationSource,
219
212
  ): Promise<WrittenStations & { agents: number }> {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.169"
2
+ "version": "0.1.0-beta.170"
3
3
  }