@stage-labs/metro 0.1.0-beta.30 → 0.1.0-beta.31
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 +12 -12
- package/dist/bedrock.js +5 -3
- package/dist/cli.js +18 -11
- package/dist/store.js +26 -3
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +9 -14
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +29 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +27 -18
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +21 -19
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +4 -30
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +13 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +8 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +13 -13
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +8 -15
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +6 -20
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +10 -5
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +0 -218
package/dist/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { metroUrl, readToken } from './store.js';
|
|
1
|
+
import { metroUrl, readRunTokens, readToken } from './store.js';
|
|
2
2
|
export class NotSignedIn extends Error {
|
|
3
3
|
}
|
|
4
4
|
const TIMEOUT_MS = 20_000;
|
|
@@ -18,11 +18,15 @@ export function carriesSecretsSafely(base) {
|
|
|
18
18
|
function tokenOrThrow() {
|
|
19
19
|
if (!carriesSecretsSafely(metroUrl()))
|
|
20
20
|
throw new Error(`refusing to send your session to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
21
|
-
const token = readToken();
|
|
21
|
+
const token = readToken() ?? soleRunToken();
|
|
22
22
|
if (token === null)
|
|
23
|
-
throw new NotSignedIn('not signed in — run `metro login`
|
|
23
|
+
throw new NotSignedIn('not signed in — run `metro login`, or `metro start <agent-id>` on this machine');
|
|
24
24
|
return token;
|
|
25
25
|
}
|
|
26
|
+
function soleRunToken() {
|
|
27
|
+
const tokens = readRunTokens();
|
|
28
|
+
return tokens.length === 1 ? (tokens[0] ?? null) : null;
|
|
29
|
+
}
|
|
26
30
|
async function get(path) {
|
|
27
31
|
const auth = tokenOrThrow();
|
|
28
32
|
let res;
|
|
@@ -36,7 +40,7 @@ async function get(path) {
|
|
|
36
40
|
throw new Error(`could not reach ${metroUrl()}`);
|
|
37
41
|
}
|
|
38
42
|
if (res.status === 401)
|
|
39
|
-
throw new NotSignedIn('
|
|
43
|
+
throw new NotSignedIn("metro refused this machine's sign-in — run `metro login` again");
|
|
40
44
|
if (!res.ok)
|
|
41
45
|
throw new Error(`metro answered ${String(res.status)}`);
|
|
42
46
|
return res.json();
|
|
@@ -98,13 +102,9 @@ export async function claimCode(code) {
|
|
|
98
102
|
if (!isRecord(body) ||
|
|
99
103
|
typeof body.token !== 'string' ||
|
|
100
104
|
typeof body.email !== 'string' ||
|
|
101
|
-
typeof body.
|
|
105
|
+
typeof body.agent !== 'string')
|
|
102
106
|
throw new Error('metro returned an unexpected response');
|
|
103
|
-
return {
|
|
104
|
-
token: body.token,
|
|
105
|
-
email: body.email,
|
|
106
|
-
collection: body.collection,
|
|
107
|
-
};
|
|
107
|
+
return { token: body.token, email: body.email, agent: body.agent };
|
|
108
108
|
}
|
|
109
109
|
export async function claimRuntime(code, label) {
|
|
110
110
|
const body = await post('/api/run/claim', { code, label });
|
|
@@ -125,7 +125,7 @@ export async function whoisAuthorized() {
|
|
|
125
125
|
const body = await get('/api/cli/session');
|
|
126
126
|
if (!isRecord(body) ||
|
|
127
127
|
typeof body.email !== 'string' ||
|
|
128
|
-
typeof body.
|
|
128
|
+
typeof body.agent !== 'string')
|
|
129
129
|
throw new Error('metro returned an unexpected response');
|
|
130
|
-
return { email: body.email,
|
|
130
|
+
return { email: body.email, agent: body.agent };
|
|
131
131
|
}
|
package/dist/bedrock.js
CHANGED
|
@@ -25,9 +25,11 @@ export function settingsFiles(cwd = process.cwd(), env = process.env) {
|
|
|
25
25
|
const explicit = env.CLAUDE_CONFIG_DIR?.trim() ?? '';
|
|
26
26
|
const configDir = explicit === '' ? join(homedir(), '.claude') : explicit;
|
|
27
27
|
return [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
...new Set([
|
|
29
|
+
join(configDir, 'settings.json'),
|
|
30
|
+
join(cwd, '.claude', 'settings.json'),
|
|
31
|
+
join(cwd, '.claude', 'settings.local.json'),
|
|
32
|
+
]),
|
|
31
33
|
];
|
|
32
34
|
}
|
|
33
35
|
function envBlock(file) {
|
package/dist/cli.js
CHANGED
|
@@ -23,10 +23,11 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
23
23
|
show the detached daemon's log
|
|
24
24
|
metro tail <agent-id>
|
|
25
25
|
follow this machine's inbound events, one JSON line each
|
|
26
|
-
metro login
|
|
26
|
+
metro login [code]
|
|
27
|
+
authorize this machine for one agent with a code from its page
|
|
27
28
|
metro logout forget this machine's sign-in
|
|
28
|
-
metro whoami print the account and
|
|
29
|
-
metro mcp print the mcpServers block
|
|
29
|
+
metro whoami print the account and agent this machine may read
|
|
30
|
+
metro mcp print the mcpServers block of the authorized agent's connectors
|
|
30
31
|
metro plugin set up the Claude Code plugin (connector servers + /metro:login)
|
|
31
32
|
metro claude [args...]
|
|
32
33
|
open Claude Code with the metro channel; every argument is passed through
|
|
@@ -42,7 +43,8 @@ Start Claude Code with all of them, without writing them to disk:
|
|
|
42
43
|
|
|
43
44
|
METRO_URL the metro to talk to (default https://mcp.metro.box)
|
|
44
45
|
METRO_UI_URL where the web UI lives (default https://metro.box)
|
|
45
|
-
METRO_TOKEN use this
|
|
46
|
+
METRO_TOKEN use this agent sign-in instead of the stored one; with neither,
|
|
47
|
+
a machine running metro start uses its run token
|
|
46
48
|
METRO_RUN_TOKEN use this runtime authorization instead of the stored one
|
|
47
49
|
METRO_BEDROCK_MODEL
|
|
48
50
|
send every request to this Bedrock model id (default: derive from the
|
|
@@ -109,23 +111,28 @@ function logs(argv) {
|
|
|
109
111
|
const agentId = assertAgentId(argv[0]);
|
|
110
112
|
return tail(agentId, argv.includes('-f') || argv.includes('--follow'));
|
|
111
113
|
}
|
|
112
|
-
async function
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
async function pastedCode(given) {
|
|
115
|
+
if (given !== undefined && given.trim() !== '')
|
|
116
|
+
return given.trim();
|
|
117
|
+
process.stderr.write(`Get a pairing code from the agent's page, or pick the agent at ${metroWebUrl()}/#/authorize\n`);
|
|
118
|
+
return askSecret('Paste the code here (input is hidden): ');
|
|
119
|
+
}
|
|
120
|
+
async function login(given) {
|
|
121
|
+
const { token, email, agent } = await claimCode(await pastedCode(given));
|
|
115
122
|
writeToken(token);
|
|
116
|
-
process.stderr.write(`Authorized '${
|
|
123
|
+
process.stderr.write(`Authorized '${agent}' for ${email}. Stored in ${credentialsPath()}\n`);
|
|
117
124
|
if (syncPluginServers())
|
|
118
125
|
process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
|
|
119
126
|
'run /reload-plugins in any session already open.\n');
|
|
120
127
|
}
|
|
121
128
|
async function whoami() {
|
|
122
|
-
const { email,
|
|
123
|
-
process.stdout.write(`${email} ·
|
|
129
|
+
const { email, agent } = await whoisAuthorized();
|
|
130
|
+
process.stdout.write(`${email} · agent '${agent}' on ${metroUrl()}\n`);
|
|
124
131
|
}
|
|
125
132
|
const HELP = new Set([undefined, 'help', '--help', '-h']);
|
|
126
133
|
const COMMANDS = {
|
|
127
134
|
login: async () => {
|
|
128
|
-
await login();
|
|
135
|
+
await login(process.argv[3]);
|
|
129
136
|
return 0;
|
|
130
137
|
},
|
|
131
138
|
logout: async () => {
|
package/dist/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
const DEFAULT_BASE = 'https://mcp.metro.box';
|
|
@@ -21,10 +21,10 @@ function configDir() {
|
|
|
21
21
|
export function credentialsPath() {
|
|
22
22
|
return join(configDir(), 'credentials.json');
|
|
23
23
|
}
|
|
24
|
-
function
|
|
24
|
+
function readStoredAt(path) {
|
|
25
25
|
let raw;
|
|
26
26
|
try {
|
|
27
|
-
raw = readFileSync(
|
|
27
|
+
raw = readFileSync(path, 'utf8');
|
|
28
28
|
}
|
|
29
29
|
catch {
|
|
30
30
|
return null;
|
|
@@ -43,6 +43,29 @@ function readStored() {
|
|
|
43
43
|
return null;
|
|
44
44
|
return { token, url: typeof url === 'string' ? url : DEFAULT_BASE };
|
|
45
45
|
}
|
|
46
|
+
const readStored = () => readStoredAt(credentialsPath());
|
|
47
|
+
const RUN_TOKEN_FILE = /^runtime-[A-Za-z0-9_-]{11}\.json$/;
|
|
48
|
+
export function readRunTokens() {
|
|
49
|
+
const fromEnv = process.env.METRO_RUN_TOKEN?.trim();
|
|
50
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
51
|
+
return [fromEnv];
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(configDir());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
if (!RUN_TOKEN_FILE.test(name))
|
|
62
|
+
continue;
|
|
63
|
+
const stored = readStoredAt(join(configDir(), name));
|
|
64
|
+
if (stored !== null && stored.url === metroUrl())
|
|
65
|
+
out.push(stored.token);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
46
69
|
export function readToken() {
|
|
47
70
|
const fromEnv = process.env.METRO_TOKEN?.trim();
|
|
48
71
|
if (fromEnv !== undefined && fromEnv !== '')
|
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.31",
|
|
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": {
|
|
@@ -50,12 +50,9 @@ export interface AgentApiDeps extends AccountApiDeps {
|
|
|
50
50
|
}>;
|
|
51
51
|
capabilities: () => Record<string, string[]>;
|
|
52
52
|
liveness: () => Map<string, { connected: boolean; lastSeenAt: number }>;
|
|
53
|
-
mintRuntimeCode: (
|
|
54
|
-
email: string,
|
|
55
|
-
agentId: string,
|
|
56
|
-
) => Promise<{ code: string; expiresAt: number }>;
|
|
57
53
|
releaseRuntime: (email: string, agentId: string) => Promise<void>;
|
|
58
54
|
runtimes: (agentIds: string[]) => Promise<Map<string, string>>;
|
|
55
|
+
connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
type Routable =
|
|
@@ -134,12 +131,14 @@ function agentPayload(
|
|
|
134
131
|
agent: AgentSummary,
|
|
135
132
|
live: Map<string, { connected: boolean; lastSeenAt: number }>,
|
|
136
133
|
runtimes: Map<string, string>,
|
|
134
|
+
connectors: Map<string, string[]>,
|
|
137
135
|
): Record<string, unknown> {
|
|
138
136
|
return {
|
|
139
137
|
id: agent.id,
|
|
140
138
|
name: agent.name,
|
|
141
139
|
owned: agent.owned,
|
|
142
140
|
runtime: agent.owned ? (runtimes.get(agent.id) ?? null) : null,
|
|
141
|
+
connector_ids: connectors.get(agent.id) ?? [],
|
|
143
142
|
...livenessPayload(agent, live),
|
|
144
143
|
...keyPayload(agent),
|
|
145
144
|
};
|
|
@@ -166,10 +165,11 @@ async function handleList(
|
|
|
166
165
|
const held = await deps.runtimes(
|
|
167
166
|
list.filter((a) => a.owned).map((a) => a.id),
|
|
168
167
|
);
|
|
168
|
+
const connectors = await deps.connectorIds(list.map((a) => a.id));
|
|
169
169
|
const base = {
|
|
170
170
|
email: session.email,
|
|
171
171
|
endpoint: mcpEndpoint(),
|
|
172
|
-
agents: list.map((a) => agentPayload(a, live, held)),
|
|
172
|
+
agents: list.map((a) => agentPayload(a, live, held, connectors)),
|
|
173
173
|
capabilities: deps.capabilities(),
|
|
174
174
|
attachable: ATTACHABLE,
|
|
175
175
|
};
|
|
@@ -190,14 +190,9 @@ async function handleRuntime(
|
|
|
190
190
|
session: ApiSession,
|
|
191
191
|
id: string,
|
|
192
192
|
): Promise<void> {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
sendJson(req, res, 200, { agent: id, runtime: null });
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
const minted = await deps.mintRuntimeCode(session.email, id);
|
|
200
|
-
sendJson(req, res, 201, minted);
|
|
193
|
+
await deps.releaseRuntime(session.email, id);
|
|
194
|
+
log.info({ agent: id }, 'agent-api: runtime released');
|
|
195
|
+
sendJson(req, res, 200, { agent: id, runtime: null });
|
|
201
196
|
}
|
|
202
197
|
|
|
203
198
|
async function handleCreate(
|
|
@@ -286,7 +281,7 @@ const ALLOWED: Record<AgentTarget['kind'], string[]> = {
|
|
|
286
281
|
collection: ['GET', 'POST'],
|
|
287
282
|
agent: ['DELETE'],
|
|
288
283
|
key: ['POST'],
|
|
289
|
-
runtime: ['
|
|
284
|
+
runtime: ['DELETE'],
|
|
290
285
|
};
|
|
291
286
|
|
|
292
287
|
function methodAllowed(tgt: Routable, method: string | undefined): boolean {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
3
|
+
import {
|
|
4
|
+
apiFailure,
|
|
5
|
+
apiSession,
|
|
6
|
+
bodyField,
|
|
7
|
+
cors,
|
|
8
|
+
readJsonBody,
|
|
9
|
+
sendJson,
|
|
10
|
+
type ApiSession,
|
|
11
|
+
} from './api-http.js';
|
|
12
|
+
import { parseId } from '../db/ids.js';
|
|
13
|
+
import type { AgentConnectors } from '../db/agent-connectors.js';
|
|
14
|
+
|
|
15
|
+
const PREFIX = '/api/agents';
|
|
16
|
+
|
|
17
|
+
export interface AgentConnectorApiDeps {
|
|
18
|
+
agentConnectors: (email: string, agentId: string) => Promise<AgentConnectors>;
|
|
19
|
+
addConnector: (
|
|
20
|
+
email: string,
|
|
21
|
+
agentId: string,
|
|
22
|
+
connectorId: string,
|
|
23
|
+
) => Promise<AgentConnectors>;
|
|
24
|
+
removeConnector: (
|
|
25
|
+
email: string,
|
|
26
|
+
agentId: string,
|
|
27
|
+
connectorId: string,
|
|
28
|
+
) => Promise<AgentConnectors>;
|
|
29
|
+
mintCode: (
|
|
30
|
+
email: string,
|
|
31
|
+
agentId: string,
|
|
32
|
+
) => Promise<{ code: string; expiresAt: number; agent: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Routable =
|
|
36
|
+
| { kind: 'connectors'; id: string }
|
|
37
|
+
| { kind: 'connector'; id: string; connectorId: string }
|
|
38
|
+
| { kind: 'code'; id: string };
|
|
39
|
+
|
|
40
|
+
type Target = Routable | { kind: 'unknown' } | null;
|
|
41
|
+
|
|
42
|
+
const ALLOWED: Record<Routable['kind'], string[]> = {
|
|
43
|
+
connectors: ['GET', 'POST'],
|
|
44
|
+
connector: ['DELETE'],
|
|
45
|
+
code: ['POST'],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function connectorTarget(id: string, rest: string[]): Target {
|
|
49
|
+
if (rest.length === 1) return { kind: 'connectors', id };
|
|
50
|
+
if (rest.length > 2) return { kind: 'unknown' };
|
|
51
|
+
const connectorId = parseId(rest[1] ?? '');
|
|
52
|
+
return connectorId === null
|
|
53
|
+
? { kind: 'unknown' }
|
|
54
|
+
: { kind: 'connector', id, connectorId };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function target(path: string): Target {
|
|
58
|
+
if (!path.startsWith(`${PREFIX}/`)) return null;
|
|
59
|
+
const segments = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
|
|
60
|
+
const id = parseId(segments[0] ?? '');
|
|
61
|
+
const head = segments[1];
|
|
62
|
+
if (id === null || head === undefined) return null;
|
|
63
|
+
if (head === 'connectors') return connectorTarget(id, segments.slice(1));
|
|
64
|
+
if (head === 'code' && segments.length === 2) return { kind: 'code', id };
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleConnectors(
|
|
69
|
+
req: IncomingMessage,
|
|
70
|
+
res: ServerResponse,
|
|
71
|
+
deps: AgentConnectorApiDeps,
|
|
72
|
+
session: ApiSession,
|
|
73
|
+
id: string,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
if (req.method === 'GET') {
|
|
76
|
+
sendJson(req, res, 200, await deps.agentConnectors(session.email, id));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const connectorId = bodyField(await readJsonBody(req), 'connectorId');
|
|
80
|
+
if (typeof connectorId !== 'string' || parseId(connectorId) === null) {
|
|
81
|
+
sendJson(req, res, 400, { error: 'connectorId is required' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
sendJson(req, res, 200, await deps.addConnector(session.email, id, connectorId));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function handleCode(
|
|
88
|
+
req: IncomingMessage,
|
|
89
|
+
res: ServerResponse,
|
|
90
|
+
deps: AgentConnectorApiDeps,
|
|
91
|
+
session: ApiSession,
|
|
92
|
+
id: string,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const minted = await deps.mintCode(session.email, id);
|
|
95
|
+
log.info({ agent: id, email: session.email }, 'agent-api: minted pairing code');
|
|
96
|
+
sendJson(req, res, 201, minted);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function route(
|
|
100
|
+
req: IncomingMessage,
|
|
101
|
+
res: ServerResponse,
|
|
102
|
+
deps: AgentConnectorApiDeps,
|
|
103
|
+
session: ApiSession,
|
|
104
|
+
tgt: Routable,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
if (tgt.kind === 'connectors') await handleConnectors(req, res, deps, session, tgt.id);
|
|
108
|
+
else if (tgt.kind === 'code') await handleCode(req, res, deps, session, tgt.id);
|
|
109
|
+
else
|
|
110
|
+
sendJson(
|
|
111
|
+
req,
|
|
112
|
+
res,
|
|
113
|
+
200,
|
|
114
|
+
await deps.removeConnector(session.email, tgt.id, tgt.connectorId),
|
|
115
|
+
);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
apiFailure(req, res, err, 'agent-api');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function handleAgentConnectorRequest(
|
|
122
|
+
req: IncomingMessage,
|
|
123
|
+
res: ServerResponse,
|
|
124
|
+
deps: AgentConnectorApiDeps,
|
|
125
|
+
): boolean {
|
|
126
|
+
const tgt = target((req.url ?? '').split('?')[0] ?? '');
|
|
127
|
+
if (tgt === null) return false;
|
|
128
|
+
if (req.method === 'OPTIONS') {
|
|
129
|
+
res.writeHead(204, cors(req)).end();
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (tgt.kind === 'unknown') {
|
|
133
|
+
sendJson(req, res, 404, { error: 'no such connector' });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (!ALLOWED[tgt.kind].includes(req.method ?? '')) {
|
|
137
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const session = apiSession(req);
|
|
141
|
+
if (!session) {
|
|
142
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
route(req, res, deps, session, tgt).catch((err: unknown) => {
|
|
146
|
+
log.warn({ err: errMsg(err) }, 'agent-api: unhandled error');
|
|
147
|
+
if (!res.headersSent) sendJson(req, res, 500, { error: 'agent api failed' });
|
|
148
|
+
});
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { makeCodeStore } from './pair-codes.js';
|
|
2
|
+
|
|
3
|
+
export interface AgentCode {
|
|
4
|
+
email: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const store = makeCodeStore<AgentCode>('ma');
|
|
9
|
+
|
|
10
|
+
export const AGENT_CODE_RE = store.pattern;
|
|
11
|
+
|
|
12
|
+
export const mintAgentCode = (
|
|
13
|
+
entry: AgentCode,
|
|
14
|
+
now?: number,
|
|
15
|
+
): { code: string; expiresAt: number } => store.mint(entry, now);
|
|
16
|
+
|
|
17
|
+
export const takeAgentCode = (code: string, now?: number): AgentCode | undefined =>
|
|
18
|
+
store.take(code, now);
|
|
19
|
+
|
|
20
|
+
export const agentCodeCount = (): number => store.count();
|
|
@@ -2,10 +2,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import { ApiError } from './api-error.js';
|
|
3
3
|
import { errMsg, log } from './log.js';
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
verifyAgentToken,
|
|
6
6
|
verifyRunToken,
|
|
7
7
|
verifySession,
|
|
8
|
-
type
|
|
8
|
+
type AgentClaims,
|
|
9
9
|
type RunClaims,
|
|
10
10
|
} from './session.js';
|
|
11
11
|
import { extractToken } from '../mcp/request-identity.js';
|
|
@@ -67,16 +67,39 @@ export function runIdentity(req: IncomingMessage): RunClaims | null {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export
|
|
70
|
+
export interface AgentIdentity extends AgentClaims {
|
|
71
|
+
runtimeId?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function agentIdentity(req: IncomingMessage): AgentIdentity | null {
|
|
71
75
|
const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
|
|
72
76
|
if (secret === '') return null;
|
|
73
77
|
const token = extractToken(req) ?? '';
|
|
74
78
|
if (token === '') return null;
|
|
75
79
|
try {
|
|
76
|
-
const { email,
|
|
77
|
-
return { email: email.toLowerCase(),
|
|
80
|
+
const { email, agentId } = verifyAgentToken(token, secret);
|
|
81
|
+
return { email: email.toLowerCase(), agentId };
|
|
78
82
|
} catch {
|
|
79
|
-
|
|
83
|
+
const run = runIdentity(req);
|
|
84
|
+
if (run === null) return null;
|
|
85
|
+
return { email: run.email, agentId: run.agentId, runtimeId: run.runtimeId };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type Fence = (runtimeId: string, agentId: string) => Promise<void>;
|
|
90
|
+
|
|
91
|
+
const RELEASED = 'this machine no longer holds the agent';
|
|
92
|
+
|
|
93
|
+
export async function assertLease(
|
|
94
|
+
who: AgentIdentity,
|
|
95
|
+
fence: Fence,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
if (who.runtimeId === undefined) return;
|
|
98
|
+
try {
|
|
99
|
+
await fence(who.runtimeId, who.agentId);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof ApiError) throw new ApiError(RELEASED, 401);
|
|
102
|
+
throw err;
|
|
80
103
|
}
|
|
81
104
|
}
|
|
82
105
|
|
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
touchRuntime,
|
|
27
27
|
} from '../db/runtimes.js';
|
|
28
28
|
import {
|
|
29
|
-
|
|
29
|
+
blockedStationsFor,
|
|
30
|
+
mintAgentCodeForEmail,
|
|
30
31
|
releaseRuntimeForEmail,
|
|
31
32
|
} from '../db/runtime-admin.js';
|
|
32
33
|
import { loadAgentForRuntime, localAgentKey } from '../db/materialize.js';
|
|
@@ -82,14 +83,12 @@ import {
|
|
|
82
83
|
setMemberRoleForEmail,
|
|
83
84
|
} from '../db/projects.js';
|
|
84
85
|
import {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
renameCollectionForEmail,
|
|
92
|
-
} from '../db/connector-collections.js';
|
|
86
|
+
addConnectorToAgentForEmail,
|
|
87
|
+
agentConnectorsForEmail,
|
|
88
|
+
connectorIdsByAgent,
|
|
89
|
+
removeConnectorFromAgentForEmail,
|
|
90
|
+
} from '../db/agent-connectors.js';
|
|
91
|
+
import type { AgentConnectorApiDeps } from './agent-connector-api.js';
|
|
93
92
|
import type { StationName } from '../db/schema.js';
|
|
94
93
|
import { AttachSessions } from './attach-session.js';
|
|
95
94
|
import { startUploadReaper } from './upload-store.js';
|
|
@@ -208,8 +207,8 @@ const agentApi: AgentApiDeps = {
|
|
|
208
207
|
gatherAccounts: gatherAccountsForAgents,
|
|
209
208
|
capabilities: accountStationCapabilities,
|
|
210
209
|
liveness: agentLiveness,
|
|
211
|
-
mintRuntimeCode: mintRuntimeCodeForEmail,
|
|
212
210
|
releaseRuntime: releaseRuntimeForEmail,
|
|
211
|
+
connectorIds: connectorIdsByAgent,
|
|
213
212
|
runtimes: runtimeLabels,
|
|
214
213
|
prepareAccount,
|
|
215
214
|
attachAccount: attachAccountToAgent,
|
|
@@ -222,6 +221,14 @@ const runApi: RunApiDeps = {
|
|
|
222
221
|
fenceRuntime,
|
|
223
222
|
touchRuntime,
|
|
224
223
|
loadAgent: loadAgentForRuntime,
|
|
224
|
+
blockedStations: blockedStationsFor,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const agentConnectorApi: AgentConnectorApiDeps = {
|
|
228
|
+
agentConnectors: agentConnectorsForEmail,
|
|
229
|
+
addConnector: addConnectorToAgentForEmail,
|
|
230
|
+
removeConnector: removeConnectorFromAgentForEmail,
|
|
231
|
+
mintCode: mintAgentCodeForEmail,
|
|
225
232
|
};
|
|
226
233
|
|
|
227
234
|
const projectApi: ProjectApiDeps = {
|
|
@@ -238,13 +245,8 @@ const projectApi: ProjectApiDeps = {
|
|
|
238
245
|
const connectorApi: ConnectorApiDeps = {
|
|
239
246
|
listConnectors: listConnectorsForEmail,
|
|
240
247
|
connectorNamesByIds,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
createCollection: createCollectionForEmail,
|
|
244
|
-
renameCollection: renameCollectionForEmail,
|
|
245
|
-
deleteCollection: deleteCollectionForEmail,
|
|
246
|
-
addToCollection: addToCollectionForEmail,
|
|
247
|
-
removeFromCollection: removeFromCollectionForEmail,
|
|
248
|
+
agentConnectors: agentConnectorsForEmail,
|
|
249
|
+
fenceRuntime,
|
|
248
250
|
renameConnector: renameConnectorForEmail,
|
|
249
251
|
createConnector: createConnectorForEmail,
|
|
250
252
|
createPendingConnector: createPendingConnectorForEmail,
|
|
@@ -263,7 +265,14 @@ async function main(): Promise<void> {
|
|
|
263
265
|
const metroMcp = await createMetroMcp();
|
|
264
266
|
webhookServer = await startWebhookServer(
|
|
265
267
|
emit,
|
|
266
|
-
{
|
|
268
|
+
{
|
|
269
|
+
agentApi,
|
|
270
|
+
agentConnectorApi,
|
|
271
|
+
connectorApi,
|
|
272
|
+
projectApi,
|
|
273
|
+
runApi,
|
|
274
|
+
relayApi: { target: relayTarget, fence: fenceRuntime },
|
|
275
|
+
},
|
|
267
276
|
metroMcp.httpHandler,
|
|
268
277
|
metroCall,
|
|
269
278
|
);
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import {
|
|
2
|
+
import { log } from './log.js';
|
|
3
3
|
import {
|
|
4
|
+
agentIdentity,
|
|
5
|
+
apiFailure,
|
|
6
|
+
assertLease,
|
|
4
7
|
bodyField,
|
|
5
|
-
cliIdentity,
|
|
6
8
|
cors,
|
|
7
9
|
readJsonBody,
|
|
8
10
|
sendJson,
|
|
9
11
|
} from './api-http.js';
|
|
10
12
|
import { extractToken } from '../mcp/request-identity.js';
|
|
11
13
|
import { publicBaseOrDefault } from './attach-serve.js';
|
|
12
|
-
import {
|
|
14
|
+
import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
|
|
13
15
|
import { relayServersJson } from './connector-json.js';
|
|
14
16
|
import { sessionTtlFromEnv } from './google-oauth.js';
|
|
15
|
-
import {
|
|
17
|
+
import { signAgentToken } from './session.js';
|
|
16
18
|
import type { ConnectorApiDeps } from './connector-api.js';
|
|
17
19
|
|
|
18
20
|
const CLAIM_PATH = '/api/cli/claim';
|
|
@@ -42,28 +44,28 @@ async function handleClaim(
|
|
|
42
44
|
}
|
|
43
45
|
const raw = bodyField(await readJsonBody(req), 'code');
|
|
44
46
|
const code = typeof raw === 'string' ? raw.trim() : '';
|
|
45
|
-
if (!
|
|
47
|
+
if (!AGENT_CODE_RE.test(code)) {
|
|
46
48
|
sendJson(req, res, 400, {
|
|
47
49
|
error:
|
|
48
|
-
'that does not look like
|
|
50
|
+
'that does not look like an agent code — metro login wants the ma_… code from the agent page',
|
|
49
51
|
});
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
|
-
const taken =
|
|
54
|
+
const taken = takeAgentCode(code);
|
|
53
55
|
if (taken === undefined) {
|
|
54
56
|
sendJson(req, res, 400, {
|
|
55
57
|
error: 'that code has expired or was already used',
|
|
56
58
|
});
|
|
57
59
|
return;
|
|
58
60
|
}
|
|
59
|
-
const
|
|
60
|
-
const token =
|
|
61
|
-
{ email: taken.email,
|
|
61
|
+
const agent = await deps.agentConnectors(taken.email, taken.agentId);
|
|
62
|
+
const token = signAgentToken(
|
|
63
|
+
{ email: taken.email, agentId: agent.id },
|
|
62
64
|
secret,
|
|
63
65
|
{ ttlSec: sessionTtlFromEnv() },
|
|
64
66
|
);
|
|
65
|
-
log.info({ email: taken.email,
|
|
66
|
-
sendJson(req, res, 200, { token, email: taken.email,
|
|
67
|
+
log.info({ email: taken.email, agent: agent.id }, 'cli: code claimed');
|
|
68
|
+
sendJson(req, res, 200, { token, email: taken.email, agent: agent.name });
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
async function handleRead(
|
|
@@ -72,23 +74,24 @@ async function handleRead(
|
|
|
72
74
|
deps: ConnectorApiDeps,
|
|
73
75
|
path: string,
|
|
74
76
|
): Promise<void> {
|
|
75
|
-
const who =
|
|
77
|
+
const who = agentIdentity(req);
|
|
76
78
|
if (who === null) {
|
|
77
79
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
80
|
-
|
|
82
|
+
await assertLease(who, deps.fenceRuntime);
|
|
83
|
+
const agent = await deps.agentConnectors(who.email, who.agentId);
|
|
81
84
|
if (path === SESSION_PATH) {
|
|
82
|
-
sendJson(req, res, 200, { email: who.email,
|
|
85
|
+
sendJson(req, res, 200, { email: who.email, agent: agent.name });
|
|
83
86
|
return;
|
|
84
87
|
}
|
|
85
|
-
const entries = await deps.connectorNamesByIds(
|
|
88
|
+
const entries = await deps.connectorNamesByIds(agent.connectorIds);
|
|
86
89
|
const json = relayServersJson(
|
|
87
90
|
entries,
|
|
88
91
|
publicBaseOrDefault(),
|
|
89
92
|
extractToken(req) ?? '',
|
|
90
93
|
);
|
|
91
|
-
sendJson(req, res, 200, { json,
|
|
94
|
+
sendJson(req, res, 200, { json, agent: agent.name });
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
export function handleCliPairRequest(
|
|
@@ -116,8 +119,7 @@ export function handleCliPairRequest(
|
|
|
116
119
|
? handleClaim(req, res, deps)
|
|
117
120
|
: handleRead(req, res, deps, path);
|
|
118
121
|
work.catch((err: unknown) => {
|
|
119
|
-
|
|
120
|
-
if (!res.headersSent) sendJson(req, res, 500, { error: 'cli api failed' });
|
|
122
|
+
apiFailure(req, res, err, 'cli');
|
|
121
123
|
});
|
|
122
124
|
return true;
|
|
123
125
|
}
|