@stage-labs/metro 0.1.0-beta.23 → 0.1.0-beta.25
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/api.js
CHANGED
|
@@ -65,6 +65,34 @@ async function post(path, body) {
|
|
|
65
65
|
const detail = isRecord(parsed) ? parsed.error : undefined;
|
|
66
66
|
throw new Error(typeof detail === 'string' ? detail : `metro answered ${String(res.status)}`);
|
|
67
67
|
}
|
|
68
|
+
export class RuntimeRevoked extends Error {
|
|
69
|
+
}
|
|
70
|
+
export function keyOfRunConfig(body) {
|
|
71
|
+
const agent = isRecord(body) ? body.agent : undefined;
|
|
72
|
+
const key = isRecord(agent) ? agent.key : undefined;
|
|
73
|
+
if (typeof key !== 'string' || key === '')
|
|
74
|
+
throw new Error('this agent has no key — reset it on its page in the web UI');
|
|
75
|
+
return key;
|
|
76
|
+
}
|
|
77
|
+
export async function runConfigKey(token) {
|
|
78
|
+
if (!carriesSecretsSafely(metroUrl()))
|
|
79
|
+
throw new Error(`refusing to send this machine's authorization to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
80
|
+
let res;
|
|
81
|
+
try {
|
|
82
|
+
res = await fetch(`${metroUrl()}/api/run/config`, {
|
|
83
|
+
headers: { authorization: `Bearer ${token}` },
|
|
84
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error(`could not reach ${metroUrl()}`);
|
|
89
|
+
}
|
|
90
|
+
if (res.status === 401 || res.status === 403 || res.status === 409)
|
|
91
|
+
throw new RuntimeRevoked('this machine no longer holds the agent — authorize it again from the agent page');
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new Error(`metro answered ${String(res.status)}`);
|
|
94
|
+
return keyOfRunConfig(await res.json());
|
|
95
|
+
}
|
|
68
96
|
export async function claimCode(code) {
|
|
69
97
|
const body = await post('/api/cli/claim', { code });
|
|
70
98
|
if (!isRecord(body) ||
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { claimCode, claimRuntime, mcpServers, NotSignedIn, whoisAuthorized, } fr
|
|
|
4
4
|
import { askSecret } from './prompt.js';
|
|
5
5
|
import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from './store.js';
|
|
6
6
|
import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
|
|
7
|
+
import { tailEvents } from './tail.js';
|
|
7
8
|
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, writeRunToken, } from './runtime.js';
|
|
8
9
|
import { update } from './update.js';
|
|
9
10
|
import { currentVersion } from './version.js';
|
|
@@ -17,6 +18,8 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
17
18
|
is it running, and is it healthy
|
|
18
19
|
metro logs <agent-id> [-f]
|
|
19
20
|
show the detached daemon's log
|
|
21
|
+
metro tail <agent-id>
|
|
22
|
+
follow this machine's inbound events, one JSON line each
|
|
20
23
|
metro login authorize a connector collection with a code from the web UI
|
|
21
24
|
metro logout forget this machine's sign-in
|
|
22
25
|
metro whoami print the account and collection this machine may read
|
|
@@ -123,6 +126,7 @@ const COMMANDS = {
|
|
|
123
126
|
stop: () => stopDaemon(process.argv.slice(3)),
|
|
124
127
|
status: () => status(process.argv.slice(3)),
|
|
125
128
|
logs: () => logs(process.argv.slice(3)),
|
|
129
|
+
tail: () => tailEvents(process.argv.slice(3)),
|
|
126
130
|
mcp: async () => {
|
|
127
131
|
process.stdout.write(`${await mcpServers()}\n`);
|
|
128
132
|
return 0;
|
package/dist/tail.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { runConfigKey } from './api.js';
|
|
2
|
+
import { assertAgentId, localUrl, readRunToken } from './runtime.js';
|
|
3
|
+
const BACKOFF_MS = [1_000, 2_000, 5_000];
|
|
4
|
+
const STALL_MS = 90_000;
|
|
5
|
+
export const backoffAt = (attempt) => BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)] ?? 5_000;
|
|
6
|
+
export function sseDataLines(buffer, chunk) {
|
|
7
|
+
const lines = (buffer + chunk).split('\n');
|
|
8
|
+
const rest = lines.pop() ?? '';
|
|
9
|
+
const events = [];
|
|
10
|
+
for (const raw of lines) {
|
|
11
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
12
|
+
if (!line.startsWith('data:'))
|
|
13
|
+
continue;
|
|
14
|
+
const value = line.slice(5);
|
|
15
|
+
events.push(value.startsWith(' ') ? value.slice(1) : value);
|
|
16
|
+
}
|
|
17
|
+
return { rest, events };
|
|
18
|
+
}
|
|
19
|
+
function keySource(agentId) {
|
|
20
|
+
const fromEnv = process.env.METRO_AGENT_KEY?.trim();
|
|
21
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
22
|
+
return { resolve: () => Promise.resolve(fromEnv), fixed: true };
|
|
23
|
+
return {
|
|
24
|
+
resolve: () => {
|
|
25
|
+
const token = readRunToken(agentId);
|
|
26
|
+
if (token === null)
|
|
27
|
+
throw new Error(`this machine is not authorized for ${agentId} — ` +
|
|
28
|
+
`run 'metro start ${agentId}' once, or set METRO_AGENT_KEY`);
|
|
29
|
+
return runConfigKey(token);
|
|
30
|
+
},
|
|
31
|
+
fixed: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const armStall = (control) => setTimeout(() => {
|
|
35
|
+
control.abort();
|
|
36
|
+
}, STALL_MS);
|
|
37
|
+
async function connect(key, signal) {
|
|
38
|
+
try {
|
|
39
|
+
return await fetch(`${localUrl()}/api/tail`, {
|
|
40
|
+
headers: { authorization: `Bearer ${key}` },
|
|
41
|
+
signal,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function pump(body, control, stall) {
|
|
49
|
+
const reader = body.getReader();
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
let buffer = '';
|
|
52
|
+
try {
|
|
53
|
+
for (;;) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done)
|
|
56
|
+
return 'ended';
|
|
57
|
+
clearTimeout(stall.timer);
|
|
58
|
+
stall.timer = armStall(control);
|
|
59
|
+
const out = sseDataLines(buffer, decoder.decode(value, { stream: true }));
|
|
60
|
+
buffer = out.rest;
|
|
61
|
+
for (const event of out.events)
|
|
62
|
+
process.stdout.write(`${event}\n`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'ended';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function streamOnce(key) {
|
|
70
|
+
const control = new AbortController();
|
|
71
|
+
const stall = { timer: armStall(control) };
|
|
72
|
+
try {
|
|
73
|
+
const res = await connect(key, control.signal);
|
|
74
|
+
if (res === null)
|
|
75
|
+
return 'unreachable';
|
|
76
|
+
if (res.status === 401)
|
|
77
|
+
return 'unauthorized';
|
|
78
|
+
if (!res.ok || res.body === null)
|
|
79
|
+
return 'unreachable';
|
|
80
|
+
return await pump(res.body, control, stall);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(stall.timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function note(state, message) {
|
|
87
|
+
if (state.last === message)
|
|
88
|
+
return;
|
|
89
|
+
state.last = message;
|
|
90
|
+
process.stderr.write(`metro: ${message}\n`);
|
|
91
|
+
}
|
|
92
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
async function settle(end, source, key, agentId, state) {
|
|
94
|
+
if (end === 'unauthorized') {
|
|
95
|
+
if (source.fixed)
|
|
96
|
+
throw new Error('the key in METRO_AGENT_KEY was refused by the local daemon');
|
|
97
|
+
note(state, 'key refused — fetching the current one');
|
|
98
|
+
return source.resolve();
|
|
99
|
+
}
|
|
100
|
+
if (end === 'unreachable')
|
|
101
|
+
note(state, `no metro daemon on ${localUrl()} — run 'metro start ${agentId}'`);
|
|
102
|
+
else
|
|
103
|
+
note(state, 'stream ended — reconnecting');
|
|
104
|
+
return key;
|
|
105
|
+
}
|
|
106
|
+
export async function tailEvents(argv) {
|
|
107
|
+
const agentId = assertAgentId(argv[0]);
|
|
108
|
+
const source = keySource(agentId);
|
|
109
|
+
let key = await source.resolve();
|
|
110
|
+
const state = { last: '' };
|
|
111
|
+
let attempt = 0;
|
|
112
|
+
for (;;) {
|
|
113
|
+
const end = await streamOnce(key);
|
|
114
|
+
if (end === 'ended')
|
|
115
|
+
attempt = 0;
|
|
116
|
+
key = await settle(end, source, key, agentId, state);
|
|
117
|
+
await delay(backoffAt(attempt));
|
|
118
|
+
attempt += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
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.25",
|
|
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": {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { attachmentOwner, recordAttachmentOwner } from './attach-owner.js';
|
|
15
15
|
import { grantAllows, issueAttachmentGrant } from './attach-grant.js';
|
|
16
16
|
import { errMsg, log } from './log.js';
|
|
17
|
-
import { loadTunnelConfig } from './tunnel.js';
|
|
17
|
+
import { loadTunnelConfig, webhookPort } from './tunnel.js';
|
|
18
18
|
|
|
19
19
|
function authorized(req: IncomingMessage, name: string): boolean {
|
|
20
20
|
const owner = attachmentOwner(name);
|
|
@@ -34,14 +34,19 @@ export function publicBaseUrl(): string | null {
|
|
|
34
34
|
return host ? `https://${host}` : null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
const localBase = (): string | null =>
|
|
38
|
+
(process.env.METRO_RUN_TOKEN?.trim() ?? '') === ''
|
|
39
|
+
? null
|
|
40
|
+
: `http://127.0.0.1:${String(webhookPort())}`;
|
|
41
|
+
|
|
37
42
|
export const publicBaseOrDefault = (): string =>
|
|
38
|
-
publicBaseUrl() ?? DEFAULT_PUBLIC_BASE;
|
|
43
|
+
publicBaseUrl() ?? localBase() ?? DEFAULT_PUBLIC_BASE;
|
|
39
44
|
|
|
40
45
|
export function attachmentUrl(
|
|
41
46
|
pathOrName: string,
|
|
42
47
|
agentId: string,
|
|
43
48
|
): string | null {
|
|
44
|
-
const base = publicBaseUrl();
|
|
49
|
+
const base = publicBaseUrl() ?? localBase();
|
|
45
50
|
if (!base) return null;
|
|
46
51
|
const name = pathOrName.split('/').pop();
|
|
47
52
|
if (!name || !resolveCachedAttachment(name)) return null;
|
|
@@ -181,10 +181,6 @@ async function loadAgents(): Promise<LoadedAgent[]> {
|
|
|
181
181
|
|
|
182
182
|
const out: LoadedAgent[] = [];
|
|
183
183
|
for (const a of agentRows) {
|
|
184
|
-
if (a.runtimeId !== null) {
|
|
185
|
-
out.push({ id: a.id, name: a.name, accounts: [], key: null });
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
184
|
const acctRows = await db
|
|
189
185
|
.select()
|
|
190
186
|
.from(stations)
|
|
@@ -198,7 +194,7 @@ async function loadAgents(): Promise<LoadedAgent[]> {
|
|
|
198
194
|
allowlist: r.allowlist,
|
|
199
195
|
config: r.config as Record<string, unknown>,
|
|
200
196
|
})),
|
|
201
|
-
key: a.key,
|
|
197
|
+
key: a.runtimeId === null ? a.key : null,
|
|
202
198
|
});
|
|
203
199
|
}
|
|
204
200
|
return out;
|
package/runtime/runtime.json
CHANGED