@stage-labs/metro 0.1.0-beta.46 → 0.1.0-beta.48

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/cli.js CHANGED
@@ -16,7 +16,7 @@ const USAGE = `metro — the command line for your MCP connectors
16
16
 
17
17
  metro start <agent-id> [--detach]
18
18
  run that agent's stations on this machine
19
- metro serve [--port <n>] [--tunnel]
19
+ metro serve [--port <n>] [--tunnel] [--owner <address>]
20
20
  run a daemon of your own: its agents and stations live in ~/.metro/agents
21
21
  here, and metro.box manages it through the link it prints; --tunnel adds
22
22
  a public https address through a Cloudflare quick tunnel (needs
package/dist/serve.js CHANGED
@@ -1,25 +1,45 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
2
3
  import { join } from 'node:path';
4
+ import { agentsDir } from './local.js';
3
5
  import { serveLockedBy, serveStateDir } from './control.js';
4
6
  import { findBun, localPort, runtimeDir, SERVER_ENTRY, spawnPlan, } from './runtime.js';
5
7
  const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
6
8
  const PORT_FLAG = /^--port=(.*)$/;
7
- const USAGE = 'usage: metro serve [--port <n>] [--tunnel]';
9
+ const OWNER_FLAG = /^--owner=(.*)$/;
10
+ const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
11
+ const USAGE = 'usage: metro serve [--port <n>] [--tunnel] [--owner <address>]';
8
12
  function portOf(raw) {
9
13
  const n = Number(raw);
10
14
  if (raw === undefined || raw === '' || !Number.isInteger(n) || n < 1 || n > 65535)
11
15
  throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
12
16
  return n;
13
17
  }
18
+ function ownerOf(raw) {
19
+ if (raw === undefined || !ADDRESS.test(raw))
20
+ throw new Error(`'${raw ?? ''}' is not an Ethereum address — ${USAGE}`);
21
+ return raw.toLowerCase();
22
+ }
14
23
  export function parseServeArgs(argv) {
15
24
  let port = localPort();
16
25
  let tunnel = false;
26
+ let owner = null;
17
27
  for (let i = 0; i < argv.length; i += 1) {
18
28
  const arg = argv[i] ?? '';
19
29
  if (arg === '--tunnel') {
20
30
  tunnel = true;
21
31
  continue;
22
32
  }
33
+ const inlineOwner = OWNER_FLAG.exec(arg);
34
+ if (inlineOwner) {
35
+ owner = ownerOf(inlineOwner[1]);
36
+ continue;
37
+ }
38
+ if (arg === '--owner') {
39
+ owner = ownerOf(argv[i + 1]);
40
+ i += 1;
41
+ continue;
42
+ }
23
43
  const inline = PORT_FLAG.exec(arg);
24
44
  if (inline) {
25
45
  port = portOf(inline[1]);
@@ -32,7 +52,15 @@ export function parseServeArgs(argv) {
32
52
  }
33
53
  throw new Error(`unknown argument '${arg}' — ${USAGE}`);
34
54
  }
35
- return { port, tunnel };
55
+ return { port, tunnel, owner };
56
+ }
57
+ export function requireOwner(owner, dir = agentsDir()) {
58
+ if (owner !== null || existsSync(join(dir, '.owner')))
59
+ return;
60
+ throw new Error('no owner is set for this machine, so no wallet could sign in.\n' +
61
+ 'Pass the wallet that owns it once; it is remembered in ' +
62
+ join(dir, '.owner') +
63
+ ':\n metro serve --owner <address>');
36
64
  }
37
65
  function findCloudflared() {
38
66
  const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
@@ -60,18 +88,20 @@ export function servePlan(opts) {
60
88
  METRO_TRAINS_DIR: join(opts.dir, 'trains'),
61
89
  METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
62
90
  ...(opts.tunnel ? { METRO_TUNNEL: 'quick' } : {}),
91
+ ...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
63
92
  },
64
93
  };
65
94
  }
66
95
  export function serve(argv) {
67
- const { port, tunnel } = parseServeArgs(argv);
96
+ const { port, tunnel, owner } = parseServeArgs(argv);
68
97
  const running = serveLockedBy();
69
98
  if (running !== null)
70
99
  throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
71
100
  'Stop it first: metro stop');
101
+ requireOwner(owner);
72
102
  if (tunnel)
73
103
  findCloudflared();
74
- const plan = servePlan({ dir: runtimeDir(), port, tunnel });
104
+ const plan = servePlan({ dir: runtimeDir(), port, tunnel, owner });
75
105
  process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
76
106
  return spawnPlan(plan);
77
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.46",
3
+ "version": "0.1.0-beta.48",
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": {
@@ -33,6 +33,8 @@ import {
33
33
  import { loadAgentForRuntime, localAgentKey } from '../db/materialize.js';
34
34
  import { fileSource } from '../db/file-source.js';
35
35
  import { ensureLocalSessionSecret } from './local-secret.js';
36
+ import { applyLocalOwner } from './local-owner.js';
37
+ import { localOwner } from '../db/file-admin.js';
36
38
  import { localSessionApis } from './local-mode.js';
37
39
  import type { ModeInfo } from './mode-api.js';
38
40
  import type { SessionApis } from './session-apis.js';
@@ -124,7 +126,7 @@ let webhookServer: Server | null = null;
124
126
  const tunnel = quickTunnelWanted() ? new Tunnel(webhookPort(), announcePublic) : null;
125
127
 
126
128
  function announcePublic(url: string): void {
127
- process.stderr.write(`\n${publicConnectHint(url)}`);
129
+ process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
128
130
  }
129
131
 
130
132
  setTrainCallBackend((train, action, args) =>
@@ -138,7 +140,7 @@ const localSource = linkedSource ?? (isLocalMode() ? fileSource : null);
138
140
  function announceLocalEndpoint(): void {
139
141
  if (isLocalMode())
140
142
  process.stderr.write(
141
- `\n${tunnel === null ? localConnectHint(webhookPort()) : tunnelPendingHint()}`,
143
+ `\n${tunnel === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint()}`,
142
144
  );
143
145
  const key = localAgentKey();
144
146
  if (key === null) {
@@ -303,7 +305,10 @@ function sessionApis(): SessionApis {
303
305
  }
304
306
 
305
307
  async function main(): Promise<void> {
306
- if (isLocalMode()) ensureLocalSessionSecret();
308
+ if (isLocalMode()) {
309
+ ensureLocalSessionSecret();
310
+ applyLocalOwner();
311
+ }
307
312
  if (localSource === null) await materializeFromDb();
308
313
  else await materializeFrom(localSource, { allowEmpty: linkedSource === null });
309
314
  supervisor.start();
@@ -5,7 +5,12 @@ const uiBase = (): string => process.env.METRO_UI_URL ?? 'https://metro.box';
5
5
  const connectLink = (base: string): string =>
6
6
  `${uiBase()}/#/${new URL(base).host}`;
7
7
 
8
- export function localConnectHint(port: number): string {
8
+ const ownerLine = (owner: string | null): string =>
9
+ owner === null
10
+ ? 'No owner is set, so no wallet can sign in. Restart with: metro serve --owner <address>'
11
+ : `Only ${owner} can sign in.`;
12
+
13
+ export function localConnectHint(port: number, owner: string | null): string {
9
14
  const here = `http://127.0.0.1:${String(port)}`;
10
15
  const forward = `ssh -L ${String(port)}:127.0.0.1:${String(port)} ${hostname()}`;
11
16
  return [
@@ -14,17 +19,18 @@ export function localConnectHint(port: number): string {
14
19
  ` ${connectLink(here)}`,
15
20
  '',
16
21
  `From another computer, forward the port first: ${forward}`,
22
+ ownerLine(owner),
17
23
  '',
18
24
  ].join('\n');
19
25
  }
20
26
 
21
- export function publicConnectHint(url: string): string {
27
+ export function publicConnectHint(url: string, owner: string | null): string {
22
28
  return [
23
29
  'Manage this machine from the web UI, from anywhere:',
24
30
  '',
25
31
  ` ${connectLink(url)}`,
26
32
  '',
27
- 'Sign in right away: the first wallet to sign in owns this machine.',
33
+ ownerLine(owner),
28
34
  '',
29
35
  ].join('\n');
30
36
  }
@@ -15,6 +15,7 @@ const PATH = '/api/agents/import';
15
15
 
16
16
  export interface ImportedAgent extends CreatedAgent {
17
17
  stations: number;
18
+ connectors: number;
18
19
  }
19
20
 
20
21
  export interface ImportApiDeps {
@@ -31,7 +32,7 @@ async function handle(
31
32
  const code = parsePairingCode(bodyField(await readJsonBody(req), 'code'));
32
33
  const made = await deps.importAgent(subject, code);
33
34
  log.info(
34
- { agent: made.name, id: made.id, stations: made.stations },
35
+ { agent: made.name, id: made.id, stations: made.stations, connectors: made.connectors },
35
36
  'import-api: agent imported from metro',
36
37
  );
37
38
  sendJson(req, res, 201, made);
@@ -14,6 +14,7 @@ import { allowLocalConnectors } from './connector-url.js';
14
14
  import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
15
15
  import type { RelayApiDeps } from './relay.js';
16
16
  import {
17
+ localImportConnectors,
17
18
  localAddConnector,
18
19
  localAgentConnectors,
19
20
  localConnectorNamesByIds,
@@ -32,7 +33,7 @@ import {
32
33
  } from '../db/local-connectors.js';
33
34
  import {
34
35
  assertLocalOwner,
35
- claimLocalOwner,
36
+ ownerSignIn,
36
37
  connectorIdsOfLocalAgent,
37
38
  LOCAL_PROJECT_ID,
38
39
  localAttachAccount,
@@ -164,6 +165,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
164
165
  assertLocalOwner(subject);
165
166
  const agent = await fetchAgent(code, hostname());
166
167
  const made = await localImportAgent(subject, agent);
168
+ const connectors = localImportConnectors(agent.connectors ?? []);
167
169
  for (const station of new Set(agent.accounts.map((a) => a.station)))
168
170
  await deps.syncStations(station).catch((err: unknown) => {
169
171
  log.warn(
@@ -171,7 +173,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
171
173
  'import: station reload failed, the change lands at the next boot',
172
174
  );
173
175
  });
174
- return made;
176
+ return { ...made, connectors };
175
177
  },
176
178
  };
177
179
  }
@@ -190,7 +192,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
190
192
  relayApi,
191
193
  localCli,
192
194
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
193
- siwe: { ensureUser: claimLocalOwner },
195
+ siwe: { ensureUser: ownerSignIn },
194
196
  mode: localModeInfo,
195
197
  };
196
198
  }
@@ -0,0 +1,14 @@
1
+ import { localOwner, setLocalOwner } from '../db/file-admin.js';
2
+ import { log } from './log.js';
3
+
4
+ export function applyLocalOwner(): string | null {
5
+ const wanted = process.env.METRO_OWNER?.trim() ?? '';
6
+ if (wanted !== '') {
7
+ const address = setLocalOwner(wanted);
8
+ log.info({ owner: address }, 'local daemon: owner set from METRO_OWNER');
9
+ return address;
10
+ }
11
+ const stored = localOwner();
12
+ if (stored === null) log.warn('local daemon: no owner set, so no wallet can sign in; restart with metro serve --owner <address>');
13
+ return stored;
14
+ }
@@ -1,12 +1,4 @@
1
- import {
2
- closeSync,
3
- existsSync,
4
- openSync,
5
- readFileSync,
6
- rmdirSync,
7
- rmSync,
8
- writeSync,
9
- } from 'node:fs';
1
+ import { existsSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
10
2
  import { join } from 'node:path';
11
3
  import { ApiError } from '../daemon/api-error.js';
12
4
  import { ensureSecureDir, writeSecure } from '../daemon/secure-fs.js';
@@ -54,31 +46,23 @@ export function localOwner(dir = agentsDir()): string | null {
54
46
  }
55
47
  }
56
48
 
57
- function writeOwnerOnce(path: string, address: string): boolean {
58
- let fd: number;
59
- try {
60
- fd = openSync(path, 'wx', 0o600);
61
- } catch (err) {
62
- if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
63
- throw err;
64
- }
65
- writeSync(fd, `${address}\n`);
66
- closeSync(fd);
67
- return true;
68
- }
69
-
70
- export function claimLocalOwner(raw: string, dir = agentsDir()): Promise<string> {
49
+ export function setLocalOwner(raw: string, dir = agentsDir()): string {
71
50
  const address = normalizeAddress(raw);
72
- if (address === null)
73
- return Promise.reject(new ApiError('an Ethereum address is required', 400));
51
+ if (address === null) throw new ApiError(`'${raw}' is not an Ethereum address`, 400);
74
52
  ensureSecureDir(dir);
75
- if (writeOwnerOnce(join(dir, OWNER_FILE), address))
76
- return Promise.resolve(address);
77
- if (localOwner(dir) !== address)
53
+ writeSecure(join(dir, OWNER_FILE), `${address}\n`);
54
+ return address;
55
+ }
56
+
57
+ export function ownerSignIn(raw: string, dir = agentsDir()): Promise<string> {
58
+ const owner = localOwner(dir);
59
+ if (owner === null)
78
60
  return Promise.reject(
79
- new ApiError('this machine belongs to another wallet', 403),
61
+ new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403),
80
62
  );
81
- return Promise.resolve(address);
63
+ if (owner !== normalizeAddress(raw))
64
+ return Promise.reject(new ApiError('this machine belongs to another wallet', 403));
65
+ return Promise.resolve(owner);
82
66
  }
83
67
 
84
68
  function isOwner(subject: string, dir: string): boolean {
@@ -203,12 +187,7 @@ export function assertLocalOwner(subject: string, dir = agentsDir()): void {
203
187
  if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
204
188
  }
205
189
 
206
- export async function localImportAgent(
207
- subject: string,
208
- agent: LoadedAgent,
209
- dir = agentsDir(),
210
- ): Promise<{ id: string; name: string; key: string; stations: number }> {
211
- assertLocalOwner(subject, dir);
190
+ function assertImportable(agent: LoadedAgent): asserts agent is LoadedAgent & { key: string } {
212
191
  const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
213
192
  if (immovable !== undefined)
214
193
  throw new AgentAdminError(
@@ -217,37 +196,71 @@ export async function localImportAgent(
217
196
  );
218
197
  if (agent.key === null)
219
198
  throw new AgentAdminError('that agent has no key; reset it on metro.box first', 400);
220
- const folder = join(dir, agent.name);
221
- const path = join(folder, AGENT_FILE);
222
- if (existsSync(path))
199
+ }
200
+
201
+ interface ImportTarget {
202
+ path: string;
203
+ previous: AgentFile | undefined;
204
+ }
205
+
206
+ function importTarget(dir: string, agent: LoadedAgent & { key: string }): ImportTarget {
207
+ const existing = storedAgents(dir);
208
+ const same = existing.find((s) => s.file.id === agent.id);
209
+ const fresh = join(dir, agent.name, AGENT_FILE);
210
+ if (same === undefined && existsSync(fresh))
223
211
  throw new AgentAdminError(
224
212
  `an agent named '${agent.name}' already exists on this machine`,
225
213
  409,
226
214
  );
227
- assertUnclaimed(storedAgents(dir), agent);
228
- const file = fileFor(agent, localOwner(dir), path);
229
- ensureSecureDir(folder);
215
+ assertUnclaimed(existing.filter((s) => s !== same), agent);
216
+ return { path: same?.path ?? fresh, previous: same?.file };
217
+ }
218
+
219
+ export async function localImportAgent(
220
+ subject: string,
221
+ agent: LoadedAgent,
222
+ dir = agentsDir(),
223
+ ): Promise<{ id: string; name: string; key: string; stations: number }> {
224
+ assertLocalOwner(subject, dir);
225
+ assertImportable(agent);
226
+ const { path, previous } = importTarget(dir, agent);
227
+ const file = fileFor(agent, localOwner(dir), path, previous);
228
+ ensureSecureDir(join(path, '..'));
230
229
  save({ path, file });
230
+ if (previous !== undefined && previous.key !== agent.key) unregisterAgentKey(agent.id);
231
231
  registerKey(agent.key, agent.id);
232
232
  return Promise.resolve({
233
233
  id: agent.id,
234
234
  name: agent.name,
235
235
  key: agent.key,
236
- stations: agent.accounts.length,
236
+ stations: file.stations.length,
237
237
  });
238
238
  }
239
239
 
240
- function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentFile {
240
+ function mergedStations(agent: LoadedAgent, previous: AgentFile | undefined): AgentFile['stations'] {
241
+ const fromMetro = agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] }));
242
+ const kept = (previous?.stations ?? []).filter(
243
+ (s) => !fromMetro.some((m) => m.station === s.station && m.id === s.id),
244
+ );
245
+ return [...fromMetro, ...kept];
246
+ }
247
+
248
+ function fileFor(
249
+ agent: LoadedAgent,
250
+ owner: string | null,
251
+ path: string,
252
+ previous?: AgentFile,
253
+ ): AgentFile {
241
254
  try {
242
255
  return parseAgentFile(
243
256
  JSON.stringify({
244
257
  version: 1,
245
258
  id: agent.id,
246
- name: agent.name,
259
+ name: previous?.name ?? agent.name,
247
260
  key: agent.key,
248
261
  owner,
249
- stations: agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] })),
250
- connectors: [],
262
+ stations: mergedStations(agent, previous),
263
+ connectors: previous?.connectors ?? [],
251
264
  }),
252
265
  path,
253
266
  );
@@ -48,6 +48,7 @@ import {
48
48
  LOCAL_PROJECT_ID,
49
49
  } from './file-admin.js';
50
50
  import { newId } from './ids.js';
51
+ import type { LoadedConnector } from './materialize.js';
51
52
 
52
53
  export interface LocalConnectorRow {
53
54
  id: string;
@@ -113,6 +114,26 @@ async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
113
114
  return advertisesOAuth(url);
114
115
  }
115
116
 
117
+ export function localImportConnectors(rows: LoadedConnector[], dir = agentsDir()): number {
118
+ const current = readLocalConnectors(dir);
119
+ for (const row of rows) {
120
+ const other = current.find((r) => r.id !== row.id && r.name === row.name);
121
+ if (other !== undefined)
122
+ throw new ConnectorError(`a connector named '${row.name}' already exists on this daemon; rename it first`, 409);
123
+ }
124
+ const imported: LocalConnectorRow[] = rows.map((row) => ({
125
+ id: row.id,
126
+ name: row.name,
127
+ url: row.url,
128
+ transport: 'http',
129
+ config: readConfig(row.config),
130
+ }));
131
+ const ids = new Set(imported.map((r) => r.id));
132
+ writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
133
+ for (const row of imported) localHoldEverywhere(row.id, dir);
134
+ return imported.length;
135
+ }
136
+
116
137
  export async function localListConnectors(
117
138
  subject: string,
118
139
  project: string,
@@ -21,7 +21,14 @@ import {
21
21
  type AllowlistMap,
22
22
  } from './agent-map.js';
23
23
  import { setKeyMap } from './key-map.js';
24
- import { STATIONS, stations, agents, type StationName } from './schema.js';
24
+ import {
25
+ agentConnectors,
26
+ agents,
27
+ connectors,
28
+ stations,
29
+ STATIONS,
30
+ type StationName,
31
+ } from './schema.js';
25
32
 
26
33
  interface StationTarget {
27
34
  file: string;
@@ -36,11 +43,20 @@ export interface LoadedAccount {
36
43
  config: Record<string, unknown>;
37
44
  }
38
45
 
46
+ export interface LoadedConnector {
47
+ id: string;
48
+ name: string;
49
+ url: string;
50
+ transport: 'http';
51
+ config: Record<string, unknown>;
52
+ }
53
+
39
54
  export interface LoadedAgent {
40
55
  id: string;
41
56
  name: string;
42
57
  accounts: LoadedAccount[];
43
58
  key: string | null;
59
+ connectors?: LoadedConnector[];
44
60
  }
45
61
 
46
62
  const METRO_DIR = join(homedir(), '.metro');
@@ -133,10 +149,22 @@ export async function loadAgentForRuntime(
133
149
  .select()
134
150
  .from(stations)
135
151
  .where(eq(stations.agentId, a.id));
152
+ const held = await db
153
+ .select({ id: connectors.id, name: connectors.name, url: connectors.url, config: connectors.config })
154
+ .from(agentConnectors)
155
+ .innerJoin(connectors, eq(connectors.id, agentConnectors.connectorId))
156
+ .where(eq(agentConnectors.agentId, a.id));
136
157
  return {
137
158
  id: a.id,
138
159
  name: a.name,
139
160
  key: a.key,
161
+ connectors: held.map((c) => ({
162
+ id: c.id,
163
+ name: c.name,
164
+ url: c.url,
165
+ transport: 'http' as const,
166
+ config: c.config as Record<string, unknown>,
167
+ })),
140
168
  accounts: acctRows
141
169
  .filter((r) => MOVABLE_STATIONS.has(r.station))
142
170
  .map((r) => ({
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.46"
2
+ "version": "0.1.0-beta.48"
3
3
  }