@stage-labs/metro 0.1.0-beta.304 → 0.1.0-beta.306
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/memory.js +2 -3
- package/dist/runtime-install.js +3 -11
- package/dist/voice.js +1 -1
- package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
- package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +1 -32
- package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
- package/package.json +4 -2
- package/runtime/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -17
- package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +38 -30
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +15 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
- package/runtime/node_modules/@metro-labs/http/package.json +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
- package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -17
- package/runtime/marketplace/plugin/.mcp.json +0 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -48
- package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
- package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -145
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
- /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
package/runtime/runtime.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: metro
|
|
3
|
-
description: How this box's agent works on metro. Acknowledge, delegate, relay, answer over chat, never block on the terminal. Loaded at every session start by
|
|
3
|
+
description: How this box's agent works on metro. Acknowledge, delegate, relay, answer over chat, never block on the terminal. Loaded at every session start by a metro hook; edit it here to change the rules on this box.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard
|
|
6
|
+
You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard metro installs denies every other tool on this thread, so this is the shape of the work, not a preference.
|
|
7
7
|
|
|
8
8
|
**Inbound.** A message arrives as a `<channel source="metro" …>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (👀, then 👍 or ✅ once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim. Exception: when the person asks you to close something ("mark as done"), skip the reaction and the acknowledgement: close it directly and confirm once when it is finished.
|
|
9
9
|
|
|
@@ -15,7 +15,12 @@ You are the main thread of a Claude Code session that is connected to chat throu
|
|
|
15
15
|
|
|
16
16
|
**Calls that need the owner's approval run in a worker.** The owner can make a metro tool on a channel wait for approval. On the main thread such a call is refused, because waiting for the answer would stop the whole session: hand that exact call to a background worker (`run_in_background: true`) and keep answering. The worker waits for the owner's answer on its own.
|
|
17
17
|
|
|
18
|
-
**Connectors
|
|
18
|
+
**Connectors.** Third-party tools the organization gave this agent (Notion, n8n, Linear, Gmail and the like) are reached only through two metro tools, in two steps:
|
|
19
|
+
|
|
20
|
+
1. `tool_search` finds a tool and returns its full definition: `select:notion.search` for an exact name, words (`notion create page`) to rank, or `+linear issue` to require `linear` in the name. Each name is `<connector>.<tool>`.
|
|
21
|
+
2. `tool_call` runs it, with the name exactly as found and arguments that match its schema. After `tool_search` with `select:notion.search` returned `notion.search`: `{"calls": [{"tool": "notion.search", "arguments": {"query": "roadmap"}}]}`.
|
|
22
|
+
|
|
23
|
+
Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. `tool_search` may run on this thread, but never call `tool_call` here: delegate it to a worker, as all other work. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
|
|
19
24
|
|
|
20
25
|
**Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
|
|
21
26
|
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "metro",
|
|
3
|
-
"description": "Metro — connector relay and chat bridge for agents",
|
|
4
|
-
"owner": { "name": "Bonustrack", "url": "https://metro.box" },
|
|
5
|
-
"plugins": [
|
|
6
|
-
{
|
|
7
|
-
"name": "metro",
|
|
8
|
-
"source": "./plugin",
|
|
9
|
-
"description": "Every connector on this box as its own MCP server through metro's local relay, the orchestrator guard, and the standing rules loaded at session start."
|
|
10
|
-
}
|
|
11
|
-
]
|
|
12
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.304",
|
|
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 (or one `mcp` server on mcp.metro.box), 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
|
-
"author": {
|
|
6
|
-
"name": "Bonustrack",
|
|
7
|
-
"url": "https://metro.box"
|
|
8
|
-
},
|
|
9
|
-
"repository": "https://github.com/bonustrack/metro",
|
|
10
|
-
"license": "MIT",
|
|
11
|
-
"keywords": [
|
|
12
|
-
"metro",
|
|
13
|
-
"mcp",
|
|
14
|
-
"connectors",
|
|
15
|
-
"relay"
|
|
16
|
-
]
|
|
17
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
|
|
7
|
-
const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
|
|
8
|
-
|
|
9
|
-
function readAgent() {
|
|
10
|
-
const path = join(agentsDir(), 'agent.json');
|
|
11
|
-
let parsed;
|
|
12
|
-
try {
|
|
13
|
-
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
14
|
-
} catch {
|
|
15
|
-
throw new Error(`no agent on this machine yet (${path})`);
|
|
16
|
-
}
|
|
17
|
-
if (typeof parsed?.key !== 'string') throw new Error(`no agent key in ${path}`);
|
|
18
|
-
return parsed;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function readMcpToken() {
|
|
22
|
-
try {
|
|
23
|
-
const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
|
|
24
|
-
return MCP_TOKEN.test(token) ? token : null;
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const command = process.argv[2];
|
|
31
|
-
|
|
32
|
-
if (command === 'mcp-headers') {
|
|
33
|
-
const token = readMcpToken();
|
|
34
|
-
if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
|
|
35
|
-
process.exit(0);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (command !== 'headers') {
|
|
39
|
-
process.stderr.write('usage: metro-plugin.mjs headers | mcp-headers\n');
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${readAgent().key}` })}\n`);
|
|
45
|
-
} catch (err) {
|
|
46
|
-
process.stderr.write(`metro: ${err.message}\n`);
|
|
47
|
-
process.stdout.write('{}\n');
|
|
48
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"hooks": {
|
|
3
|
-
"PreToolUse": [
|
|
4
|
-
{
|
|
5
|
-
"hooks": [
|
|
6
|
-
{
|
|
7
|
-
"type": "command",
|
|
8
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/guard.mjs\"",
|
|
9
|
-
"statusMessage": "Checking the orchestrator boundary"
|
|
10
|
-
}
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
],
|
|
14
|
-
"SessionStart": [
|
|
15
|
-
{
|
|
16
|
-
"hooks": [
|
|
17
|
-
{
|
|
18
|
-
"type": "command",
|
|
19
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/session-start.mjs\""
|
|
20
|
-
}
|
|
21
|
-
]
|
|
22
|
-
}
|
|
23
|
-
]
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
-
import { log } from '@metro-labs/core/log';
|
|
7
|
-
import { agentMarketplaceDir, agentUser, asAgent, claudeBin } from '../agent-user/user.js';
|
|
8
|
-
|
|
9
|
-
const MARKETPLACE = 'metro';
|
|
10
|
-
const PLUGIN = 'metro@metro';
|
|
11
|
-
const STEP_MS = 120_000;
|
|
12
|
-
const SAID_MAX = 300;
|
|
13
|
-
|
|
14
|
-
export interface Answer {
|
|
15
|
-
status: number;
|
|
16
|
-
stdout: string;
|
|
17
|
-
stderr: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type Run = (args: string[]) => Promise<Answer>;
|
|
21
|
-
|
|
22
|
-
export interface PluginInstallDeps {
|
|
23
|
-
run: Run | null;
|
|
24
|
-
marketplaceDir: string | null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type PluginOutcome = 'skipped' | 'unchanged' | 'installed' | 'updated' | 'failed';
|
|
28
|
-
|
|
29
|
-
function storeMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
30
|
-
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
31
|
-
if (store === '') return null;
|
|
32
|
-
const dir = join(store, 'marketplace');
|
|
33
|
-
return existsSync(join(dir, '.claude-plugin', 'marketplace.json')) ? dir : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function stagedMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
37
|
-
const store = storeMarketplaceDir(env);
|
|
38
|
-
const user = agentUser();
|
|
39
|
-
if (store === null || user === null) return store;
|
|
40
|
-
const copy = agentMarketplaceDir(user);
|
|
41
|
-
return existsSync(join(copy, '.claude-plugin', 'marketplace.json')) ? copy : null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function stagedPluginVersion(dir: string): string | null {
|
|
45
|
-
try {
|
|
46
|
-
const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
47
|
-
return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : null;
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const runner =
|
|
54
|
-
(bin: string): Run =>
|
|
55
|
-
(args) =>
|
|
56
|
-
new Promise((resolve) => {
|
|
57
|
-
execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
58
|
-
const status = err === null ? 0 : typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 1;
|
|
59
|
-
resolve({ status, stdout, stderr });
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
async function findClaude(candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()]): Promise<Run | null> {
|
|
64
|
-
for (const bin of candidates) {
|
|
65
|
-
const run = runner(bin);
|
|
66
|
-
if ((await run(['--version'])).status === 0) return run;
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function listOf(stdout: string): Record<string, unknown>[] {
|
|
72
|
-
try {
|
|
73
|
-
const parsed: unknown = JSON.parse(stdout);
|
|
74
|
-
return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
|
|
75
|
-
} catch {
|
|
76
|
-
return [];
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const realish = (path: string): string => {
|
|
81
|
-
try {
|
|
82
|
-
return realpathSync(path);
|
|
83
|
-
} catch {
|
|
84
|
-
return path;
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const samePath = (a: string | null, b: string): boolean => a !== null && realish(a) === realish(b);
|
|
89
|
-
|
|
90
|
-
async function step(run: Run, args: string[]): Promise<boolean> {
|
|
91
|
-
const answer = await run(args);
|
|
92
|
-
if (answer.status === 0) {
|
|
93
|
-
log.info({ args }, 'plugin: claude');
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'plugin: claude refused');
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function marketplacePath(run: Run): Promise<string | null | undefined> {
|
|
101
|
-
const mine = listOf((await run(['plugin', 'marketplace', 'list', '--json'])).stdout).find((m) => m.name === MARKETPLACE);
|
|
102
|
-
if (mine === undefined) return undefined;
|
|
103
|
-
const at = mine.installLocation ?? mine.path;
|
|
104
|
-
return typeof at === 'string' ? at : null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function ensureMarketplace(run: Run, dir: string): Promise<boolean> {
|
|
108
|
-
const at = await marketplacePath(run);
|
|
109
|
-
if (at !== undefined && samePath(at, dir)) return true;
|
|
110
|
-
if (at !== undefined) await step(run, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
|
|
111
|
-
return step(run, ['plugin', 'marketplace', 'add', dir]);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function installedVersion(run: Run): Promise<string | null> {
|
|
115
|
-
const mine = listOf((await run(['plugin', 'list', '--json'])).stdout).find((p) => p.id === PLUGIN);
|
|
116
|
-
return mine !== undefined && typeof mine.version === 'string' ? mine.version : null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function ensureInstalled(run: Run, wanted: string | null): Promise<PluginOutcome> {
|
|
120
|
-
const have = await installedVersion(run);
|
|
121
|
-
if (have === null) return (await step(run, ['plugin', 'install', PLUGIN, '-y', '--scope', 'user'])) ? 'installed' : 'failed';
|
|
122
|
-
if (wanted === null || have === wanted) return 'unchanged';
|
|
123
|
-
await step(run, ['plugin', 'marketplace', 'update', MARKETPLACE]);
|
|
124
|
-
return (await step(run, ['plugin', 'update', PLUGIN, '-y'])) ? 'updated' : 'failed';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function explainMissingMarketplace(env: NodeJS.ProcessEnv = process.env): void {
|
|
128
|
-
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
129
|
-
if (store === '') return;
|
|
130
|
-
log.warn(
|
|
131
|
-
{ store },
|
|
132
|
-
'plugin: the runtime store holds no plugin marketplace, so metro cannot install its Claude Code plugin; restart the metro service once so `metro serve` stages it again',
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export async function ensureMetroPlugin(deps?: Partial<PluginInstallDeps>): Promise<PluginOutcome> {
|
|
137
|
-
const dir = deps?.marketplaceDir === undefined ? stagedMarketplaceDir() : deps.marketplaceDir;
|
|
138
|
-
if (dir === null) {
|
|
139
|
-
if (deps?.marketplaceDir === undefined) explainMissingMarketplace();
|
|
140
|
-
return 'skipped';
|
|
141
|
-
}
|
|
142
|
-
const run = deps?.run === undefined ? await findClaude() : deps.run;
|
|
143
|
-
if (run === null) {
|
|
144
|
-
log.info('plugin: claude is not on this machine, so metro leaves its Claude Code plugin alone');
|
|
145
|
-
return 'skipped';
|
|
146
|
-
}
|
|
147
|
-
if (!(await ensureMarketplace(run, dir))) return 'failed';
|
|
148
|
-
return ensureInstalled(run, stagedPluginVersion(dir));
|
|
149
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
2
|
-
import { decide, policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
|
|
3
|
-
import { serverKeysOf } from './plugin-sync.js';
|
|
4
|
-
import { NEEDS_APPROVAL, revokeToolPrompts, settlePrompts, takeGrant } from '../approvals/pending.js';
|
|
5
|
-
import type { BlockedReason } from './relay-policy.js';
|
|
6
|
-
|
|
7
|
-
export interface GateRow {
|
|
8
|
-
id: string;
|
|
9
|
-
name: string;
|
|
10
|
-
config: { policy?: ToolPolicy; toolGroups?: Record<string, ToolGroup> };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ConnectorGate {
|
|
14
|
-
id: string;
|
|
15
|
-
name: string;
|
|
16
|
-
server: string;
|
|
17
|
-
policy: ToolPolicy;
|
|
18
|
-
tools: Record<string, ToolGroup>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
let gates: ConnectorGate[] = [];
|
|
22
|
-
|
|
23
|
-
const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
24
|
-
|
|
25
|
-
function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | undefined, keys: ReadonlyMap<string, string>): boolean {
|
|
26
|
-
if (next === undefined || gate.server !== keys.get(gate.id) || JSON.stringify([gate.name, gate.policy]) !== JSON.stringify([next.name, next.config.policy ?? {}])) return true;
|
|
27
|
-
const before = decide(gate.policy, { name: tool, group: toolGroupOf(gate, tool) });
|
|
28
|
-
const after = decide(next.config.policy, { name: tool, group: next.config.toolGroups?.[tool] === 'read' ? 'read' : 'write' });
|
|
29
|
-
return before !== after;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function registerConnectors(rows: readonly GateRow[], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
|
|
33
|
-
const next = rows.map((row) => ({
|
|
34
|
-
id: row.id,
|
|
35
|
-
name: row.name,
|
|
36
|
-
server: keys.get(row.id) ?? '',
|
|
37
|
-
policy: row.config.policy ?? {},
|
|
38
|
-
tools: row.config.toolGroups ?? {},
|
|
39
|
-
}));
|
|
40
|
-
revokeToolPrompts((tool) => {
|
|
41
|
-
const before = connectorToolOf(tool);
|
|
42
|
-
const after = connectorToolOf(tool, next);
|
|
43
|
-
if (before?.gate.id !== after?.gate.id) return true;
|
|
44
|
-
return before !== undefined && approvalChanged(before.gate, before.tool, rows.find((row) => row.id === before.gate.id), keys);
|
|
45
|
-
});
|
|
46
|
-
gates = next;
|
|
47
|
-
const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
|
|
48
|
-
row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
|
|
49
|
-
);
|
|
50
|
-
setPolicies('connector', entries);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export const connectorGates = (): readonly ConnectorGate[] => gates;
|
|
54
|
-
|
|
55
|
-
export const toolGroupOf = (gate: ConnectorGate, tool: string): ToolGroup => (gate.tools[tool] === 'read' ? 'read' : 'write');
|
|
56
|
-
|
|
57
|
-
const promptFor = (connectorId: string, tool: string) => (asked: string): boolean => {
|
|
58
|
-
const found = connectorToolOf(asked);
|
|
59
|
-
return found?.gate.id === connectorId && found.tool === tool;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
|
|
63
|
-
const gate = gates.find((g) => g.id === connectorId);
|
|
64
|
-
if (gate === undefined) return 'This connector is not available.';
|
|
65
|
-
settlePrompts(promptFor(connectorId, tool), args);
|
|
66
|
-
const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
|
|
67
|
-
if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
|
|
68
|
-
if (access === 'allow' || takeGrant(promptFor(connectorId, tool), args)) return null;
|
|
69
|
-
return NEEDS_APPROVAL(gate.name, tool);
|
|
70
|
-
}
|
|
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
|
-
|
|
93
|
-
function resolvedTool(gate: ConnectorGate, called: string): { gate: ConnectorGate; tool: string } {
|
|
94
|
-
const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
|
|
95
|
-
return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function connectorToolOf(toolName: string, entries: readonly ConnectorGate[] = gates): { gate: ConnectorGate; tool: string } | undefined {
|
|
99
|
-
if (toolName.startsWith('mcp__metro__')) return undefined;
|
|
100
|
-
for (const gate of entries)
|
|
101
|
-
for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
|
|
102
|
-
if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
|
|
103
|
-
return undefined;
|
|
104
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
-
import { agentsDir } from '../agents/files.js';
|
|
6
|
-
import { isOrgRelayId, orgRelayId } from './relay-id.js';
|
|
7
|
-
import { readServerKeys, writeServerKeys } from './server-keys.js';
|
|
8
|
-
|
|
9
|
-
interface Alias {
|
|
10
|
-
sourceId: string;
|
|
11
|
-
relayId: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function recordsOf(dir: string): unknown[] {
|
|
15
|
-
let raw: unknown;
|
|
16
|
-
try {
|
|
17
|
-
raw = JSON.parse(readFileSync(join(dir, 'connector-migrations.json'), 'utf8'));
|
|
18
|
-
} catch (err) {
|
|
19
|
-
if (isRecord(err) && err.code === 'ENOENT') return [];
|
|
20
|
-
log.warn({ err: errMsg(err) }, 'connectors: the old migration records could not be read; server keys left as they are');
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
23
|
-
if (isRecord(raw) && raw.version === 1 && Array.isArray(raw.records)) return raw.records;
|
|
24
|
-
log.warn('connectors: the old migration records are not valid; server keys left as they are');
|
|
25
|
-
return [];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function aliasOf(record: unknown): Alias[] {
|
|
29
|
-
if (!isRecord(record) || record.phase !== 'active' || typeof record.sourceId !== 'string' || typeof record.destinationId !== 'string') return [];
|
|
30
|
-
const relayId = orgRelayId(record.destinationId);
|
|
31
|
-
return isOrgRelayId(relayId) && record.sourceId !== relayId ? [{ sourceId: record.sourceId, relayId }] : [];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function adoptOrgServerKeys(dir = agentsDir()): number {
|
|
35
|
-
try {
|
|
36
|
-
const aliases = recordsOf(dir).flatMap(aliasOf);
|
|
37
|
-
if (aliases.length === 0) return 0;
|
|
38
|
-
const keys = readServerKeys(dir);
|
|
39
|
-
if (keys === undefined) return 0;
|
|
40
|
-
let moved = 0;
|
|
41
|
-
for (const { sourceId, relayId } of aliases) {
|
|
42
|
-
const key = keys.get(sourceId);
|
|
43
|
-
if (key === undefined || keys.has(relayId)) continue;
|
|
44
|
-
keys.delete(sourceId);
|
|
45
|
-
keys.set(relayId, key);
|
|
46
|
-
moved += 1;
|
|
47
|
-
}
|
|
48
|
-
if (moved > 0) {
|
|
49
|
-
writeServerKeys(keys, dir);
|
|
50
|
-
log.info({ moved }, 'connectors: server keys moved to organization connector ids, tool names unchanged');
|
|
51
|
-
}
|
|
52
|
-
return moved;
|
|
53
|
-
} catch (err) {
|
|
54
|
-
log.warn({ err: errMsg(err) }, 'connectors: the server keys could not be moved to organization connector ids');
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
-
import { screenCalls, type Screened } from './relay-policy.js';
|
|
4
|
-
import type { RelaySession } from './relay-session.js';
|
|
5
|
-
import type { RelayTarget } from './relay-target.js';
|
|
6
|
-
|
|
7
|
-
type Ready = Extract<RelayTarget, { kind: 'ok' }>;
|
|
8
|
-
type Forward = (target: Ready, body: Uint8Array<ArrayBuffer> | null, signal: AbortSignal) => Promise<Response>;
|
|
9
|
-
type OrgExchange =
|
|
10
|
-
| { kind: 'response'; upstream: Response; answers: Record<string, unknown>[] }
|
|
11
|
-
| { kind: 'blocked'; screened: Screened };
|
|
12
|
-
|
|
13
|
-
const METHODS = new Set(['initialize', 'ping', 'tools/list', 'tools/call', 'notifications/initialized', 'notifications/cancelled']);
|
|
14
|
-
|
|
15
|
-
function requestBatch(body: Uint8Array | null): boolean {
|
|
16
|
-
if (body === null) return false;
|
|
17
|
-
let parsed: unknown;
|
|
18
|
-
try { parsed = JSON.parse(new TextDecoder().decode(body)); }
|
|
19
|
-
catch { throw new ApiError('Invalid organization connector request.', 400); }
|
|
20
|
-
const batch = Array.isArray(parsed);
|
|
21
|
-
const entries: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
|
|
22
|
-
if (entries.length === 0 || entries.some((item) => !validMessage(item))) throw new ApiError('This organization connector accepts MCP tool requests only.', 400);
|
|
23
|
-
return batch;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function validMessage(item: unknown): boolean {
|
|
27
|
-
if (!isRecord(item) || item.jsonrpc !== '2.0' || typeof item.method !== 'string' || !METHODS.has(item.method)) return false;
|
|
28
|
-
if (item.method !== 'tools/call') return true;
|
|
29
|
-
if (!isRecord(item.params) || typeof item.params.name !== 'string' || item.params.name === '') return false;
|
|
30
|
-
return item.params.arguments === undefined || isRecord(item.params.arguments);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function ready(session: RelaySession, signal: AbortSignal): Ready {
|
|
34
|
-
if (signal.aborted) throw new ApiError('This organization connector is no longer available.', 503);
|
|
35
|
-
const target = session.current();
|
|
36
|
-
if (target.kind === 'missing') throw new ApiError('No such organization connector.', 404);
|
|
37
|
-
if (target.kind === 'signin') throw new ApiError('This organization connector needs signing in again.', 424);
|
|
38
|
-
return target;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function exchangeOrg(id: string, session: RelaySession, body: Uint8Array<ArrayBuffer> | null, clientSignal: AbortSignal, forward: Forward): Promise<OrgExchange> {
|
|
42
|
-
const signal = AbortSignal.any([clientSignal, session.signal]);
|
|
43
|
-
const batch = requestBatch(body);
|
|
44
|
-
let pending = body;
|
|
45
|
-
const answers: Record<string, unknown>[] = [];
|
|
46
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
47
|
-
await session.target(attempt > 0);
|
|
48
|
-
ready(session, signal);
|
|
49
|
-
const screened = pending === null ? null : screenCalls(pending, id, session.blocker());
|
|
50
|
-
if (screened !== null) {
|
|
51
|
-
answers.push(...screened.answers);
|
|
52
|
-
pending = screened.forward;
|
|
53
|
-
if (pending === null) return { kind: 'blocked', screened: { forward: null, answers, batch } };
|
|
54
|
-
}
|
|
55
|
-
const target = ready(session, signal);
|
|
56
|
-
const upstream = await forward(target, pending, signal);
|
|
57
|
-
if (upstream.status !== 401 && upstream.status !== 403) return { kind: 'response', upstream, answers };
|
|
58
|
-
await upstream.body?.cancel();
|
|
59
|
-
}
|
|
60
|
-
throw new ApiError('The organization connector rejected its credential. Check its sign-in in the organization settings.', 424);
|
|
61
|
-
}
|