@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 +1 -1
- package/dist/serve.js +34 -4
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +8 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +9 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-owner.ts +14 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +14 -30
- package/runtime/runtime.json +1 -1
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
|
|
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.
|
|
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())
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
61
|
+
new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403),
|
|
80
62
|
);
|
|
81
|
-
|
|
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 {
|
package/runtime/runtime.json
CHANGED