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

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.47",
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
  }
@@ -32,7 +32,7 @@ import {
32
32
  } from '../db/local-connectors.js';
33
33
  import {
34
34
  assertLocalOwner,
35
- claimLocalOwner,
35
+ ownerSignIn,
36
36
  connectorIdsOfLocalAgent,
37
37
  LOCAL_PROJECT_ID,
38
38
  localAttachAccount,
@@ -190,7 +190,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
190
190
  relayApi,
191
191
  localCli,
192
192
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
193
- siwe: { ensureUser: claimLocalOwner },
193
+ siwe: { ensureUser: ownerSignIn },
194
194
  mode: localModeInfo,
195
195
  };
196
196
  }
@@ -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 {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.46"
2
+ "version": "0.1.0-beta.47"
3
3
  }