@stage-labs/metro 0.1.0-beta.302 → 0.1.0-beta.304
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 +2 -2
- package/runtime/marketplace/plugin/METRO.md +2 -0
- package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +20 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -8
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
- package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +12 -37
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +27 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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.",
|
|
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
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
7
7
|
"url": "https://metro.box"
|
|
@@ -15,6 +15,8 @@ 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 through the `mcp` server.** When the `mcp` server is present, it holds every connector tool behind two tools. Find what you need with `find_tools`: `select:notion.search,linear.create_issue` for exact names, keywords (`notion page update`) to rank, or `+slack send` to require `slack` in the tool name. Have a worker run them with `call_tool`, up to 10 independent calls in one go. 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 call `call_tool` again with the same tool and arguments and `approval: "<id>"`.
|
|
19
|
+
|
|
18
20
|
**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.
|
|
19
21
|
|
|
20
22
|
The exception is a person describing a problem, asking a question or thinking out loud. There the answer IS the deliverable: reply, and change nothing until they ask.
|
|
@@ -23,6 +23,7 @@ const ORCHESTRATION = new Set([
|
|
|
23
23
|
const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
24
24
|
const MCP = /^mcp__/;
|
|
25
25
|
const METRO_MCP = /^mcp__metro__/;
|
|
26
|
+
const FIND_TOOLS = 'mcp__plugin_metro_mcp__find_tools';
|
|
26
27
|
const VOICE = process.env.METRO_SESSION === 'voice';
|
|
27
28
|
|
|
28
29
|
function answer(decision, reason) {
|
|
@@ -54,7 +55,7 @@ function verdict(payload, input) {
|
|
|
54
55
|
: 'A foreground subagent blinds the main thread: it cannot see or acknowledge inbound metro messages until the agent finishes. Re-issue the identical Agent call with run_in_background: true, then wait for the task notification.';
|
|
55
56
|
if (VOICE) return null;
|
|
56
57
|
if (ORCHESTRATION.has(tool)) return null;
|
|
57
|
-
if (METRO_MCP.test(tool)) return null;
|
|
58
|
+
if (METRO_MCP.test(tool) || tool === FIND_TOOLS) return null;
|
|
58
59
|
if (MCP.test(tool))
|
|
59
60
|
return `A connector call is work, not orchestration: ${tool} reads somebody's files, mail or records, and its answer can be long enough to crowd out the chat you are here to follow. Only metro's own tools run on this thread. Delegate it to a subagent with the Agent tool (run_in_background: true; the worker reaches every connector) and relay what it finds.`;
|
|
60
61
|
if (tool === 'Read' && typeof input.file_path === 'string' && IMAGE.test(input.file_path)) return null;
|
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
|
|
7
|
+
const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
|
|
7
8
|
|
|
8
9
|
function readAgent() {
|
|
9
10
|
const path = join(agentsDir(), 'agent.json');
|
|
@@ -17,8 +18,25 @@ function readAgent() {
|
|
|
17
18
|
return parsed;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
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');
|
|
22
40
|
process.exit(1);
|
|
23
41
|
}
|
|
24
42
|
|
|
@@ -3,6 +3,8 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
5
|
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { mcpServerUrl } from '../mcp-server/config.js';
|
|
7
|
+
import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
|
|
6
8
|
import { removeHome, writeHomeText } from './home-fs.js';
|
|
7
9
|
import { closeMetroHome } from './metro-home.js';
|
|
8
10
|
import { agentUser, agentViewDir, type AgentUser } from './user.js';
|
|
@@ -49,7 +51,8 @@ export function viewFiles(dir = agentsDir()): Map<string, string | null> {
|
|
|
49
51
|
['agent.json', agentFile(dir)],
|
|
50
52
|
['model.json', modelFile(dir)],
|
|
51
53
|
]);
|
|
52
|
-
|
|
54
|
+
const copied = mcpServerUrl() === null ? COPIED : [...COPIED, MCP_TOKEN_FILE];
|
|
55
|
+
for (const name of copied) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
|
|
53
56
|
return files;
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -5,7 +5,7 @@ import { apiFailure, apiSession, cors, readJsonBody, requireAdmin, sendJson } fr
|
|
|
5
5
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
7
|
import { parseId } from '@metro-labs/core/ids';
|
|
8
|
-
import type { LoadedAccount, LoadedAgent
|
|
8
|
+
import type { LoadedAccount, LoadedAgent } from '../stations/materialize.js';
|
|
9
9
|
import { STATIONS } from '@metro-labs/core/station-names';
|
|
10
10
|
import { approversOf } from './files.js';
|
|
11
11
|
|
|
@@ -17,7 +17,7 @@ const STATION_NAMES = new Set<string>(STATIONS);
|
|
|
17
17
|
export interface AgentBundle {
|
|
18
18
|
version: 1;
|
|
19
19
|
agent: { id: string; name: string; stations: LoadedAccount[] };
|
|
20
|
-
connectors:
|
|
20
|
+
connectors: [];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export const loadedAgentOf = (bundle: AgentBundle): LoadedAgent => ({
|
|
@@ -68,13 +68,6 @@ function stationOf(raw: unknown): LoadedAccount {
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function connectorOf(raw: unknown): LoadedConnector {
|
|
72
|
-
if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.url !== 'string')
|
|
73
|
-
throw bad('a connector is missing its id, name or url');
|
|
74
|
-
if (!isRecord(raw.config)) throw bad('a connector has no config');
|
|
75
|
-
return { id: raw.id, name: raw.name, url: raw.url, config: raw.config };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
71
|
function agentOf(raw: unknown): AgentBundle['agent'] {
|
|
79
72
|
if (!isRecord(raw)) throw bad('not a v1 agent bundle');
|
|
80
73
|
const { id, name, stations } = raw;
|
|
@@ -85,8 +78,7 @@ function agentOf(raw: unknown): AgentBundle['agent'] {
|
|
|
85
78
|
|
|
86
79
|
export function parseBundle(raw: unknown): AgentBundle {
|
|
87
80
|
if (!isRecord(raw) || raw.version !== 1) throw bad('not a v1 agent bundle');
|
|
88
|
-
|
|
89
|
-
return { version: 1, agent: agentOf(raw.agent), connectors: connectors.map(connectorOf) };
|
|
81
|
+
return { version: 1, agent: agentOf(raw.agent), connectors: [] };
|
|
90
82
|
}
|
|
91
83
|
|
|
92
84
|
async function answer(req: IncomingMessage, deps: BundleApiDeps, path: string): Promise<unknown> {
|
|
@@ -17,6 +17,7 @@ export interface PendingPrompt {
|
|
|
17
17
|
line: string | undefined;
|
|
18
18
|
at: number;
|
|
19
19
|
call?: CallSource;
|
|
20
|
+
origin?: 'mcp-server';
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface Held extends PendingPrompt {
|
|
@@ -93,6 +94,8 @@ export const pendingPrompts = (): PendingPrompt[] => [...held.values()].map(show
|
|
|
93
94
|
|
|
94
95
|
export const promptLine = (requestId: string): string | undefined => held.get(requestId)?.line;
|
|
95
96
|
|
|
97
|
+
export const isPromptHeld = (requestId: string): boolean => held.has(requestId);
|
|
98
|
+
|
|
96
99
|
function chatNotice(via: AnsweredVia, behavior: Behavior): string | undefined {
|
|
97
100
|
if (via === 'expiry') return 'Expired, denied.';
|
|
98
101
|
if (via === 'page') return behavior === 'allow' ? 'Approved on the page.' : 'Denied on the page.';
|
|
@@ -116,8 +119,12 @@ export async function answerPrompt(
|
|
|
116
119
|
const entry = held.get(requestId);
|
|
117
120
|
if (entry === undefined) return undefined;
|
|
118
121
|
held.delete(requestId);
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
const server = entry.origin === 'mcp-server';
|
|
123
|
+
if (behavior === 'allow' && via !== 'expiry' && !server) grant(entry);
|
|
124
|
+
log.info(
|
|
125
|
+
{ requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` },
|
|
126
|
+
server ? 'approvals: an mcp.metro.box approval answered' : 'approvals: a Claude Code permission prompt answered',
|
|
127
|
+
);
|
|
121
128
|
await entry.send(behavior);
|
|
122
129
|
await tellChat(entry, via, behavior);
|
|
123
130
|
return shown(entry);
|
|
@@ -152,7 +159,7 @@ export function revokeCallPrompts(route: CallRoute): void {
|
|
|
152
159
|
|
|
153
160
|
export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
|
|
154
161
|
for (const [id, entry] of held)
|
|
155
|
-
if (toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
162
|
+
if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
|
|
156
163
|
held.delete(id);
|
|
157
164
|
log.info({ requestId: id, tool: entry.tool }, 'approvals: the call reached metro, so Claude Code already closed the prompt');
|
|
158
165
|
}
|
|
@@ -21,10 +21,12 @@ import {
|
|
|
21
21
|
trainEventToMetroEvent,
|
|
22
22
|
} from '../routes/http.js';
|
|
23
23
|
import { agentsDir, fileSource } from '../agents/files.js';
|
|
24
|
-
import {
|
|
24
|
+
import { connectorsReady, publishConnectors } from '../connectors/publication.js';
|
|
25
|
+
import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
|
|
25
26
|
import { readAgentKey } from '../connectors/agent-key.js';
|
|
26
27
|
import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
|
|
27
|
-
import {
|
|
28
|
+
import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
|
|
29
|
+
import { holdServerApproval } from '../mcp/server-approvals.js';
|
|
28
30
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
29
31
|
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
30
32
|
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
@@ -140,10 +142,8 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
|
|
|
140
142
|
async function main(): Promise<void> {
|
|
141
143
|
applyLocalOwner();
|
|
142
144
|
installBearerSessions(agentsDir(), localOwner);
|
|
145
|
+
adoptOrgServerKeys();
|
|
143
146
|
watchPolicySnapshot();
|
|
144
|
-
loadConnectorPolicies().catch((err: unknown) => {
|
|
145
|
-
log.warn({ err: errMsg(err) }, 'connector policy: loading the policies failed');
|
|
146
|
-
});
|
|
147
147
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
148
148
|
await materializeFrom(fileSource);
|
|
149
149
|
ensureStationDeps();
|
|
@@ -160,10 +160,15 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
160
160
|
);
|
|
161
161
|
metroMcp.startInbound();
|
|
162
162
|
startPromptExpiry();
|
|
163
|
-
publishLocalConnectors(readLocalConnectors());
|
|
164
163
|
startUploadReaper();
|
|
165
164
|
startAttachReaper();
|
|
166
165
|
watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
|
|
166
|
+
watchMcpServer({
|
|
167
|
+
owner: localOwner,
|
|
168
|
+
hold: (approval, answer) => {
|
|
169
|
+
holdServerApproval(approval, answer, emit);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
167
172
|
startCloudWatchPublisher();
|
|
168
173
|
watchFallbackUsage();
|
|
169
174
|
announceLocalEndpoint();
|
|
@@ -195,7 +200,7 @@ async function startClaude(): Promise<void> {
|
|
|
195
200
|
ensureMetroPlugin()
|
|
196
201
|
.then((outcome) => {
|
|
197
202
|
log.info({ outcome }, 'plugin: metro plugin for Claude Code');
|
|
198
|
-
if (outcome !== 'skipped')
|
|
203
|
+
if (outcome !== 'skipped') publishConnectors();
|
|
199
204
|
})
|
|
200
205
|
.catch((err: unknown) => {
|
|
201
206
|
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
@@ -203,7 +208,7 @@ async function startClaude(): Promise<void> {
|
|
|
203
208
|
tryClaudeSetup();
|
|
204
209
|
try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
|
|
205
210
|
tryMemoryJob(memoryRoutine());
|
|
206
|
-
watchSession();
|
|
211
|
+
watchSession({ connectorsReady: () => connectorsReady() });
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
const SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
@@ -216,6 +221,9 @@ async function shutdown(): Promise<void> {
|
|
|
216
221
|
shuttingDown = true;
|
|
217
222
|
log.info('dispatcher shutting down');
|
|
218
223
|
stopOrgConnectorSync(true);
|
|
224
|
+
stopMcpServer().catch((err: unknown) => {
|
|
225
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
|
|
226
|
+
});
|
|
219
227
|
const leaving = leaveCallsForShutdown();
|
|
220
228
|
unwatchSession();
|
|
221
229
|
tunnel?.stop();
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SavedMedia,
|
|
8
8
|
} from './media-note.js';
|
|
9
9
|
import { buildWebhookNote } from './webhook-note.js';
|
|
10
|
+
import { isMetroNotice } from './notice.js';
|
|
10
11
|
import { replyMeta } from './addressed.js';
|
|
11
12
|
import { reactContent, reactionEmoji } from './react-note.js';
|
|
12
13
|
import {
|
|
@@ -285,7 +286,9 @@ export class InboundRelay {
|
|
|
285
286
|
capSet(this.allowedLines, ALLOWED_LINES_MAX);
|
|
286
287
|
}
|
|
287
288
|
|
|
288
|
-
private droppedSender(
|
|
289
|
+
private droppedSender(ev: Record<string, unknown>, from: string, line: string): boolean {
|
|
290
|
+
if (isMetroNotice(ev)) return false;
|
|
291
|
+
const verified = ev.senderVerified;
|
|
289
292
|
if (
|
|
290
293
|
from.startsWith('metro://claude') ||
|
|
291
294
|
from === 'metro://user' ||
|
|
@@ -311,7 +314,7 @@ export class InboundRelay {
|
|
|
311
314
|
if (!this.deps.getStations().has(station)) return null;
|
|
312
315
|
const from = str(ev.from);
|
|
313
316
|
const line = str(ev.line);
|
|
314
|
-
if (this.droppedSender(from, line
|
|
317
|
+
if (this.droppedSender(ev, from, line)) return null;
|
|
315
318
|
const text = str(ev.text);
|
|
316
319
|
if (!replay && this.isDuplicate(station, line, evType, str(ev.messageId))) {
|
|
317
320
|
this.deps.log(
|
|
@@ -337,7 +340,7 @@ export class InboundRelay {
|
|
|
337
340
|
if (await this.handlePermissionReply(ev, base)) return;
|
|
338
341
|
await this.notify('notifications/claude/channel', {
|
|
339
342
|
content:
|
|
340
|
-
base.evType === 'system'
|
|
343
|
+
base.evType === 'system' && !isMetroNotice(ev)
|
|
341
344
|
? buildWebhookNote(base.text, str(ev.lineName), ev.payload)
|
|
342
345
|
: base.text,
|
|
343
346
|
meta: {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mintId, type MetroEvent } from '@metro-labs/core/events';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { asLine, Line } from '@metro-labs/core/lines';
|
|
4
|
+
|
|
5
|
+
const NOTICE_FROM = 'metro://metro';
|
|
6
|
+
const NOTICE_SOURCE = 'metro';
|
|
7
|
+
|
|
8
|
+
export function metroNotice(line: string, text: string): MetroEvent {
|
|
9
|
+
return {
|
|
10
|
+
id: mintId(),
|
|
11
|
+
ts: new Date().toISOString(),
|
|
12
|
+
station: Line.station(line) ?? '',
|
|
13
|
+
line: asLine(line),
|
|
14
|
+
from: asLine(NOTICE_FROM),
|
|
15
|
+
to: asLine(line),
|
|
16
|
+
text,
|
|
17
|
+
event: { type: 'system', source: NOTICE_SOURCE },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const isMetroNotice = (ev: Record<string, unknown>): boolean =>
|
|
22
|
+
ev.from === NOTICE_FROM && isRecord(ev.event) && ev.event.type === 'system' && ev.event.source === NOTICE_SOURCE;
|
|
@@ -49,6 +49,7 @@ export interface SessionDeps {
|
|
|
49
49
|
now?: () => number;
|
|
50
50
|
version?: string;
|
|
51
51
|
prepare?: (runner: HarnessRunner) => Promise<void>;
|
|
52
|
+
connectorsReady?: () => boolean;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export interface SessionStatus {
|
|
@@ -316,9 +317,10 @@ function strike(now: number): boolean {
|
|
|
316
317
|
return true;
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused';
|
|
320
|
+
export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused' | 'waiting';
|
|
320
321
|
|
|
321
322
|
function holdsBack(deps: SessionDeps, now: number): Ensured | null {
|
|
323
|
+
if (deps.connectorsReady?.() === false) return 'waiting';
|
|
322
324
|
if (now < memory.pausedUntil) return 'paused';
|
|
323
325
|
if (sessionBlocked(deps) !== null) return 'blocked';
|
|
324
326
|
return strike(now) ? 'paused' : null;
|
|
@@ -151,6 +151,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
|
|
|
151
151
|
'e6b59eba825b4568dfa9ace5049fd9568a2e10d731719976ad2657084fd4e5f9',
|
|
152
152
|
'5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
|
|
153
153
|
'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
|
|
154
|
+
'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
|
|
154
155
|
]);
|
|
155
156
|
const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
156
157
|
'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
|
|
@@ -2,7 +2,6 @@ import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
|
2
2
|
import { decide, policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
|
|
3
3
|
import { serverKeysOf } from './plugin-sync.js';
|
|
4
4
|
import { NEEDS_APPROVAL, revokeToolPrompts, settlePrompts, takeGrant } from '../approvals/pending.js';
|
|
5
|
-
import { isOrgRelayId } from './relay-id.js';
|
|
6
5
|
import type { BlockedReason } from './relay-policy.js';
|
|
7
6
|
|
|
8
7
|
export interface GateRow {
|
|
@@ -101,14 +100,5 @@ export function connectorToolOf(toolName: string, entries: readonly ConnectorGat
|
|
|
101
100
|
for (const gate of entries)
|
|
102
101
|
for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
|
|
103
102
|
if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function legacyToolOf(toolName: string, entries: readonly ConnectorGate[]): { gate: ConnectorGate; tool: string } | undefined {
|
|
108
|
-
if (!toolName.startsWith('mcp__metro_box_')) return undefined;
|
|
109
|
-
const legacy = entries.filter((gate) => !isOrgRelayId(gate.id) && toolName.startsWith(`mcp__${normalized(`metro.box ${gate.name}`)}__`));
|
|
110
|
-
const gate = legacy.length === 1 ? legacy[0] : undefined;
|
|
111
|
-
if (gate === undefined) return undefined;
|
|
112
|
-
const called = toolName.slice(`mcp__${normalized(`metro.box ${gate.name}`)}__`.length);
|
|
113
|
-
return called === '' ? undefined : resolvedTool(gate, called);
|
|
103
|
+
return undefined;
|
|
114
104
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
}
|