@stage-labs/metro 0.1.0-beta.50 → 0.1.0-beta.52

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.
Files changed (51) hide show
  1. package/dist/api.js +8 -142
  2. package/dist/cli.js +27 -131
  3. package/dist/control.js +5 -104
  4. package/dist/plugin.js +2 -4
  5. package/dist/runtime.js +18 -72
  6. package/dist/serve.js +3 -2
  7. package/dist/tail.js +7 -8
  8. package/dist/update.js +13 -1
  9. package/package.json +1 -1
  10. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -2
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +3 -35
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +7 -22
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +4 -54
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +1 -2
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +29 -208
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +0 -4
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +2 -37
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +0 -3
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +4 -6
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +3 -14
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +0 -81
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +2 -2
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +119 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +42 -12
  25. package/runtime/node_modules/@metro-labs/mcp/src/db/account-attach.ts +1 -73
  26. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +1 -172
  27. package/runtime/node_modules/@metro-labs/mcp/src/db/client.ts +0 -5
  28. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +2 -89
  29. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +3 -262
  30. package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +1 -1
  31. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +2 -140
  32. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +1 -89
  33. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +14 -41
  34. package/runtime/node_modules/@metro-labs/mcp/src/db/vault.ts +5 -5
  35. package/runtime/node_modules/@metro-labs/whatsapp/package.json +0 -2
  36. package/runtime/runtime.json +1 -1
  37. package/dist/prompt.js +0 -61
  38. package/dist/store.js +0 -93
  39. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-import.ts +0 -76
  40. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +0 -20
  41. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +0 -141
  42. package/runtime/node_modules/@metro-labs/mcp/src/daemon/import-api.ts +0 -68
  43. package/runtime/node_modules/@metro-labs/mcp/src/daemon/pair-codes.ts +0 -58
  44. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +0 -214
  45. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +0 -139
  46. package/runtime/node_modules/@metro-labs/mcp/src/daemon/runtime-source.ts +0 -105
  47. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +0 -143
  48. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +0 -309
  49. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +0 -24
  50. package/runtime/node_modules/@metro-labs/mcp/src/db/runtimes.ts +0 -105
  51. package/runtime/node_modules/@metro-labs/mcp/src/db/whatsapp-login.ts +0 -18
@@ -6,13 +6,10 @@ import {
6
6
  writeFileSync,
7
7
  } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
- import { ID_RE } from './ids.js';
10
9
  import { join } from 'node:path';
11
- import { eq } from 'drizzle-orm';
12
10
  import { log } from '../daemon/log.js';
13
11
  import { writeSecure } from '../daemon/secure-fs.js';
14
12
  import { isLocalMode, trainsDir } from '../daemon/paths.js';
15
- import { closeDb, databaseUrl, getDb } from './client.js';
16
13
  import {
17
14
  setAgentMap,
18
15
  setAllowlistMap,
@@ -21,14 +18,7 @@ import {
21
18
  type AllowlistMap,
22
19
  } from './agent-map.js';
23
20
  import { setKeyMap } from './key-map.js';
24
- import {
25
- agentConnectors,
26
- agents,
27
- connectors,
28
- stations,
29
- STATIONS,
30
- type StationName,
31
- } from './schema.js';
21
+ import { STATIONS, type StationName } from './schema.js';
32
22
 
33
23
  interface StationTarget {
34
24
  file: string;
@@ -99,116 +89,12 @@ function accountFilePath(station: StationName): string {
99
89
  return process.env[target.fileEnv] ?? join(METRO_DIR, target.file);
100
90
  }
101
91
 
102
- function agentFilter(): string | undefined {
103
- const v = process.env.METRO_AGENT?.trim();
104
- if (!v) return undefined;
105
- if (!ID_RE.test(v))
106
- throw new Error(`METRO_AGENT must be an 11-character agent id, got '${v}'`);
107
- return v;
108
- }
109
-
110
92
  export type StationSource = () => Promise<LoadedAgent[]>;
111
93
 
112
94
  export const MOVABLE_STATIONS = new Set<StationName>(
113
95
  STATIONS.filter((s) => s !== 'webhook'),
114
96
  );
115
97
 
116
- export function unmovableStations(list: LoadedAccount[]): StationName[] {
117
- return [
118
- ...new Set(
119
- list
120
- .map((a) => a.station)
121
- .filter((station) => !MOVABLE_STATIONS.has(station)),
122
- ),
123
- ];
124
- }
125
-
126
- export async function loadAllStationsFor(
127
- agentId: string,
128
- ): Promise<LoadedAccount[]> {
129
- const rows = await getDb()
130
- .select()
131
- .from(stations)
132
- .where(eq(stations.agentId, agentId));
133
- return rows.map((r) => ({
134
- station: r.station,
135
- id: r.id,
136
- allowlist: r.allowlist,
137
- config: r.config as Record<string, unknown>,
138
- }));
139
- }
140
-
141
- export async function loadAgentForRuntime(
142
- agentId: string,
143
- ): Promise<LoadedAgent> {
144
- const db = getDb();
145
- const rows = await db.select().from(agents).where(eq(agents.id, agentId));
146
- const a = rows[0];
147
- if (a === undefined) throw new Error(`no such agent '${agentId}'`);
148
- const acctRows = await db
149
- .select()
150
- .from(stations)
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));
157
- return {
158
- id: a.id,
159
- name: a.name,
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
- })),
168
- accounts: acctRows
169
- .filter((r) => MOVABLE_STATIONS.has(r.station))
170
- .map((r) => ({
171
- station: r.station,
172
- id: r.id,
173
- allowlist: r.allowlist,
174
- config: r.config as Record<string, unknown>,
175
- })),
176
- };
177
- }
178
-
179
- const pgSource: StationSource = () => loadAgents();
180
-
181
- async function loadAgents(): Promise<LoadedAgent[]> {
182
- const db = getDb();
183
- const only = agentFilter();
184
- const agentRows =
185
- only !== undefined
186
- ? await db.select().from(agents).where(eq(agents.id, only))
187
- : await db.select().from(agents);
188
- if (only !== undefined && agentRows.length === 0)
189
- throw new Error(`METRO_AGENT=${only} does not match any agent`);
190
-
191
- const out: LoadedAgent[] = [];
192
- for (const a of agentRows) {
193
- const acctRows = await db
194
- .select()
195
- .from(stations)
196
- .where(eq(stations.agentId, a.id));
197
- out.push({
198
- id: a.id,
199
- name: a.name,
200
- accounts: acctRows.map((r) => ({
201
- station: r.station,
202
- id: r.id,
203
- allowlist: r.allowlist,
204
- config: r.config as Record<string, unknown>,
205
- })),
206
- key: a.runtimeId === null ? a.key : null,
207
- });
208
- }
209
- return out;
210
- }
211
-
212
98
  function trainStubPath(station: StationName): string {
213
99
  return join(trainsDir(), `${station}.ts`);
214
100
  }
@@ -227,11 +113,8 @@ export function writeIfChanged(path: string, content: string): boolean {
227
113
  return true;
228
114
  }
229
115
 
230
- const isLocalRuntime = (): boolean =>
231
- (process.env.METRO_RUN_TOKEN?.trim() ?? '') !== '';
232
-
233
116
  export const stationRunsHere = (station: StationName): boolean =>
234
- isLocalRuntime() || isLocalMode() || !MOVABLE_STATIONS.has(station);
117
+ isLocalMode() || !MOVABLE_STATIONS.has(station);
235
118
 
236
119
  interface WrittenStations {
237
120
  active: Map<StationName, number>;
@@ -350,16 +233,6 @@ export async function materializeFrom(
350
233
  );
351
234
  }
352
235
 
353
- export async function materializeFromDb(): Promise<void> {
354
- if (!databaseUrl())
355
- throw new Error('DATABASE_URL is not set — accounts load from Postgres');
356
- try {
357
- await materializeFrom(pgSource);
358
- } finally {
359
- await closeDb();
360
- }
361
- }
362
-
363
236
  export interface ReloadedStations {
364
237
  active: StationName[];
365
238
  removed: StationName[];
@@ -378,14 +251,3 @@ export async function reloadFrom(
378
251
  );
379
252
  return { active: [...active.keys()], removed, changed };
380
253
  }
381
-
382
- export async function reloadAccountsFromDb(): Promise<ReloadedStations> {
383
- if (!databaseUrl())
384
- throw new Error('DATABASE_URL is not set — accounts load from Postgres');
385
- return reloadFrom(pgSource);
386
- }
387
-
388
- if (import.meta.main) {
389
- await materializeFromDb();
390
- process.exit(0);
391
- }
@@ -1,4 +1,4 @@
1
- import { boolean, index, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core';
1
+ import { index, jsonb, pgTable, text } from 'drizzle-orm/pg-core';
2
2
 
3
3
  export const STATIONS = [
4
4
  'xmtp',
@@ -17,98 +17,10 @@ export const users = pgTable('users', {
17
17
  address: text('address').unique(),
18
18
  });
19
19
 
20
-
21
20
  export type ProjectRole = 'admin' | 'member';
22
21
 
23
- export const projects = pgTable('projects', {
24
- id: text('id').primaryKey(),
25
- name: text('name').notNull(),
26
- ownerId: text('owner_id')
27
- .notNull()
28
- .references(() => users.id, { onDelete: 'restrict' }),
29
- isDefault: boolean('is_default').notNull().default(false),
30
- });
31
-
32
- export const projectMembers = pgTable(
33
- 'project_members',
34
- {
35
- id: text('id').primaryKey(),
36
- projectId: text('project_id')
37
- .notNull()
38
- .references(() => projects.id, { onDelete: 'cascade' }),
39
- userId: text('user_id')
40
- .notNull()
41
- .references(() => users.id, { onDelete: 'restrict' }),
42
- role: text('role').$type<ProjectRole>().notNull(),
43
- },
44
- (t) => [unique('project_members_project_id_user_id_unique').on(t.projectId, t.userId)],
45
- );
46
-
47
- export const agents = pgTable('agents', {
48
- id: text('id').primaryKey(),
49
- name: text('name').notNull(),
50
- projectId: text('project_id')
51
- .notNull()
52
- .references(() => projects.id, { onDelete: 'restrict' }),
53
- key: text('key').unique(),
54
- runtimeId: text('runtime_id'),
55
- });
56
-
57
- export const runtimes = pgTable('runtimes', {
58
- id: text('id').primaryKey(),
59
- agentId: text('agent_id')
60
- .notNull()
61
- .references(() => agents.id, { onDelete: 'cascade' }),
62
- label: text('label').notNull(),
63
- createdAt: text('created_at').notNull(),
64
- lastSeenAt: text('last_seen_at'),
65
- revokedAt: text('revoked_at'),
66
- });
67
-
68
- export const stations = pgTable('stations', {
69
- id: text('id').primaryKey(),
70
- agentId: text('agent_id').notNull(),
71
- station: text('station').$type<StationName>().notNull(),
72
- allowlist: text('allowlist').array().default(['*']),
73
- config: jsonb('config').notNull(),
74
- });
75
-
76
-
77
22
  export type ConnectorTransport = 'http' | 'sse';
78
23
 
79
- export const connectors = pgTable(
80
- 'connectors',
81
- {
82
- id: text('id').primaryKey(),
83
- projectId: text('project_id')
84
- .notNull()
85
- .references(() => projects.id, { onDelete: 'restrict' }),
86
- name: text('name').notNull(),
87
- url: text('url').notNull(),
88
- transport: text('transport').$type<ConnectorTransport>().notNull(),
89
- config: jsonb('config').notNull(),
90
- },
91
- );
92
-
93
- export const agentConnectors = pgTable(
94
- 'agent_connectors',
95
- {
96
- id: text('id').primaryKey(),
97
- agentId: text('agent_id')
98
- .notNull()
99
- .references(() => agents.id, { onDelete: 'cascade' }),
100
- connectorId: text('connector_id')
101
- .notNull()
102
- .references(() => connectors.id, { onDelete: 'cascade' }),
103
- },
104
- (t) => [
105
- unique('agent_connectors_agent_id_connector_id_unique').on(
106
- t.agentId,
107
- t.connectorId,
108
- ),
109
- ],
110
- );
111
-
112
24
  export const vault = pgTable(
113
25
  'vault',
114
26
  {
@@ -1,4 +1,4 @@
1
- import { eq, type SQL } from 'drizzle-orm';
1
+ import { eq } from 'drizzle-orm';
2
2
  import { ApiError } from '../daemon/api-error.js';
3
3
  import { getDb } from './client.js';
4
4
  import { newId } from './ids.js';
@@ -22,51 +22,24 @@ export function isUniqueViolation(err: unknown): boolean {
22
22
  return false;
23
23
  }
24
24
 
25
- export function normalizeEmail(raw: string): string {
26
- return raw.trim().toLowerCase();
27
- }
28
-
29
25
  export function normalizeAddress(raw: string): string | null {
30
26
  const address = raw.trim().toLowerCase();
31
27
  return ADDRESS_RE.test(address) ? address : null;
32
28
  }
33
29
 
34
- async function userIdWhere(where: SQL): Promise<string | null> {
35
- const rows = await getDb().select({ id: users.id }).from(users).where(where);
36
- return rows[0]?.id ?? null;
37
- }
38
-
39
- export async function userIdForSubject(subject: string): Promise<string | null> {
40
- const address = normalizeAddress(subject);
41
- if (address !== null) return userIdWhere(eq(users.address, address));
42
- return userIdWhere(eq(users.email, normalizeEmail(subject)));
43
- }
44
-
45
- export async function resolveUserId(
46
- insert: () => Promise<string | undefined>,
47
- lookup: () => Promise<string | null>,
48
- ): Promise<string> {
49
- const inserted = await insert();
50
- if (inserted !== undefined) return inserted;
51
- const existing = await lookup();
52
- if (existing === null)
53
- throw new UserError('user lookup returned no id', 500);
54
- return existing;
55
- }
56
-
57
30
  export async function ensureUserByAddress(raw: string): Promise<string> {
58
31
  const address = normalizeAddress(raw);
59
- if (address === null)
60
- throw new UserError('an Ethereum address is required', 400);
61
- return resolveUserId(
62
- async () => {
63
- const rows = await getDb()
64
- .insert(users)
65
- .values({ id: newId(), address })
66
- .onConflictDoNothing({ target: users.address })
67
- .returning({ id: users.id });
68
- return rows[0]?.id;
69
- },
70
- () => userIdWhere(eq(users.address, address)),
71
- );
32
+ if (address === null) throw new UserError('an Ethereum address is required', 400);
33
+ const db = getDb();
34
+ const inserted = await db
35
+ .insert(users)
36
+ .values({ id: newId(), address })
37
+ .onConflictDoNothing({ target: users.address })
38
+ .returning({ id: users.id });
39
+ const id = inserted[0]?.id;
40
+ if (id !== undefined) return id;
41
+ const rows = await db.select({ id: users.id }).from(users).where(eq(users.address, address));
42
+ const existing = rows[0]?.id;
43
+ if (existing === undefined) throw new UserError('user lookup returned no id', 500);
44
+ return existing;
72
45
  }
@@ -32,7 +32,7 @@ const missing = (): VaultError => new VaultError('no such bundle', 404);
32
32
 
33
33
  function ownerOf(subject: string): string {
34
34
  const address = normalizeAddress(subject);
35
- if (address === null) throw new VaultError('the vault needs a wallet sign-in', 403);
35
+ if (address === null) throw new VaultError('the vault needs a vault identity', 403);
36
36
  return address;
37
37
  }
38
38
 
@@ -44,12 +44,12 @@ function stationsOf(raw: unknown): string[] {
44
44
  return [...new Set(list)];
45
45
  }
46
46
 
47
- function envelopeOf(raw: unknown, id: string, owner: string): Record<string, unknown> {
47
+ function envelopeOf(raw: unknown, id: string): Record<string, unknown> {
48
48
  if (!isRecord(raw) || raw.v !== 1 || raw.agentId !== id)
49
49
  throw new VaultError('envelope must be a v1 envelope for this agent', 400);
50
50
  const key = raw.key;
51
- if (!isRecord(key) || key.recipient !== owner)
52
- throw new VaultError('envelope must be sealed to the signed-in wallet', 400);
51
+ if (!isRecord(key) || normalizeAddress(typeof key.recipient === 'string' ? key.recipient : '') === null)
52
+ throw new VaultError('envelope must name the wallet it was sealed to', 400);
53
53
  if (typeof raw.ciphertext !== 'string' || typeof raw.nonce !== 'string')
54
54
  throw new VaultError('envelope carries no ciphertext', 400);
55
55
  if (JSON.stringify(raw).length > ENVELOPE_MAX)
@@ -63,7 +63,7 @@ export function parseVaultInput(subject: string, id: string, body: unknown): Vau
63
63
  if (!isRecord(body)) throw new VaultError('body must be an object', 400);
64
64
  const name = typeof body.name === 'string' ? body.name.trim() : '';
65
65
  if (!AGENT_NAME_RE.test(name)) throw new VaultError('name is not an agent name', 400);
66
- return { owner, name, stations: stationsOf(body.stations), envelope: envelopeOf(body.envelope, id, owner) };
66
+ return { owner, name, stations: stationsOf(body.stations), envelope: envelopeOf(body.envelope, id) };
67
67
  }
68
68
 
69
69
  const entryOf = (row: { id: string; name: string; stations: unknown; syncedAt: string }): VaultEntry => ({
@@ -19,11 +19,9 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@stage-labs/config": "0.1.0-beta.2",
22
- "@types/qrcode-terminal": "^0.12.2",
23
22
  "eslint": "^10.3.0",
24
23
  "knip": "^6.15.0",
25
24
  "madge": "^8.0.0",
26
- "qrcode-terminal": "^0.12.0",
27
25
  "typescript": "^5"
28
26
  }
29
27
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.50"
2
+ "version": "0.1.0-beta.52"
3
3
  }
package/dist/prompt.js DELETED
@@ -1,61 +0,0 @@
1
- import { createInterface } from 'node:readline';
2
- const CTRL_C = '\u0003';
3
- const BACKSPACE = '\u007f';
4
- function askLine(question) {
5
- const rl = createInterface({ input: process.stdin, output: process.stderr });
6
- return new Promise((resolve) => {
7
- rl.question(question, (answer) => {
8
- rl.close();
9
- resolve(answer.trim());
10
- });
11
- });
12
- }
13
- export function feedSecret(value, chunk) {
14
- let out = value;
15
- for (const ch of chunk) {
16
- if (ch === CTRL_C)
17
- return { kind: 'cancelled' };
18
- if (ch === '\r' || ch === '\n')
19
- return { kind: 'done', value: out };
20
- if (ch === BACKSPACE || ch === '\b') {
21
- out = out.slice(0, -1);
22
- continue;
23
- }
24
- if (ch >= ' ')
25
- out += ch;
26
- }
27
- return { kind: 'more', value: out };
28
- }
29
- export function askSecret(question) {
30
- const stdin = process.stdin;
31
- if (!stdin.isTTY)
32
- return askLine(question);
33
- process.stderr.write(question);
34
- stdin.setRawMode(true);
35
- stdin.resume();
36
- stdin.setEncoding('utf8');
37
- return new Promise((resolve, reject) => {
38
- let value = '';
39
- const finish = () => {
40
- stdin.setRawMode(false);
41
- stdin.pause();
42
- stdin.off('data', onData);
43
- process.stderr.write('\n');
44
- };
45
- const onData = (chunk) => {
46
- const step = feedSecret(value, chunk);
47
- if (step.kind === 'cancelled') {
48
- finish();
49
- reject(new Error('cancelled'));
50
- return;
51
- }
52
- if (step.kind === 'done') {
53
- finish();
54
- resolve(step.value.trim());
55
- return;
56
- }
57
- value = step.value;
58
- };
59
- stdin.on('data', onData);
60
- });
61
- }
package/dist/store.js DELETED
@@ -1,93 +0,0 @@
1
- import { chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
4
- const DEFAULT_BASE = 'https://mcp.metro.box';
5
- const DEFAULT_WEB = 'https://metro.box';
6
- function fromEnv(name, fallback) {
7
- const raw = process.env[name]?.trim();
8
- return raw === undefined || raw === '' ? fallback : raw.replace(/\/+$/, '');
9
- }
10
- export function metroUrl() {
11
- return fromEnv('METRO_URL', DEFAULT_BASE);
12
- }
13
- export function metroWebUrl() {
14
- return fromEnv('METRO_UI_URL', DEFAULT_WEB);
15
- }
16
- function configDir() {
17
- const xdg = process.env.XDG_CONFIG_HOME?.trim();
18
- const base = xdg === undefined || xdg === '' ? join(homedir(), '.config') : xdg;
19
- return join(base, 'metro');
20
- }
21
- export function credentialsPath() {
22
- return join(configDir(), 'credentials.json');
23
- }
24
- function readStoredAt(path) {
25
- let raw;
26
- try {
27
- raw = readFileSync(path, 'utf8');
28
- }
29
- catch {
30
- return null;
31
- }
32
- let parsed;
33
- try {
34
- parsed = JSON.parse(raw);
35
- }
36
- catch {
37
- return null;
38
- }
39
- if (typeof parsed !== 'object' || parsed === null)
40
- return null;
41
- const { token, url } = parsed;
42
- if (typeof token !== 'string' || token === '')
43
- return null;
44
- return { token, url: typeof url === 'string' ? url : DEFAULT_BASE };
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
- }
69
- export function readToken() {
70
- const fromEnv = process.env.METRO_TOKEN?.trim();
71
- if (fromEnv !== undefined && fromEnv !== '')
72
- return fromEnv;
73
- const stored = readStored();
74
- if (stored === null)
75
- return null;
76
- return stored.url === metroUrl() ? stored.token : null;
77
- }
78
- export function writeToken(token) {
79
- const path = credentialsPath();
80
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
81
- writeFileSync(path, `${JSON.stringify({ token, url: metroUrl() })}\n`, {
82
- mode: 0o600,
83
- });
84
- chmodSync(path, 0o600);
85
- }
86
- export function clearToken() {
87
- try {
88
- rmSync(credentialsPath());
89
- }
90
- catch {
91
- return;
92
- }
93
- }
@@ -1,76 +0,0 @@
1
- import { mkdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { ApiError } from './api-error.js';
4
- import { configDir } from './paths.js';
5
- import { writeSecure } from './secure-fs.js';
6
- import { carriesSecretsSafely, httpSource } from './runtime-source.js';
7
- import type { LoadedAgent } from '../db/materialize.js';
8
-
9
- const CLAIM_TIMEOUT_MS = 15_000;
10
- const CODE_RE = /^ma_[A-Za-z0-9_-]{16}$/;
11
-
12
- function metroBaseUrl(): string {
13
- const raw = (process.env.METRO_URL?.trim() ?? '').replace(/\/+$/, '');
14
- const url = raw === '' ? 'https://mcp.metro.box' : raw;
15
- if (!carriesSecretsSafely(url))
16
- throw new ApiError(`refusing to read station credentials from ${url} in the clear`, 400);
17
- return url;
18
- }
19
-
20
- export function parsePairingCode(raw: unknown): string {
21
- const code = typeof raw === 'string' ? raw.trim() : '';
22
- if (!CODE_RE.test(code))
23
- throw new ApiError('paste the pairing code from the agent\'s page on metro.box', 400);
24
- return code;
25
- }
26
-
27
- async function claim(base: string, code: string, label: string): Promise<string> {
28
- let res: Response;
29
- try {
30
- res = await fetch(`${base}/api/run/claim`, {
31
- method: 'POST',
32
- headers: { 'content-type': 'application/json' },
33
- body: JSON.stringify({ code, label }),
34
- signal: AbortSignal.timeout(CLAIM_TIMEOUT_MS),
35
- redirect: 'manual',
36
- });
37
- } catch {
38
- throw new ApiError(`could not reach ${base}`, 502);
39
- }
40
- const body: unknown = await res.json().catch(() => null);
41
- const field = (key: string): unknown =>
42
- typeof body === 'object' && body !== null ? (body as Record<string, unknown>)[key] : undefined;
43
- if (!res.ok) {
44
- const reason = field('error');
45
- throw new ApiError(
46
- typeof reason === 'string' ? reason : `metro answered ${String(res.status)} for the code`,
47
- res.status === 404 ? 400 : res.status,
48
- );
49
- }
50
- const token = field('token');
51
- if (typeof token !== 'string' || token === '')
52
- throw new ApiError('metro returned no runtime token', 502);
53
- return token;
54
- }
55
-
56
- function storeRunToken(agentId: string, token: string, url: string): string {
57
- const dir = configDir();
58
- mkdirSync(dir, { recursive: true, mode: 0o700 });
59
- const path = join(dir, `runtime-${agentId}.json`);
60
- writeSecure(path, `${JSON.stringify({ token, url }, null, 2)}\n`);
61
- return path;
62
- }
63
-
64
- export async function fetchAgentWithCode(
65
- code: string,
66
- label: string,
67
- base = metroBaseUrl(),
68
- ): Promise<LoadedAgent> {
69
- const token = await claim(base, code, label);
70
- const agents = await httpSource({ url: base, token })();
71
- const agent = agents[0];
72
- if (agent === undefined || agents.length !== 1)
73
- throw new ApiError('metro returned no agent for that code', 502);
74
- storeRunToken(agent.id, token, base);
75
- return agent;
76
- }
@@ -1,20 +0,0 @@
1
- import { makeCodeStore } from './pair-codes.js';
2
-
3
- export interface AgentCode {
4
- subject: 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();