@stage-labs/metro 0.1.0-beta.286 → 0.1.0-beta.288
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/STAGE.md +11 -0
- package/runtime/marketplace/plugin/bin/policy.mjs +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +148 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +14 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -72
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +101 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +40 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/metro-api.ts +92 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +200 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +32 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +4 -7
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +61 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +111 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +246 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +33 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +10 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +25 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -20
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +16 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/frame-schema.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/package.json +10 -2
- package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +110 -0
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +6 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +115 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +54 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-discovery.ts → http/src/connector-discovery.ts} +31 -14
- package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +73 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/verify.ts → http/src/connector-mcp.ts} +16 -16
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-client.ts → http/src/connector-oauth.ts} +36 -13
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +53 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/url.ts → http/src/connector-url.ts} +1 -1
- package/runtime/node_modules/@metro-labs/http/src/ed25519.ts +56 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- /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": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.288",
|
|
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",
|
|
@@ -82,6 +82,17 @@ The separate `metadata.status` and `metadata.priority` fields are optional. Stat
|
|
|
82
82
|
]}}
|
|
83
83
|
}}}
|
|
84
84
|
```
|
|
85
|
+
- Live frames: add `"source": {"url": "https://…"}` to the frame to name the node that serves its widget, an https URL that answers with ChatKit widget JSON (Stage's node protocol: https://github.com/bonustrack/stage/blob/main/NODES.md). Stage then sends the frame's actions to that node, not to you. The node's reply replaces the frame in place, on that person's device only, and no message comes to you. Only an action with ChatKit's `"handler": "client"` still comes to you as a frame action, like every action of a frame without `source`. `frame.open` and `frame.back` stay in Stage. Here `24h` goes to the node, and `Ask` comes to you as `Frame action: ask (tapped "Ask")`:
|
|
86
|
+
```json
|
|
87
|
+
{"line": "…", "frame": {"source": {"url": "https://eth.example.com/"}, "widget": {"type": "Card", "children": [
|
|
88
|
+
{"type": "Title", "value": "ETH 2,410 USD"},
|
|
89
|
+
{"type": "Row", "children": [
|
|
90
|
+
{"type": "Button", "label": "24h", "onClickAction": {"type": "range", "payload": {"days": 1}}},
|
|
91
|
+
{"type": "Button", "label": "Ask", "color": "secondary", "onClickAction": {"type": "ask", "handler": "client"}}
|
|
92
|
+
]}
|
|
93
|
+
]}}}
|
|
94
|
+
```
|
|
95
|
+
Stage calls the node only on a tap or on Refresh in the frame's three-dot menu, and only once the person has accepted the chat. Until then, and again after the app restarts, it shows the widget as you sent it. Do not add a Refresh button to a node: every live frame and Dashboard widget has Refresh in its menu. Add to dashboard on the frame adds a live widget loaded from that URL, and its `"handler": "client"` actions still come to this chat.
|
|
85
96
|
|
|
86
97
|
**Wallet cards.** The payment and signature cards Stage's own Request payment and Request signature send. Sending one moves nothing and signs nothing: the person taps Pay or Sign and confirms in Stage. Send one only when you were asked to.
|
|
87
98
|
- Send: `send {"line": "…", "wallet": {"type": "<type>", "content": {…}}}`. With `text` or a `frame` as well, the card goes last, and the `message_id` you get back is the card's.
|
|
@@ -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}__
|
|
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);
|
|
@@ -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 {
|
|
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
|
-
|
|
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')
|
|
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();
|
|
@@ -159,8 +159,10 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
|
159
159
|
'08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
|
|
160
160
|
'f87e9494ea6151d140b742c1b88b4f435ae653559be641fddc2160cd24dd4e1b',
|
|
161
161
|
'cb4307b016855c1a09d48bc8b3924a3080bd78b205963df3d6f20401d0486df9',
|
|
162
|
+
'd25a8575b1c27ee725ff3a3ec6e1ba42aebd08ba545012c4807c072627c5dd4a',
|
|
162
163
|
'f2be51c5290d2e61b47f10c379fa45d051a59a6b3f06d99174c2a170074bc8f5',
|
|
163
164
|
'4c727aa110c9023371cfb364f3304ad9db22cdf2501208ef1e42e6ccc245b00f',
|
|
165
|
+
'f3901d8a4537441b1d7fe8e4073322a97b779472df6b4a8e0f2091ab5ab0ae09',
|
|
164
166
|
]);
|
|
165
167
|
const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
|
|
166
168
|
|
|
@@ -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 '
|
|
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 '
|
|
8
|
-
import type { OAuthClient } from '
|
|
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
|
-
|
|
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 {
|