@stage-labs/metro 0.1.0-beta.287 → 0.1.0-beta.289

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 (54) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/bin/policy.mjs +3 -2
  4. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  5. package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +17 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +6 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +7 -3
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +148 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +14 -2
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -72
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +101 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +40 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/metro-api.ts +93 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +201 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +32 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -3
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +4 -7
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +61 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +111 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +246 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +25 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +138 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +60 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +39 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +187 -0
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -1
  27. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +33 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +2 -0
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +10 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +4 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +25 -4
  32. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -20
  33. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +16 -13
  34. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +3 -2
  35. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +2 -1
  36. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +13 -3
  37. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +6 -0
  38. package/runtime/node_modules/@metro-labs/http/package.json +10 -2
  39. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +110 -0
  40. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +6 -2
  41. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +115 -0
  42. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +54 -0
  43. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-discovery.ts → http/src/connector-discovery.ts} +31 -14
  44. package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +73 -0
  45. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/verify.ts → http/src/connector-mcp.ts} +16 -16
  46. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-client.ts → http/src/connector-oauth.ts} +36 -13
  47. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +53 -0
  48. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/url.ts → http/src/connector-url.ts} +1 -1
  49. package/runtime/node_modules/@metro-labs/http/src/ed25519.ts +56 -0
  50. package/runtime/runtime.json +1 -1
  51. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  52. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +17 -0
  53. package/runtime/sdk-runner/runner.json +1 -1
  54. /package/runtime/node_modules/@metro-labs/{daemon/src/connectors/reach.ts → http/src/connector-reach.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.287",
3
+ "version": "0.1.0-beta.289",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.287",
3
+ "version": "0.1.0-beta.289",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -51,7 +51,7 @@ function targets(snap, name, input) {
51
51
 
52
52
  function decide(policy, name, group) {
53
53
  if (!isRecord(policy)) return 'allow';
54
- const byTool = isRecord(policy.tools) ? policy.tools[name] : undefined;
54
+ const byTool = isRecord(policy.tools) && Object.hasOwn(policy.tools, name) ? policy.tools[name] : undefined;
55
55
  const access = byTool ?? policy[group] ?? 'allow';
56
56
  return access in RANK ? access : 'allow';
57
57
  }
@@ -62,7 +62,8 @@ function connectorCall(snap, toolName) {
62
62
  for (const [server, entry] of Object.entries(isRecord(snap.connectors) ? snap.connectors : {})) {
63
63
  if (!isRecord(entry)) continue;
64
64
  const name = text(entry.name);
65
- const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`, `mcp__${normalized(`metro.box ${name}`)}__`];
65
+ const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
66
+ if (!/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id))) prefixes.push(`mcp__${normalized(`metro.box ${name}`)}__`);
66
67
  const prefix = prefixes.find((p) => toolName.startsWith(p) && toolName.length > p.length);
67
68
  if (prefix === undefined) continue;
68
69
  const called = toolName.slice(prefix.length);
@@ -6,6 +6,7 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./log": "./src/log.ts",
9
+ "./connector-sync": "./src/connector-sync.ts",
9
10
  "./events": "./src/events.ts",
10
11
  "./is-record": "./src/is-record.ts",
11
12
  "./runner-activity": "./src/runner-activity.ts",
@@ -0,0 +1,17 @@
1
+ export interface ConnectorSyncReceipt {
2
+ state: 'waiting' | 'received' | 'expired' | 'unavailable';
3
+ lastReceivedAt: number | null;
4
+ leaseExpiresAt: number | null;
5
+ counts: { total: number; ready: number; unavailable: number } | null;
6
+ }
7
+
8
+ export interface ConnectorSyncStatus extends Omit<ConnectorSyncReceipt, 'state'> {
9
+ enabled: boolean;
10
+ source: 'saved' | 'environment' | 'default' | 'invalid' | 'scope_changed';
11
+ enrolled: boolean;
12
+ owner: string | null;
13
+ agentId: string | null;
14
+ scope: string;
15
+ liveEnabled: boolean;
16
+ state: 'off' | 'not_enrolled' | ConnectorSyncReceipt['state'];
17
+ }
@@ -138,6 +138,12 @@ export function cancelPrompt(requestId: string, owner: object): void {
138
138
  });
139
139
  }
140
140
 
141
+ export function revokeToolPrompts(matches: (tool: string) => boolean): void {
142
+ grants = grants.filter((entry) => !matches(entry.tool));
143
+ for (const entry of held.values())
144
+ if (matches(entry.tool)) cancelPrompt(entry.requestId, entry.owner);
145
+ }
146
+
141
147
  export function revokeCallPrompts(route: CallRoute): void {
142
148
  grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
143
149
  for (const entry of held.values())
@@ -21,7 +21,9 @@ import {
21
21
  trainEventToMetroEvent,
22
22
  } from '../routes/http.js';
23
23
  import { agentsDir, fileSource } from '../agents/files.js';
24
- import { syncPluginServers } from '../connectors/plugin-sync.js';
24
+ import { publishLocalConnectors } from '../connectors/publication.js';
25
+ import { readAgentKey } from '../connectors/agent-key.js';
26
+ import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
25
27
  import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.js';
26
28
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
27
29
  import { runningAsRoot } from '../metro-user/privilege.js';
@@ -158,9 +160,10 @@ installBearerSessions(agentsDir(), localOwner);
158
160
  );
159
161
  metroMcp.startInbound();
160
162
  startPromptExpiry();
161
- syncPluginServers(readLocalConnectors());
163
+ publishLocalConnectors(readLocalConnectors());
162
164
  startUploadReaper();
163
165
  startAttachReaper();
166
+ watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
164
167
  startCloudWatchPublisher();
165
168
  watchFallbackUsage();
166
169
  announceLocalEndpoint();
@@ -192,7 +195,7 @@ async function startClaude(): Promise<void> {
192
195
  ensureMetroPlugin()
193
196
  .then((outcome) => {
194
197
  log.info({ outcome }, 'plugin: metro plugin for Claude Code');
195
- if (outcome !== 'skipped') syncPluginServers(readLocalConnectors());
198
+ if (outcome !== 'skipped') publishLocalConnectors(readLocalConnectors());
196
199
  })
197
200
  .catch((err: unknown) => {
198
201
  log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
@@ -212,6 +215,7 @@ async function shutdown(): Promise<void> {
212
215
  if (shuttingDown) return;
213
216
  shuttingDown = true;
214
217
  log.info('dispatcher shutting down');
218
+ stopOrgConnectorSync();
215
219
  const leaving = leaveCallsForShutdown();
216
220
  unwatchSession();
217
221
  tunnel?.stop();
@@ -0,0 +1,148 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, type KeyObject } from 'node:crypto';
2
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { parseId } from '@metro-labs/core/ids';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { log } from '@metro-labs/core/log';
7
+ import { ensureSecureDir } from '@metro-labs/core/secure-fs';
8
+ import { agentKeyId, rawPublicKey } from '@metro-labs/http/agent-signature';
9
+ import { ApiError } from '@metro-labs/http/api-error';
10
+ import { isOrganizationId } from '@metro-labs/http/workos-token';
11
+ import { agentsDir } from '../agents/files.js';
12
+
13
+ const FILE = 'agent-key.json';
14
+
15
+ export interface Enrollment {
16
+ server: string;
17
+ organization: string;
18
+ at: string;
19
+ }
20
+
21
+ export interface AgentKey {
22
+ keyId: string;
23
+ signingKey: string;
24
+ sealingKey: string;
25
+ signing: KeyObject;
26
+ sealing: KeyObject;
27
+ enrollment: Enrollment | null;
28
+ }
29
+
30
+ const pathOf = (dir: string): string => join(dir, FILE);
31
+
32
+ function privateKeyOf(raw: unknown, type: 'ed25519' | 'x25519'): KeyObject | null {
33
+ if (typeof raw !== 'string' || raw === '') return null;
34
+ try {
35
+ const key = createPrivateKey({ key: Buffer.from(raw, 'base64'), format: 'der', type: 'pkcs8' });
36
+ return key.asymmetricKeyType === type ? key : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function enrollmentOf(raw: unknown): Enrollment | null {
43
+ if (!isRecord(raw) || typeof raw.organization !== 'string' || typeof raw.at !== 'string') return null;
44
+ const server = parseId(raw.server);
45
+ return server !== null && isOrganizationId(raw.organization) ? { server, organization: raw.organization, at: raw.at } : null;
46
+ }
47
+
48
+ function agentKeyOf(signing: KeyObject, sealing: KeyObject, enrollment: Enrollment | null): AgentKey {
49
+ const signingKey = rawPublicKey(createPublicKey(signing));
50
+ return { keyId: agentKeyId(signingKey), signingKey, sealingKey: rawPublicKey(createPublicKey(sealing)), signing, sealing, enrollment };
51
+ }
52
+
53
+ const unreadable = (): null => {
54
+ log.warn('agent-key.json: Metro cannot read this agent key, so the agent must be enrolled again');
55
+ return null;
56
+ };
57
+
58
+ function storedOf(path: string): Record<string, unknown> | null {
59
+ let parsed: unknown;
60
+ try {
61
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
62
+ } catch {
63
+ return unreadable();
64
+ }
65
+ return isRecord(parsed) ? parsed : unreadable();
66
+ }
67
+
68
+ function keyOf(stored: Record<string, unknown> | null): AgentKey | null {
69
+ if (stored === null) return null;
70
+ const signing = privateKeyOf(stored.signing, 'ed25519');
71
+ const sealing = privateKeyOf(stored.sealing, 'x25519');
72
+ if (stored.version !== 1 || signing === null || sealing === null) return unreadable();
73
+ return agentKeyOf(signing, sealing, enrollmentOf(stored.enrollment));
74
+ }
75
+
76
+ export function readAgentKey(dir = agentsDir()): AgentKey | null {
77
+ const path = existsSync(pathOf(dir)) ? pathOf(dir) : join(dir, 'box-key.json');
78
+ return existsSync(path) ? keyOf(storedOf(path)) : null;
79
+ }
80
+
81
+ const pendingPath = (dir: string): string => join(dir, 'agent-key.pending.json');
82
+
83
+ function pendingFields(stored: Record<string, unknown> | null): { owner: string; ticketHash: string; expectedServer: string | null } {
84
+ if (typeof stored?.ticketHash !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(stored.ticketHash) || typeof stored.owner !== 'string' || !isOrganizationId(stored.owner)) {
85
+ throw new ApiError('Metro cannot read the pending enrollment key. Restore its file or permissions before enrolling again.', 503);
86
+ }
87
+ const expectedServer = stored.expectedServer === undefined ? null : parseId(stored.expectedServer);
88
+ if (stored.expectedServer !== undefined && expectedServer === null) throw new ApiError('Metro cannot read the pending enrollment identity. Restore its file before enrolling again.', 503);
89
+ return { owner: stored.owner, ticketHash: stored.ticketHash, expectedServer };
90
+ }
91
+
92
+ export function readPendingAgentKey(owner: string, dir: string): (AgentKey & { ticketHash: string; expectedServer: string | null }) | null {
93
+ if (!existsSync(pendingPath(dir))) return null;
94
+ const stored = storedOf(pendingPath(dir));
95
+ const fields = pendingFields(stored);
96
+ const key = keyOf(stored);
97
+ if (key === null) throw new ApiError('Metro cannot read the pending enrollment key. Restore its file or permissions before enrolling again.', 503);
98
+ return fields.owner === owner ? { ...key, ticketHash: fields.ticketHash, expectedServer: fields.expectedServer } : null;
99
+ }
100
+
101
+ function flush(path: string): void {
102
+ const file = openSync(path, 'r');
103
+ try {
104
+ fsyncSync(file);
105
+ } finally {
106
+ closeSync(file);
107
+ }
108
+ }
109
+
110
+ function writeKey(path: string, stored: Record<string, unknown>): void {
111
+ const dir = dirname(path);
112
+ ensureSecureDir(dir);
113
+ const temp = `${path}.${randomBytes(12).toString('hex')}.tmp`;
114
+ const file = openSync(temp, 'wx', 0o600);
115
+ try {
116
+ try {
117
+ writeFileSync(file, `${JSON.stringify(stored, null, 2)}\n`);
118
+ fsyncSync(file);
119
+ } finally {
120
+ closeSync(file);
121
+ }
122
+ renameSync(temp, path);
123
+ flush(dir);
124
+ } finally {
125
+ rmSync(temp, { force: true });
126
+ }
127
+ }
128
+
129
+ export function clearPendingAgentKey(dir: string): void {
130
+ rmSync(pendingPath(dir), { force: true });
131
+ flush(dir);
132
+ }
133
+
134
+ export function newAgentKey(): AgentKey {
135
+ return agentKeyOf(generateKeyPairSync('ed25519').privateKey, generateKeyPairSync('x25519').privateKey, null);
136
+ }
137
+
138
+ const exported = (key: KeyObject): string => key.export({ format: 'der', type: 'pkcs8' }).toString('base64');
139
+
140
+ const privateKeys = (key: AgentKey): Record<string, unknown> => ({ version: 1, signing: exported(key.signing), sealing: exported(key.sealing) });
141
+
142
+ export function savePendingAgentKey(key: AgentKey, owner: string, ticketHash: string, dir: string, expectedServer?: string): void {
143
+ writeKey(pendingPath(dir), { ...privateKeys(key), owner, ticketHash, ...(expectedServer === undefined ? {} : { expectedServer }) });
144
+ }
145
+
146
+ export function saveAgentKey(key: AgentKey, enrollment: Enrollment, dir = agentsDir()): void {
147
+ writeKey(pathOf(dir), { ...privateKeys(key), enrollment });
148
+ }
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import { healthOf } from './health.js';
4
4
  import type { RemoteTool } from './tools.js';
5
+ import type { ConnectorMigrationPreview } from './migration-preview.js';
5
6
  import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
6
7
  import {
7
8
  apiFailure,
@@ -9,6 +10,7 @@ import {
9
10
  bodyField,
10
11
  cors,
11
12
  readJsonBody,
13
+ requireAdmin,
12
14
  sendJson,
13
15
  stringOf,
14
16
  } from '@metro-labs/http/api-http';
@@ -20,7 +22,7 @@ import {
20
22
  startOAuth,
21
23
  type OAuthRouteDeps,
22
24
  } from './oauth-routes.js';
23
- import { ConnectorUnauthorized } from './verify.js';
25
+ import { ConnectorUnauthorized } from '@metro-labs/http/connector-url';
24
26
  import type {
25
27
  Connector,
26
28
  ConnectorCheck,
@@ -31,6 +33,7 @@ import type {
31
33
  const PREFIX = '/api/connectors';
32
34
 
33
35
  export interface ConnectorApiDeps extends OAuthRouteDeps {
36
+ migrationPreview?: () => ConnectorMigrationPreview;
34
37
  listConnectors: () => Promise<Connector[]>;
35
38
  createConnector: (input: ConnectorInput) => Promise<Connector>;
36
39
  verifyConnector: (id: string) => Promise<ConnectorCheck>;
@@ -43,6 +46,7 @@ export interface ConnectorApiDeps extends OAuthRouteDeps {
43
46
 
44
47
  type Routable =
45
48
  | { kind: 'collection' }
49
+ | { kind: 'migration-preview' }
46
50
  | { kind: 'callback' }
47
51
  | { kind: 'connector'; id: string }
48
52
  | { kind: 'verify'; id: string }
@@ -74,6 +78,7 @@ function target(path: string): Target {
74
78
  const head = segments[0];
75
79
  if (head === undefined) return { kind: 'collection' };
76
80
  if (head === 'callback' && segments.length === 1) return { kind: 'callback' };
81
+ if (head === 'migration-preview' && segments.length === 1) return { kind: 'migration-preview' };
77
82
  const id = parseId(head);
78
83
  return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
79
84
  }
@@ -217,7 +222,7 @@ async function handlePolicy(
217
222
  sendJson(req, res, 200, connectorPayload(row));
218
223
  }
219
224
 
220
- type ById = Exclude<Routable['kind'], 'collection' | 'callback'>;
225
+ type ById = Exclude<Routable['kind'], 'collection' | 'callback' | 'migration-preview'>;
221
226
 
222
227
  const BY_ID: Record<ById, (req: IncomingMessage, res: ServerResponse, deps: ConnectorApiDeps, id: string) => Promise<void>> = {
223
228
  verify: handleVerify,
@@ -239,6 +244,11 @@ async function route(
239
244
  ): Promise<void> {
240
245
  try {
241
246
  if (tgt.kind === 'callback') return;
247
+ if (tgt.kind === 'migration-preview') {
248
+ if (deps.migrationPreview === undefined) sendJson(req, res, 503, { error: 'Migration preview is unavailable.' });
249
+ else sendJson(req, res, 200, deps.migrationPreview());
250
+ return;
251
+ }
242
252
  if (tgt.kind !== 'collection') await BY_ID[tgt.kind](req, res, deps, tgt.id);
243
253
  else if (req.method === 'GET') await handleList(req, res, deps);
244
254
  else await handleCreate(req, res, deps);
@@ -249,6 +259,7 @@ async function route(
249
259
 
250
260
  const ALLOWED: Record<Routable['kind'], string[]> = {
251
261
  collection: ['GET', 'POST'],
262
+ 'migration-preview': ['GET'],
252
263
  callback: ['GET'],
253
264
  connector: ['GET', 'DELETE'],
254
265
  verify: ['POST'],
@@ -274,6 +285,7 @@ async function dispatch(
274
285
  sendJson(req, res, 401, { error: 'unauthorized' });
275
286
  return;
276
287
  }
288
+ if (tgt.kind === 'migration-preview') requireAdmin(session);
277
289
  await route(req, res, deps, tgt);
278
290
  }
279
291
 
@@ -1,24 +1,14 @@
1
- import { ApiError } from '@metro-labs/http/api-error';
2
1
  import { stringOf } from '@metro-labs/http/api-http';
3
2
  import type {
4
3
  ConnectorAuth,
5
4
  VerifiedRecord,
6
5
  VerifiedServer,
7
- } from './verify.js';
8
- import type { OAuthClient } from './oauth-client.js';
6
+ } from '@metro-labs/http/connector-mcp';
7
+ import type { OAuthClient } from '@metro-labs/http/connector-oauth';
9
8
  import { isRecord } from '@metro-labs/core/is-record';
10
9
  import type { ToolGroup } from '@metro-labs/core/stations/types';
11
10
  import { parsePolicy, type ToolPolicy } from '../policy/policy.js';
12
11
 
13
- const CONNECTOR_NAME_MAX = 64;
14
- const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
15
- const HEADER_VALUE_RE = /^[\x20-\x7e]{1,4096}$/;
16
- const CLIENT_ID_RE = /^[\x21-\x7e]{1,256}$/;
17
- const CLIENT_SECRET_RE = /^[\x21-\x7e]{1,1024}$/;
18
- const DEFAULT_HEADER = 'Authorization';
19
-
20
- export class ConnectorError extends ApiError {}
21
-
22
12
  export type ConnectorSignIn = 'connected' | 'disconnected' | null;
23
13
 
24
14
  export interface ConnectorConfig {
@@ -31,66 +21,6 @@ export interface ConnectorConfig {
31
21
  toolGroups?: Record<string, ToolGroup>;
32
22
  }
33
23
 
34
- function hasControlChar(value: string): boolean {
35
- for (const ch of value) {
36
- const code = ch.codePointAt(0) ?? 0;
37
- if (code < 0x20 || code === 0x7f) return true;
38
- }
39
- return false;
40
- }
41
-
42
- export function connectorName(raw: unknown): string {
43
- const name = typeof raw === 'string' ? raw.trim() : '';
44
- if (name === '') throw new ConnectorError('a connector needs a name', 400);
45
- if (name.length > CONNECTOR_NAME_MAX)
46
- throw new ConnectorError(
47
- `a connector name must be ${String(CONNECTOR_NAME_MAX)} characters or fewer`,
48
- 400,
49
- );
50
- if (hasControlChar(name))
51
- throw new ConnectorError(
52
- 'a connector name must not contain control characters',
53
- 400,
54
- );
55
- return name;
56
- }
57
-
58
- export function connectorAuth(
59
- rawHeader: unknown,
60
- rawValue: unknown,
61
- ): ConnectorAuth {
62
- const header = stringOf(rawHeader).trim();
63
- const value = stringOf(rawValue).trim();
64
- if (header === '' && value === '') return { kind: 'none' };
65
- if (value === '')
66
- throw new ConnectorError(
67
- 'that header has no value — give both a header name and its value, or neither',
68
- 400,
69
- );
70
- const name = header === '' ? DEFAULT_HEADER : header;
71
- if (!HEADER_NAME_RE.test(name))
72
- throw new ConnectorError('that is not a valid HTTP header name', 400);
73
- if (!HEADER_VALUE_RE.test(value))
74
- throw new ConnectorError('that header value is not sendable', 400);
75
- return { kind: 'header', name, value };
76
- }
77
-
78
- export function connectorClient(
79
- rawId: unknown,
80
- rawSecret: unknown,
81
- ): OAuthClient | null {
82
- const clientId = stringOf(rawId).trim();
83
- const clientSecret = stringOf(rawSecret).trim();
84
- if (clientId === '' && clientSecret === '') return null;
85
- if (clientId === '')
86
- throw new ConnectorError('a client secret needs the client ID it belongs to', 400);
87
- if (!CLIENT_ID_RE.test(clientId))
88
- throw new ConnectorError('that is not a client ID Metro can send', 400);
89
- if (clientSecret !== '' && !CLIENT_SECRET_RE.test(clientSecret))
90
- throw new ConnectorError('that client secret is not sendable', 400);
91
- return { clientId, ...(clientSecret === '' ? {} : { clientSecret }) };
92
- }
93
-
94
24
  function readClient(raw: unknown): OAuthClient | null {
95
25
  if (!isRecord(raw)) return null;
96
26
  const clientId = stringOf(raw.clientId);
@@ -0,0 +1,101 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { resolve } from 'node:path';
4
+ import { parseId } from '@metro-labs/core/ids';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { log } from '@metro-labs/core/log';
7
+ import { ApiError } from '@metro-labs/http/api-error';
8
+ import { readJsonBody, sessionRoute, type ApiSession } from '@metro-labs/http/api-http';
9
+ import { agentsDir } from '../agents/files.js';
10
+ import { clearPendingAgentKey, newAgentKey, readPendingAgentKey, saveAgentKey, savePendingAgentKey, type AgentKey } from './agent-key.js';
11
+ import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
12
+
13
+ const PATH = '/api/enrollment';
14
+ const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;
15
+ const enrolling = new Set<string>();
16
+
17
+ export interface EnrollApiDeps {
18
+ owner: () => string | null;
19
+ dir?: () => string;
20
+ api?: MetroApi;
21
+ invalidate?: () => void;
22
+ }
23
+
24
+ export interface Enrolled {
25
+ server: string;
26
+ organization: string;
27
+ keyId: string;
28
+ }
29
+
30
+ const differently = (): ApiError => new ApiError('api.metro.box enrolled this agent differently than Metro asked. Try enrolling again.', 503);
31
+
32
+ function enrolledOf(value: Record<string, unknown>, key: AgentKey, owner: string): Enrolled {
33
+ const server = parseId(value.server);
34
+ if (server === null || value.organization !== owner || value.keyId !== key.keyId) throw differently();
35
+ return { server, organization: owner, keyId: key.keyId };
36
+ }
37
+
38
+ function sameOwner(deps: EnrollApiDeps, owner: string): void {
39
+ if (deps.owner() !== owner) throw new ApiError('This agent changed organizations during enrollment. Enroll it again from its current organization.', 409);
40
+ }
41
+
42
+ async function recover(api: MetroApi, key: AgentKey, owner: string): Promise<Enrolled | null> {
43
+ try {
44
+ return enrolledOf(await agentCall(api, key, '/api/agents/session'), key, owner);
45
+ } catch (err) {
46
+ if (err instanceof MetroApiRefusal && err.upstreamStatus === 401 && err.code === 'agent_key_missing') return null;
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ function saveRecovered(pending: ReturnType<typeof readPendingAgentKey>, enrolled: Enrolled | null, ticketHash: string, api: MetroApi, dir: string): Enrolled | null {
52
+ if (pending === null || enrolled === null) return null;
53
+ if (pending.expectedServer !== null && pending.expectedServer !== enrolled.server) throw differently();
54
+ saveAgentKey(pending, { server: enrolled.server, organization: enrolled.organization, at: new Date(api.now()).toISOString() }, dir);
55
+ if (pending.ticketHash !== ticketHash) return null;
56
+ clearPendingAgentKey(dir);
57
+ return enrolled;
58
+ }
59
+
60
+ async function exchange(ticket: string, owner: string, dir: string, deps: EnrollApiDeps): Promise<Enrolled> {
61
+ const api = deps.api ?? METRO_API;
62
+ const pending = readPendingAgentKey(owner, dir);
63
+ const ticketHash = createHash('sha256').update(ticket).digest('base64url');
64
+ const recovered = pending === null ? null : await recover(api, pending, owner);
65
+ sameOwner(deps, owner);
66
+ const finished = saveRecovered(pending, recovered, ticketHash, api, dir);
67
+ if (finished !== null) return finished;
68
+ const key = pending?.ticketHash === ticketHash ? pending : newAgentKey();
69
+ if (key !== pending) savePendingAgentKey(key, owner, ticketHash, dir);
70
+ const enrolled = enrolledOf(await agentCall(api, key, '/api/agents/enroll', { ticket, organization: owner, signingKey: key.signingKey, sealingKey: key.sealingKey }), key, owner);
71
+ sameOwner(deps, owner);
72
+ savePendingAgentKey(key, owner, ticketHash, dir, enrolled.server);
73
+ const confirmed = enrolledOf(await agentCall(api, key, '/api/agents/session'), key, owner);
74
+ sameOwner(deps, owner);
75
+ if (confirmed.server !== enrolled.server) throw differently();
76
+ saveAgentKey(key, { server: enrolled.server, organization: owner, at: new Date(api.now()).toISOString() }, dir);
77
+ clearPendingAgentKey(dir);
78
+ log.info({ server: enrolled.server, organization: owner, keyId: key.keyId }, 'enrollment: this agent is enrolled with its organization on api.metro.box');
79
+ return enrolled;
80
+ }
81
+
82
+ async function enroll(req: IncomingMessage, session: ApiSession, deps: EnrollApiDeps): Promise<Enrolled> {
83
+ const body = await readJsonBody(req);
84
+ const ticket = isRecord(body) && typeof body.ticket === 'string' ? body.ticket : '';
85
+ if (!TICKET_RE.test(ticket)) throw new ApiError('enrolling needs the ticket api.metro.box gave for this agent', 400);
86
+ const owner = deps.owner();
87
+ if (owner === null || owner !== session.subject) throw new ApiError('this machine has no organization yet', 409);
88
+ const dir = resolve(deps.dir?.() ?? agentsDir());
89
+ if (enrolling.has(dir)) throw new ApiError('This agent is already enrolling. Wait for it to finish before trying again.', 409);
90
+ enrolling.add(dir);
91
+ try {
92
+ deps.invalidate?.();
93
+ return await exchange(ticket, owner, dir, deps);
94
+ } finally {
95
+ enrolling.delete(dir);
96
+ }
97
+ }
98
+
99
+ export function handleEnrollRequest(req: IncomingMessage, res: ServerResponse, deps: EnrollApiDeps): boolean {
100
+ return sessionRoute(req, res, { methods: { [PATH]: ['POST'] }, admin: true, label: 'enrollment' }, (session) => enroll(req, session, deps));
101
+ }
@@ -1,9 +1,11 @@
1
1
  import type { ToolGroup } from '@metro-labs/core/stations/types';
2
- import { policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
2
+ import { decide, policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
3
3
  import { serverKeysOf } from './plugin-sync.js';
4
- import { NEEDS_APPROVAL, settlePrompts, takeGrant } from '../approvals/pending.js';
4
+ import { NEEDS_APPROVAL, revokeToolPrompts, settlePrompts, takeGrant } from '../approvals/pending.js';
5
+ import { isOrgRelayId } from './relay-id.js';
6
+ import type { BlockedReason } from './relay-policy.js';
5
7
 
6
- interface GateRow {
8
+ export interface GateRow {
7
9
  id: string;
8
10
  name: string;
9
11
  config: { policy?: ToolPolicy; toolGroups?: Record<string, ToolGroup> };
@@ -21,8 +23,20 @@ let gates: ConnectorGate[] = [];
21
23
 
22
24
  const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
23
25
 
26
+ function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | undefined, keys: Map<string, string>): boolean {
27
+ if (next === undefined || gate.server !== keys.get(gate.id) || JSON.stringify([gate.name, gate.policy]) !== JSON.stringify([next.name, next.config.policy ?? {}])) return true;
28
+ const before = decide(gate.policy, { name: tool, group: toolGroupOf(gate, tool) });
29
+ const after = decide(next.config.policy, { name: tool, group: next.config.toolGroups?.[tool] === 'read' ? 'read' : 'write' });
30
+ return before !== after;
31
+ }
32
+
24
33
  export function registerConnectors(rows: readonly GateRow[]): void {
25
34
  const keys = serverKeysOf([...rows]);
35
+ revokeToolPrompts((tool) => {
36
+ const found = connectorToolOf(tool);
37
+ if (found === undefined || !isOrgRelayId(found.gate.id)) return false;
38
+ return approvalChanged(found.gate, found.tool, rows.find((row) => row.id === found.gate.id), keys);
39
+ });
26
40
  gates = rows.map((row) => ({
27
41
  id: row.id,
28
42
  name: row.name,
@@ -55,8 +69,30 @@ export function blockedReason(connectorId: string, tool: string, args: Record<st
55
69
  return NEEDS_APPROVAL(gate.name, tool);
56
70
  }
57
71
 
72
+ export function requestConnectorBlocker(): () => BlockedReason {
73
+ const permits = new Map<string, number>();
74
+ return () => {
75
+ const seen = new Map<string, number>();
76
+ return (id, tool, args) => {
77
+ const gate = gates.find((entry) => entry.id === id);
78
+ if (gate === undefined) return 'This organization connector is not available.';
79
+ const group = toolGroupOf(gate, tool);
80
+ const access = policyFor({ kind: 'connector', id }, { name: tool, group });
81
+ if (access !== 'ask') return blockedReason(id, tool, args);
82
+ const key = JSON.stringify([id, gate.server, gate.policy, group, tool, args]);
83
+ const occurrence = seen.get(key) ?? 0;
84
+ seen.set(key, occurrence + 1);
85
+ if (occurrence < (permits.get(key) ?? 0)) return null;
86
+ const reason = blockedReason(id, tool, args);
87
+ if (reason === null) permits.set(key, (permits.get(key) ?? 0) + 1);
88
+ return reason;
89
+ };
90
+ };
91
+ }
92
+
58
93
  function prefixesOf(gate: ConnectorGate): string[] {
59
- return [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`, `mcp__${normalized(`metro.box ${gate.name}`)}__`];
94
+ const prefixes = [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`];
95
+ return isOrgRelayId(gate.id) ? prefixes : [...prefixes, `mcp__${normalized(`metro.box ${gate.name}`)}__`];
60
96
  }
61
97
 
62
98
  export function connectorToolOf(toolName: string): { gate: ConnectorGate; tool: string } | undefined {