@commonlyai/cli 0.1.52 → 0.1.55
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
CHANGED
|
@@ -449,9 +449,12 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
449
449
|
...buildPublicClaudePolicyArgs(allowedPatterns),
|
|
450
450
|
];
|
|
451
451
|
const claudeBin = resolveClaudePath(claudeEnv);
|
|
452
|
+
// Only stdio servers have a `command`; an HTTP entry (the grant broker)
|
|
453
|
+
// carries a `url` instead, and `isAbsolute(undefined)` throws — every
|
|
454
|
+
// confined spawn of a seat with a broker grant died here (2026-09-18).
|
|
452
455
|
const mcpExecutables = (env.mcp || [])
|
|
453
456
|
.map((server) => server?.command?.[0])
|
|
454
|
-
.filter((command) => isAbsolute(command));
|
|
457
|
+
.filter((command) => typeof command === 'string' && isAbsolute(command));
|
|
455
458
|
const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], {
|
|
456
459
|
workspacePath: ctx.cwd,
|
|
457
460
|
workspaceAccess: sandboxMode === 'read-only' ? 'read' : 'write',
|
|
@@ -98,6 +98,11 @@ const buildPrompt = buildMemoryPreamble;
|
|
|
98
98
|
// Codex-specific constraints:
|
|
99
99
|
// - stdio/command servers only (no url transport here); url-only entries
|
|
100
100
|
// are skipped rather than half-wired.
|
|
101
|
+
// - Only entries that DECLARE stdio (or name no transport) are emitted, even
|
|
102
|
+
// when they also carry a `command`. `auditDeclaredMcp` classifies by
|
|
103
|
+
// `transport` and never judges the command of an entry that declared an
|
|
104
|
+
// http one, so emitting it here executes a command the guard did not
|
|
105
|
+
// approve — the case Vera measured on 2026-09-18 (Connectors 69774).
|
|
101
106
|
// - Token-bearing values ride through `env_vars`, never a `-c ...env=...`
|
|
102
107
|
// argv override. Command lines are visible to other same-user processes
|
|
103
108
|
// unless the OS sandbox blocks process inspection; keeping bearer tokens
|
|
@@ -126,7 +131,9 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
|
|
|
126
131
|
const flags = [];
|
|
127
132
|
const forwardedEnv = {};
|
|
128
133
|
for (const server of mcpServers || []) {
|
|
129
|
-
|
|
134
|
+
const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
|
|
135
|
+
if (!server?.name || transport !== 'stdio'
|
|
136
|
+
|| !Array.isArray(server.command) || !server.command.length) continue;
|
|
130
137
|
const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
|
|
131
138
|
flags.push('-c', `mcp_servers.${server.name}.command=${toml(command)}`);
|
|
132
139
|
// The user opted into every server present in the environment spec.
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi extension: Commonly's tools for a pi seat, over MCP
|
|
2
|
+
* pi extension: Commonly's tools for a pi seat, over MCP.
|
|
3
3
|
*
|
|
4
4
|
* Loaded by adapters/pi.js with `-e`. Reads COMMONLY_PI_MCP — a JSON list of
|
|
5
|
-
* `{ name, command: [...], env: {...} }`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `{ name, command: [...], env: {...} }` for stdio servers and
|
|
6
|
+
* `{ name, url, headers: {...} }` for Streamable HTTP ones — connects to each,
|
|
7
|
+
* asks it for its tools, and registers every one with pi under its own name, so
|
|
8
|
+
* a pi seat calls `commonly_post_message` exactly as a claude or codex seat
|
|
8
9
|
* does. Tool calls are forwarded as MCP `tools/call`; results come back as
|
|
9
10
|
* text. The client lives in pi-mcp-client.mjs (jest-tested); this file only
|
|
10
11
|
* binds it to pi's `registerTool`. `typebox` resolves through pi's extension
|
|
11
12
|
* loader, which aliases its bundled copy — it is not a CLI dependency.
|
|
13
|
+
*
|
|
14
|
+
* pi ships no MCP support of its own (its README: "No MCP. … build an
|
|
15
|
+
* extension that adds MCP support"), so this file is the whole transport story
|
|
16
|
+
* for a pi seat: a server that is not reachable from here is not reachable.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import { Type } from 'typebox';
|
|
@@ -1,15 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A minimal MCP
|
|
2
|
+
* A minimal MCP client for the pi bridge (pi-commonly-mcp.mjs), over both
|
|
3
|
+
* transports the environment spec admits: stdio (newline-delimited JSON-RPC)
|
|
4
|
+
* and Streamable HTTP.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* A client for four methods (initialize, tools/list, tools/call, the
|
|
7
|
+
* initialized notification) is smaller than a dependency, and keeping it in a
|
|
8
|
+
* file with no pi imports means the CLI's own jest can test it — `typebox`
|
|
9
|
+
* only resolves inside pi's extension loader, so the extension file stays thin
|
|
10
|
+
* and untested by jest.
|
|
11
|
+
*
|
|
12
|
+
* pi itself has no MCP support by design (its README: "No MCP. … build an
|
|
13
|
+
* extension that adds MCP support"); this bridge IS that extension, so an
|
|
14
|
+
* HTTP MCP server reaches a pi seat only through the client below. A
|
|
15
|
+
* `url`-only entry used to be dropped by the two filters here and in pi.js,
|
|
16
|
+
* which is how a granted pi seat ended up holding the grant broker — a
|
|
17
|
+
* Streamable HTTP server — and getting nothing, silently.
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
20
|
import { spawn } from 'node:child_process';
|
|
12
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The grant broker's path. wren's ruling for the daemon-side half of TASK-063:
|
|
24
|
+
* pi confines on no host — `pi.js assertNoSandboxDeclared` refuses a DECLARED
|
|
25
|
+
* sandbox and nothing ever derives one — so a grant broker must not reach a pi
|
|
26
|
+
* seat by ANY route: not the server's projection (the backend refuses it there
|
|
27
|
+
* too), and not an older backend that still projects it, which is this layer's
|
|
28
|
+
* job. Keyed on the PATH and not on the entry's `name`, because the name is
|
|
29
|
+
* whatever the declaration says while the path is the broker's. A url that does
|
|
30
|
+
* not parse is not this predicate's business — the origin rule in
|
|
31
|
+
* `pi.js resolveMcpServers` refuses those before they are carried.
|
|
32
|
+
*/
|
|
33
|
+
export const GRANT_BROKER_PATH = '/api/mcp/grants/';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The reason string BOTH halves of this refusal use, verbatim from the server's
|
|
37
|
+
* typed refusal (`backend/services/grantBrokerConfinement.ts`: code
|
|
38
|
+
* `grant_broker_unconfined`, reason `adapter_cannot_confine`). wren's ruling
|
|
39
|
+
* (69829): "Same reason string on both halves" — so a daemon log line and the
|
|
40
|
+
* server's `grantBrokerRefusal` field can be read side by side, and neither
|
|
41
|
+
* layer grows a vocabulary the other one does not have. Mirrored rather than
|
|
42
|
+
* imported: this package does not depend on the backend, so the literal is
|
|
43
|
+
* pinned by a test instead.
|
|
44
|
+
*/
|
|
45
|
+
export const GRANT_BROKER_REFUSAL = 'grant_broker_unconfined: adapter_cannot_confine';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is this url the instance's grant broker? Path-keyed, and the comparison is
|
|
49
|
+
* case-INSENSITIVE because the route it mirrors is: express matches paths
|
|
50
|
+
* case-insensitively unless `caseSensitive` is set, so the live API answers
|
|
51
|
+
* `/API/MCP/GRANTS/x` from the same handler as `/api/mcp/grants/x` (measured
|
|
52
|
+
* 2026-09-19: POST both → 401, POST `/api/nothing/x` → 404, i.e. the control is
|
|
53
|
+
* what distinguishes a matched route from a missing one). A case-sensitive
|
|
54
|
+
* predicate therefore refuses the spelled-canonically broker and hands pi the
|
|
55
|
+
* uppercased spelling of it — the real runtime token included. Vera measured
|
|
56
|
+
* exactly that against the running instance (69839).
|
|
57
|
+
*
|
|
58
|
+
* Case is the only spelling the router forgives: `%67rants`, `//`, `./` and
|
|
59
|
+
* `GRANTS%2F` all 404 on the live API, and a trailing slash is already inside
|
|
60
|
+
* the prefix.
|
|
61
|
+
*/
|
|
62
|
+
export const isGrantBrokerUrl = (url) => {
|
|
63
|
+
try {
|
|
64
|
+
return new URL(url).pathname.toLowerCase().startsWith(GRANT_BROKER_PATH);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
13
70
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
14
71
|
const MAX_TEXT = 50 * 1024;
|
|
15
72
|
|
|
@@ -17,8 +74,19 @@ const truncate = (text) => (text.length <= MAX_TEXT
|
|
|
17
74
|
? text
|
|
18
75
|
: `${text.slice(0, MAX_TEXT)}\n… [truncated ${text.length - MAX_TEXT} bytes]`);
|
|
19
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Connect to one declared server, stdio or Streamable HTTP. `resolveMcpServers`
|
|
79
|
+
* emits exactly one of `command`/`url` per entry, and `readServers` keeps that
|
|
80
|
+
* invariant, so the shape decides; `url` wins if a hand-built entry still
|
|
81
|
+
* carries both, because a server that advertises an endpoint is not a command
|
|
82
|
+
* to spawn.
|
|
83
|
+
*/
|
|
84
|
+
export const connectMcp = (server, opts = {}) => (typeof server?.url === 'string' && server.url
|
|
85
|
+
? connectHttpMcp(server, opts)
|
|
86
|
+
: connectStdioMcp(server, opts));
|
|
87
|
+
|
|
20
88
|
/** A minimal MCP stdio client: initialize, tools/list, tools/call. */
|
|
21
|
-
export const
|
|
89
|
+
export const connectStdioMcp = ({ name, command, env }, { spawnImpl = spawn, timeoutMs = 60_000 } = {}) => {
|
|
22
90
|
const [cmd, ...args] = command;
|
|
23
91
|
const proc = spawnImpl(cmd, args, { env: { ...process.env, ...(env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
24
92
|
const pending = new Map();
|
|
@@ -71,6 +139,137 @@ export const connectMcp = ({ name, command, env }, { spawnImpl = spawn, timeoutM
|
|
|
71
139
|
return { initialize, listTools, callTool, close, proc };
|
|
72
140
|
};
|
|
73
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Split an SSE body into its `data:` payloads. Streamable HTTP lets a server
|
|
144
|
+
* answer a POST either with one JSON object or with an event stream holding
|
|
145
|
+
* the JSON-RPC messages, so both shapes are parsed.
|
|
146
|
+
*/
|
|
147
|
+
const ssePayloads = (text) => text.split(/\r?\n\r?\n/)
|
|
148
|
+
.map((block) => block.split(/\r?\n/)
|
|
149
|
+
.filter((line) => line.startsWith('data:'))
|
|
150
|
+
.map((line) => line.slice(5).trimStart())
|
|
151
|
+
.join('\n'))
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
|
|
154
|
+
const parseMessages = (text, contentType) => {
|
|
155
|
+
const body = text.trim();
|
|
156
|
+
if (!body) return [];
|
|
157
|
+
if (contentType.includes('text/event-stream')) return ssePayloads(body);
|
|
158
|
+
// A server MAY answer a request with plain JSON regardless of the request's
|
|
159
|
+
// `accept`, so the body decides before the header does.
|
|
160
|
+
if (body.startsWith('{') || body.startsWith('[')) return [body];
|
|
161
|
+
return ssePayloads(body);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A minimal MCP Streamable HTTP client: initialize, tools/list, tools/call.
|
|
166
|
+
*
|
|
167
|
+
* `headers` is where a declared `Authorization` arrives, already substituted
|
|
168
|
+
* with the seat's runtime token by the adapter. The token therefore rides in an
|
|
169
|
+
* HTTP header built from the JSON list the bridge takes out of its own
|
|
170
|
+
* environment (see takeServers) and never on argv. NOT on argv is the whole of
|
|
171
|
+
* that guarantee: deleting the variable from the bridge's own process scrubs
|
|
172
|
+
* Node's copy, not the kernel's, so a same-user child of this process can still
|
|
173
|
+
* read the parent's environment (`ps eww $PPID`, `/proc/$PPID/environ`) and find
|
|
174
|
+
* both the token and the server list. Treat this as "not in argv" and not as a
|
|
175
|
+
* secrecy boundary; the fix is to hand the list over a 0600 file the bridge
|
|
176
|
+
* unlinks on load (row filed against the bridge's env channel, Vera, Connectors).
|
|
177
|
+
*/
|
|
178
|
+
export const connectHttpMcp = ({ name, url, headers }, { fetchImpl = globalThis.fetch, timeoutMs = 60_000 } = {}) => {
|
|
179
|
+
if (typeof fetchImpl !== 'function') throw new Error(`${name}: no fetch implementation for the HTTP MCP transport`);
|
|
180
|
+
const declared = headers || {};
|
|
181
|
+
let nextId = 1;
|
|
182
|
+
let sessionId = null;
|
|
183
|
+
let protocolVersion = PROTOCOL_VERSION;
|
|
184
|
+
let negotiated = false;
|
|
185
|
+
const requestHeaders = () => ({
|
|
186
|
+
'content-type': 'application/json',
|
|
187
|
+
// Both, per the spec: a server may answer with JSON or with an event stream.
|
|
188
|
+
accept: 'application/json, text/event-stream',
|
|
189
|
+
...declared,
|
|
190
|
+
...(sessionId ? { 'mcp-session-id': sessionId } : {}),
|
|
191
|
+
// The version header follows NEGOTIATION, not the session. Our own broker is
|
|
192
|
+
// stateless (`mcpGrants.ts` sets `sessionIdGenerator: undefined`), so it
|
|
193
|
+
// never mints a session id — gating this on one meant the only broker we
|
|
194
|
+
// have never received the version it negotiated (Vera, Connectors).
|
|
195
|
+
...(negotiated ? { 'mcp-protocol-version': protocolVersion } : {}),
|
|
196
|
+
});
|
|
197
|
+
const post = async (payload) => {
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
let timer;
|
|
200
|
+
// Race an explicit timer as well as aborting: a client whose fetch ignores
|
|
201
|
+
// the signal must not leave a seat waiting forever on a dead endpoint.
|
|
202
|
+
const expired = new Promise((_resolve, reject) => {
|
|
203
|
+
timer = setTimeout(() => {
|
|
204
|
+
controller.abort();
|
|
205
|
+
reject(new Error(`${name}: timed out after ${timeoutMs}ms`));
|
|
206
|
+
}, timeoutMs);
|
|
207
|
+
});
|
|
208
|
+
try {
|
|
209
|
+
const res = await Promise.race([fetchImpl(url, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: requestHeaders(),
|
|
212
|
+
body: JSON.stringify(payload),
|
|
213
|
+
signal: controller.signal,
|
|
214
|
+
// These requests carry the seat token in a header. A redirect is
|
|
215
|
+
// allowed to point at another origin, and whether undici strips an
|
|
216
|
+
// Authorization header when it follows one is not something a seat may
|
|
217
|
+
// rely on — so no redirect is followed at all.
|
|
218
|
+
redirect: 'error',
|
|
219
|
+
}), expired]);
|
|
220
|
+
const text = await res.text();
|
|
221
|
+
const header = res.headers?.get?.('mcp-session-id');
|
|
222
|
+
if (header && !sessionId) sessionId = header;
|
|
223
|
+
return { res, text, contentType: res.headers?.get?.('content-type') || '' };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error?.message?.startsWith(`${name}:`)) throw error;
|
|
226
|
+
throw new Error(`${name}: ${error?.name === 'AbortError' ? `timed out after ${timeoutMs}ms` : `${payload?.method || 'request'} failed: ${error?.message || error}`}`);
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const request = async (method, params) => {
|
|
232
|
+
const id = nextId++;
|
|
233
|
+
const { res, text, contentType } = await post({ jsonrpc: '2.0', id, method, params: params || {} });
|
|
234
|
+
if (!res.ok) throw new Error(`${name}: ${method} failed with HTTP ${res.status}${text ? `: ${truncate(text)}` : ''}`);
|
|
235
|
+
const messages = parseMessages(text, contentType)
|
|
236
|
+
.map((raw) => { try { return JSON.parse(raw); } catch { return null; } })
|
|
237
|
+
.filter(Boolean);
|
|
238
|
+
const answer = messages.find((m) => m.id === id);
|
|
239
|
+
if (!answer) throw new Error(`${name}: ${method} returned no JSON-RPC answer (HTTP ${res.status})`);
|
|
240
|
+
if (answer.error) throw new Error(`${name}: ${answer.error.message || JSON.stringify(answer.error)}`);
|
|
241
|
+
return answer.result;
|
|
242
|
+
};
|
|
243
|
+
const notify = async (method, params) => {
|
|
244
|
+
const { res, text } = await post({ jsonrpc: '2.0', method, params: params || {} });
|
|
245
|
+
// The spec answers a notification with 202 and no body; anything else is
|
|
246
|
+
// an error the seat should see rather than a silent drop.
|
|
247
|
+
if (!res.ok) throw new Error(`${name}: ${method} failed with HTTP ${res.status}${text ? `: ${truncate(text)}` : ''}`);
|
|
248
|
+
};
|
|
249
|
+
const initialize = async () => {
|
|
250
|
+
const result = await request('initialize', {
|
|
251
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
252
|
+
capabilities: {},
|
|
253
|
+
clientInfo: { name: 'commonly-pi-bridge', version: '1.0.0' },
|
|
254
|
+
});
|
|
255
|
+
// The server's answer is authoritative; echo what it negotiated from here on.
|
|
256
|
+
if (result && typeof result.protocolVersion === 'string') protocolVersion = result.protocolVersion;
|
|
257
|
+
negotiated = true;
|
|
258
|
+
await notify('notifications/initialized');
|
|
259
|
+
return result;
|
|
260
|
+
};
|
|
261
|
+
const listTools = async () => (await request('tools/list')).tools || [];
|
|
262
|
+
const callTool = (toolName, args) => request('tools/call', { name: toolName, arguments: args || {} });
|
|
263
|
+
const close = () => {
|
|
264
|
+
if (!sessionId) return;
|
|
265
|
+
try {
|
|
266
|
+
const pending = fetchImpl(url, { method: 'DELETE', headers: requestHeaders(), redirect: 'error' });
|
|
267
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {});
|
|
268
|
+
} catch { /* best effort: the session expires on its own */ }
|
|
269
|
+
};
|
|
270
|
+
return { initialize, listTools, callTool, close, sessionId: () => sessionId };
|
|
271
|
+
};
|
|
272
|
+
|
|
74
273
|
/** MCP `{ content, isError }` → pi tool result. Non-text parts are named, not dropped silently. */
|
|
75
274
|
export const toPiResult = (result) => {
|
|
76
275
|
const parts = (result?.content || []).map((c) => (c?.type === 'text' ? String(c.text ?? '') : `[${c?.type || 'content'} omitted]`));
|
|
@@ -79,11 +278,18 @@ export const toPiResult = (result) => {
|
|
|
79
278
|
};
|
|
80
279
|
|
|
81
280
|
/**
|
|
82
|
-
* Read the server list and REMOVE it from the environment. The list carries
|
|
83
|
-
* server's substituted
|
|
281
|
+
* Read the server list and REMOVE it from the environment. The list carries every
|
|
282
|
+
* server's substituted secrets — a stdio server's env and an HTTP server's
|
|
283
|
+
* `Authorization` header, the seat's bearer token among them — and pi's `bash`
|
|
84
284
|
* tool spawns with `{ ...process.env }` (pi's getShellEnv), so leaving it in place
|
|
85
|
-
* lets one `env` from the model print the token. The
|
|
86
|
-
*
|
|
285
|
+
* lets one `env` from the model print the token. The clients already hold what
|
|
286
|
+
* they need from spawn time; nothing else reads this variable.
|
|
287
|
+
*
|
|
288
|
+
* What this does NOT do is hide the value from a process that reads the parent's
|
|
289
|
+
* environment directly: `delete` removes the key from this process's own copy,
|
|
290
|
+
* while the kernel keeps the copy this process was started with, so
|
|
291
|
+
* `ps eww $PPID` on macOS and `/proc/$PPID/environ` on Linux still show it to a
|
|
292
|
+
* same-user child. This closes the accidental vector, not a determined one.
|
|
87
293
|
*/
|
|
88
294
|
export const takeServers = (env = process.env) => {
|
|
89
295
|
const servers = readServers(env.COMMONLY_PI_MCP);
|
|
@@ -91,8 +297,30 @@ export const takeServers = (env = process.env) => {
|
|
|
91
297
|
return servers;
|
|
92
298
|
};
|
|
93
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Entries with a non-empty `command` and no `url` are stdio; entries with a
|
|
302
|
+
* non-empty `url` and no `command` are Streamable HTTP. The two are mutually
|
|
303
|
+
* exclusive on purpose: `resolveMcpServers` emits exactly one field per entry
|
|
304
|
+
* (the transport decides which), so an entry carrying BOTH did not come from it
|
|
305
|
+
* and is dropped rather than spawned — the adapter is the layer that executes,
|
|
306
|
+
* and a shape it cannot classify is not one it should run. Both the adapter
|
|
307
|
+
* (pi.js resolveMcpServers) and this filter have to agree, or a server reaches
|
|
308
|
+
* the bridge as an unstartable entry.
|
|
309
|
+
*/
|
|
310
|
+
export const isStdioServer = (s) => Array.isArray(s?.command) && s.command.length > 0
|
|
311
|
+
&& !(typeof s?.url === 'string' && s.url.length > 0);
|
|
312
|
+
export const isHttpServer = (s) => typeof s?.url === 'string' && s.url.length > 0
|
|
313
|
+
&& !(Array.isArray(s?.command) && s.command.length > 0);
|
|
314
|
+
|
|
94
315
|
export const readServers = (raw) => {
|
|
95
316
|
if (!raw) return [];
|
|
96
|
-
try {
|
|
317
|
+
try {
|
|
318
|
+
// The broker is dropped here as well as in pi.js (same reason string, see
|
|
319
|
+
// GRANT_BROKER_REFUSAL): this is the last layer before a client is started,
|
|
320
|
+
// and the two filters have to agree or a server reaches the bridge as an
|
|
321
|
+
// entry the adapter would not have carried.
|
|
322
|
+
return JSON.parse(raw).filter((s) => s?.name
|
|
323
|
+
&& (isStdioServer(s) || (isHttpServer(s) && !isGrantBrokerUrl(s.url))));
|
|
324
|
+
} catch { return []; }
|
|
97
325
|
};
|
|
98
326
|
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -39,6 +39,7 @@ import { homedir } from 'os';
|
|
|
39
39
|
import { dirname, join } from 'path';
|
|
40
40
|
import { fileURLToPath } from 'url';
|
|
41
41
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
42
|
+
import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
|
|
42
43
|
|
|
43
44
|
const DEFAULT_TIMEOUT_MS = (() => {
|
|
44
45
|
const fallback = 15 * 60 * 1000;
|
|
@@ -79,14 +80,135 @@ const substitutePlaceholders = (value, ctx) => {
|
|
|
79
80
|
return value.replace(PLACEHOLDER_RE, (whole, key) => (SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole));
|
|
80
81
|
};
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The daemon's own predicate, applied verbatim: `auditDeclaredMcp` reads
|
|
85
|
+
* `server.transport || 'stdio'` and compares it as an exact string. It is
|
|
86
|
+
* deliberately not friendlier than the guard's — a normalized `'HTTP'` or a
|
|
87
|
+
* padded `' http '` is a shape the guard refuses as an unknown transport, and an
|
|
88
|
+
* adapter that accepted one would be running something the guard never judged
|
|
89
|
+
* (Vera, Connectors 69776). The schema admits `http`/`stdio`/`sse`
|
|
90
|
+
* (environment.js:236) and admits an ABSENT transport, which the guard reads as
|
|
91
|
+
* stdio; that is what this reads too.
|
|
92
|
+
*/
|
|
93
|
+
const transportOf = (server) => server.transport || 'stdio';
|
|
94
|
+
|
|
95
|
+
const originOf = (value) => {
|
|
96
|
+
try {
|
|
97
|
+
return new URL(value).origin;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Declared MCP servers from the environment spec, placeholders filled. Both
|
|
105
|
+
* transports the spec admits are carried through: stdio (`command` + `env`) and
|
|
106
|
+
* Streamable HTTP (`url` + `headers`). An entry with neither is skipped — there
|
|
107
|
+
* is nothing to start.
|
|
108
|
+
*
|
|
109
|
+
* The HTTP half matters beyond a user-declared remote server: the grant broker
|
|
110
|
+
* is a Streamable HTTP server (`agentBinding.ts` grantBrokerServer), so before
|
|
111
|
+
* this pi dropped the one entry that carries a grant to a seat, silently.
|
|
112
|
+
* Filling the headers here reuses the same substitution as the stdio env, and
|
|
113
|
+
* the result rides in COMMONLY_PI_MCP — which the bridge removes from its own
|
|
114
|
+
* process environment at load (see takeServers). That closes the direct vector
|
|
115
|
+
* (pi's `bash` spawns with `{ ...process.env }`, so a bare `env` used to print
|
|
116
|
+
* the token); it is not a secrecy boundary, because deleting the variable
|
|
117
|
+
* scrubs Node's copy and not the kernel's — a same-user child of the bridge can
|
|
118
|
+
* still read this process's environment via `ps eww` / `/proc/$PPID/environ`.
|
|
119
|
+
* The durable fix is to hand the list over a 0600 file the bridge unlinks on
|
|
120
|
+
* load, which is a row against this env channel, not against this PR (Vera,
|
|
121
|
+
* Connectors).
|
|
122
|
+
*
|
|
123
|
+
* WHICH shape an entry becomes is decided by `transport` — the same field, read
|
|
124
|
+
* with the same default and the same exact comparison the daemon's
|
|
125
|
+
* `auditDeclaredMcp` judges it by — and the field that transport does not select
|
|
126
|
+
* is dropped unsent. Classifying by which field is PRESENT instead is a bypass,
|
|
127
|
+
* not a shorthand: an entry declaring `transport: 'http'` with the instance's own
|
|
128
|
+
* url passes the guard (its http rule checks only the url origin, and
|
|
129
|
+
* `${COMMONLY_AGENT_TOKEN}` in the command is one of the known placeholders), and
|
|
130
|
+
* a presence-classifier then ran that command as stdio with the real token
|
|
131
|
+
* substituted. The guard's judgement and the adapter's disagreed, and the adapter
|
|
132
|
+
* is what executes (Vera, Connectors 69774).
|
|
133
|
+
*
|
|
134
|
+
* The http half also enforces the guard's own origin rule, so the HTTP half does
|
|
135
|
+
* not depend on a guard that may not be on the machine: `auditDeclaredMcp`
|
|
136
|
+
* admits a declared http server only when its url resolves to the INSTANCE's
|
|
137
|
+
* origin, because the seat token rides its headers. Everything else — an
|
|
138
|
+
* off-instance host, an unparseable url, an instance url we do not know — is
|
|
139
|
+
* refused here as well. A transport pi cannot speak (`sse`, which the schema and
|
|
140
|
+
* the guard both admit) is refused rather than reinterpreted as one it can.
|
|
141
|
+
*
|
|
142
|
+
* The grant broker is refused outright, by PATH rather than by entry name: it is
|
|
143
|
+
* the one http entry that carries authority rather than data, and pi confines on
|
|
144
|
+
* no host. wren's ruling for TASK-063 (`isGrantBrokerUrl` in pi-mcp-client.mjs),
|
|
145
|
+
* the daemon-side half of the same refusal the server makes at the projection —
|
|
146
|
+
* this is the half that holds on deploy skew, when the row names no adapter for
|
|
147
|
+
* the server to key on, and in any backend older than the refusal.
|
|
148
|
+
*
|
|
149
|
+
* The stdio half does NOT have that property and must not be read as if it did:
|
|
150
|
+
* a declared stdio command is executed with no allowlist check, here and in both
|
|
151
|
+
* sibling adapters, so it depends entirely on the guard — `auditDeclaredMcp`
|
|
152
|
+
* admits only the shipped commonly MCP server or a command already present in the
|
|
153
|
+
* local record, and that rule exists nowhere else (Vera, 69778; TASK-069).
|
|
154
|
+
*/
|
|
155
|
+
export const resolveMcpServers = (mcpServers, ctx = {}) => {
|
|
156
|
+
const carried = [];
|
|
157
|
+
for (const server of mcpServers || []) {
|
|
158
|
+
if (!server?.name || typeof server.name !== 'string') continue;
|
|
159
|
+
const transport = transportOf(server);
|
|
160
|
+
if (transport === 'http') {
|
|
161
|
+
if (typeof server.url !== 'string' || !server.url) continue;
|
|
162
|
+
const url = substitutePlaceholders(server.url, ctx);
|
|
163
|
+
const origin = originOf(url);
|
|
164
|
+
const instanceOrigin = originOf(ctx.instanceUrl);
|
|
165
|
+
if (!origin || !instanceOrigin || origin !== instanceOrigin) {
|
|
166
|
+
// eslint-disable-next-line no-console
|
|
167
|
+
console.warn(`[pi] declared MCP server '${server.name}' points at ${origin || '(unparseable)'}, not this instance (${instanceOrigin || 'unknown'}) — not starting it`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// The grant broker is the one http entry that carries AUTHORITY rather
|
|
171
|
+
// than data: a granted seat acts on external systems as the granter. A pi
|
|
172
|
+
// seat cannot be confined on any host (see assertNoSandboxDeclared), so
|
|
173
|
+
// the reach is refused here as well as at the server's projection — this
|
|
174
|
+
// layer is what holds when the backend predates that refusal, when the
|
|
175
|
+
// row names no adapter for the server to key on, or when a deploy leaves
|
|
176
|
+
// the two on different clocks.
|
|
177
|
+
if (isGrantBrokerUrl(url)) {
|
|
178
|
+
// eslint-disable-next-line no-console
|
|
179
|
+
console.warn(`[pi] ${GRANT_BROKER_REFUSAL} — refusing the grant broker '${server.name}': pi has no enforced sandbox, so this seat must not act with a granter's authority — move the seat to the claude or codex adapter, or remove the grant`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
carried.push({
|
|
183
|
+
name: server.name,
|
|
184
|
+
url,
|
|
185
|
+
headers: Object.fromEntries(Object.entries(server.headers || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (transport === 'stdio') {
|
|
190
|
+
if (!Array.isArray(server.command) || !server.command.length) {
|
|
191
|
+
// The url-only record that names no transport lands here: the guard reads
|
|
192
|
+
// an absent transport as stdio too, and refuses it for having no command,
|
|
193
|
+
// so the daemon never adopts it and this drop matches that judgement.
|
|
194
|
+
if (typeof server.url === 'string' && server.url) {
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.warn(`[pi] declared MCP server '${server.name}' names no transport, so it is judged stdio, and has no command — not starting it`);
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
carried.push({
|
|
201
|
+
name: server.name,
|
|
202
|
+
command: server.command.map((a) => substitutePlaceholders(a, ctx)),
|
|
203
|
+
env: Object.fromEntries(Object.entries(server.env || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// eslint-disable-next-line no-console
|
|
208
|
+
console.warn(`[pi] declared MCP server '${server.name}' asks for transport '${transport}', which this adapter cannot speak — not starting it`);
|
|
209
|
+
}
|
|
210
|
+
return carried;
|
|
211
|
+
};
|
|
90
212
|
|
|
91
213
|
/** The provider block for models.json: the env spec's `provider` over the LiteLLM default. */
|
|
92
214
|
export const resolveProvider = (environment = {}) => {
|