@commonlyai/cli 0.1.55 → 0.1.57

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * pi extension: Commonly's tools for a pi seat, over MCP.
3
3
  *
4
- * Loaded by adapters/pi.js with `-e`. Reads COMMONLY_PI_MCP — a JSON list of
5
- * `{ name, command: [...], env: {...} }` for stdio servers and
4
+ * Loaded by adapters/pi.js with `-e`. Reads fd 3 — a pipe the adapter writes the
5
+ * JSON into and ends at spawn, never the environment (see takeServers) a JSON
6
+ * list of `{ name, command: [...], env: {...} }` for stdio servers and
6
7
  * `{ name, url, headers: {...} }` for Streamable HTTP ones — connects to each,
7
8
  * asks it for its tools, and registers every one with pi under its own name, so
8
9
  * a pi seat calls `commonly_post_message` exactly as a claude or codex seat
@@ -20,9 +21,16 @@ import { Type } from 'typebox';
20
21
  import { connectMcp, takeServers, toPiResult } from './pi-mcp-client.mjs';
21
22
 
22
23
  export default async function commonlyMcpBridge(pi) {
23
- // Read once and remove: the list carries the seat token, and pi's bash tool
24
- // inherits this process's env (see takeServers).
25
- const servers = takeServers(process.env);
24
+ // Read once and consume: the read drains the pipe, so the list (which carries
25
+ // the seat token) does not survive anywhere in this process that a child could
26
+ // reach not the environment, which is why it does not arrive that way.
27
+ const servers = takeServers();
28
+ // The adapter loads this extension only when it has a list to hand over, so an
29
+ // empty read means the channel itself failed. Say so: the symptom otherwise is
30
+ // a seat that silently has no commonly_* tools at all.
31
+ if (!servers.length) {
32
+ process.stderr.write('[commonly-pi-bridge] no server list on fd 3 — this seat has no commonly_* tools\n');
33
+ }
26
34
  const clients = [];
27
35
  for (const server of servers) {
28
36
  const client = connectMcp(server);
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { spawn } from 'node:child_process';
21
+ import { closeSync, readFileSync } from 'node:fs';
21
22
 
22
23
  /**
23
24
  * The grant broker's path. wren's ruling for the daemon-side half of TASK-063:
@@ -166,14 +167,10 @@ const parseMessages = (text, contentType) => {
166
167
  *
167
168
  * `headers` is where a declared `Authorization` arrives, already substituted
168
169
  * 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).
170
+ * HTTP header built from the JSON list the bridge reads off its own fd 3 (see
171
+ * takeServers) never on argv, and never in this process's environment, which a
172
+ * same-user child of this process can read back whole (`ps eww $PPID`,
173
+ * `/proc/$PPID/environ`) no matter what this process deletes from its own copy.
177
174
  */
178
175
  export const connectHttpMcp = ({ name, url, headers }, { fetchImpl = globalThis.fetch, timeoutMs = 60_000 } = {}) => {
179
176
  if (typeof fetchImpl !== 'function') throw new Error(`${name}: no fetch implementation for the HTTP MCP transport`);
@@ -278,23 +275,58 @@ export const toPiResult = (result) => {
278
275
  };
279
276
 
280
277
  /**
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`
284
- * tool spawns with `{ ...process.env }` (pi's getShellEnv), so leaving it in place
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.
278
+ * Read the server list off the inherited pipe on `fd` and CONSUME it.
287
279
  *
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.
280
+ * The list carries every server's substituted secrets a stdio server's env and
281
+ * an HTTP server's `Authorization` header, the seat's bearer token among them
282
+ * and pi's `bash` tool spawns with `{ ...process.env }` (pi's getShellEnv), so it
283
+ * has to arrive by a channel pi's children do not inherit and must not outlive
284
+ * the read. It arrives here as fd 3: a pipe the adapter writes the JSON into and
285
+ * ends at spawn (pi.js runPi). This reads it to EOF and closes the descriptor.
286
+ *
287
+ * WHY NOT THE ENVIRONMENT, which is what this replaced: deleting the variable
288
+ * scrubbed Node's copy only, while the kernel keeps the environment this process
289
+ * was STARTED with, so a same-user child still read the token back with
290
+ * `ps eww $PPID` on macOS and `/proc/$PPID/environ` on Linux. Unsetting it at
291
+ * spawn cannot help either, because this runs inside the pi process that holds
292
+ * it. WHY NOT a 0600 file the bridge unlinks on load: the mode protects nothing
293
+ * against a same-user reader, and that shape needs both the unlink and a close
294
+ * to leave no window, since `/proc/<pid>/fd` on Linux still reaches an unlinked
295
+ * inode. A pipe has neither a path nor a stored copy.
296
+ *
297
+ * THE READ IS WHAT REMOVES THE SECRET, not the close: a pipe is consumed, so a
298
+ * second reader gets nothing. Measured against pi 0.84.1 — after a read to EOF
299
+ * a second read of the same descriptor returned zero bytes, while a 5-byte
300
+ * partial read left the remainder readable. Closing afterwards is hygiene.
301
+ *
302
+ * Two further measurements this rests on, both against pi 0.84.1: pi does not
303
+ * close inherited descriptors before loading extensions, so fd 3 is still open
304
+ * here (this runs at extension load, before the first model turn — and before
305
+ * any bash tool can run); and pi's own spawns (`dist/core/tools/bash.js`,
306
+ * `dist/core/exec.js`) pass a THREE-element stdio list, so a shell tool child
307
+ * does not inherit fd 3 at all.
308
+ *
309
+ * An unreadable descriptor yields no servers rather than throwing: a seat whose
310
+ * bridge cannot read its list should run without Commonly tools, not fail to
311
+ * start. The bridge logs that empty result (pi-commonly-mcp.mjs).
312
+ *
313
+ * The descriptor is closed only after a successful read, and that is not
314
+ * tidiness: on macOS, `closeSync` on the descriptor Node opens for an `'ignore'`
315
+ * stdio entry aborts the process — measured 2026-09-19, Node 20 — with
316
+ * `Assertion failed: (errno == EINTR), function uv__io_poll, file kqueue.c`,
317
+ * where the read itself had already failed harmlessly with `ENXIO`. A close in
318
+ * `finally` therefore turns "no channel" into a SIGABRT in the seat.
293
319
  */
294
- export const takeServers = (env = process.env) => {
295
- const servers = readServers(env.COMMONLY_PI_MCP);
296
- delete env.COMMONLY_PI_MCP;
297
- return servers;
320
+ export const takeServers = (fd = 3) => {
321
+ let raw = null;
322
+ try {
323
+ raw = readFileSync(fd, 'utf8');
324
+ } catch {
325
+ // Nothing to close: the read failed, so this was not a descriptor we consumed.
326
+ return [];
327
+ }
328
+ try { closeSync(fd); } catch { /* already closed */ }
329
+ return readServers(raw);
298
330
  };
299
331
 
300
332
  /**
@@ -110,15 +110,13 @@ const originOf = (value) => {
110
110
  * is a Streamable HTTP server (`agentBinding.ts` grantBrokerServer), so before
111
111
  * this pi dropped the one entry that carries a grant to a seat, silently.
112
112
  * Filling the headers here reuses the same substitution as the stdio env, and
113
- * the result rides in COMMONLY_PI_MCPwhich 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).
113
+ * the result is written into pi's fd 3 at spawn a pipe the bridge reads to EOF
114
+ * and closes (see takeServers), never the child's environment and never argv. The
115
+ * environment is not usable for this: the kernel keeps the copy the process was
116
+ * started with, so a same-user child could read the token back with
117
+ * `ps eww $PPID` / `/proc/$PPID/environ` even after the bridge deleted its own
118
+ * copy which is what the previous channel did, and the row this closes
119
+ * (Vera, Connectors).
122
120
  *
123
121
  * WHICH shape an entry becomes is decided by `transport` — the same field, read
124
122
  * with the same default and the same exact comparison the daemon's
@@ -314,11 +312,24 @@ export const extractReply = (stdout) => {
314
312
  return { text, sawAssistant, errors };
315
313
  };
316
314
 
317
- const runPi = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
315
+ const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
318
316
  let stdout = '';
319
317
  let stderr = '';
320
318
  let timedOut = false;
321
- const proc = spawnImpl('pi', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
319
+ // fd 3 carries the MCP server list, so the 4th pipe exists exactly when there
320
+ // is a list to hand over. The bridge reads it at extension load (takeServers).
321
+ const withList = typeof payload === 'string';
322
+ const proc = spawnImpl('pi', args, { cwd, env, stdio: withList ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'] });
323
+ if (withList) {
324
+ const channel = proc.stdio && proc.stdio[3];
325
+ // A missing pipe is an invariant break, not a degraded mode: the bridge would
326
+ // read EBADF and the seat would silently have no commonly_* tools.
327
+ if (!channel) { reject(new Error('pi adapter: no fd 3 pipe to carry the MCP server list')); return; }
328
+ // The child may exit before draining; that surfaces on 'close', so a write
329
+ // error here must not become an unhandled error event.
330
+ channel.on('error', () => {});
331
+ channel.end(payload);
332
+ }
322
333
  const timer = setTimeout(() => { timedOut = true; proc.kill('SIGTERM'); }, timeoutMs);
323
334
  proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
324
335
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
@@ -378,7 +389,6 @@ export default {
378
389
  ...baseEnv,
379
390
  PI_CODING_AGENT_DIR: agentDir,
380
391
  PI_SKIP_VERSION_CHECK: '1',
381
- ...(servers.length ? { COMMONLY_PI_MCP: JSON.stringify(servers) } : {}),
382
392
  };
383
393
  const args = buildArgs({
384
394
  prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
@@ -389,6 +399,7 @@ export default {
389
399
  args,
390
400
  cwd: ctx.cwd,
391
401
  env: childEnv,
402
+ payload: servers.length ? JSON.stringify(servers) : undefined,
392
403
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
393
404
  spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
394
405
  });
@@ -1,6 +1,7 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { homedir } from 'node:os';
3
3
  import { isAbsolute, resolve as pathResolve } from 'node:path';
4
+ import { auditDeclaredMcp, installedStdioEntries } from './declared-mcp-guard.js';
4
5
 
5
6
  import { seatBaseline } from './default-environment.js';
6
7
 
@@ -148,7 +149,23 @@ export const createDaemonSupervisor = ({
148
149
  return Object.keys(fallback).length ? { value: fallback, declared: false } : null;
149
150
  };
150
151
 
151
- // Ensure ~/.commonly/tokens/<name>.json exists so `agent run` can boot.
152
+ // A declared environment runs on THIS machine as the operator. Refuse any
153
+ // declared stdio command that is not the shipped commonly MCP server or one
154
+ // the operator already installed here, and any http server that would be
155
+ // handed the seat token off the instance's origin. Refusal keeps the current
156
+ // seat (or skips the mint) and says which server was kept off the machine;
157
+ // it never adopts a partial environment.
158
+ const admitDeclared = (row, environment, existing) => {
159
+ const audit = auditDeclaredMcp(environment, {
160
+ instanceUrl: existing?.instanceUrl || record.instanceUrl,
161
+ allowedStdioEntries: installedStdioEntries(existing),
162
+ });
163
+ if (audit.ok) return true;
164
+ log(`[${row.agentName}] refusing the declared environment — it would not stay on this machine's terms:`);
165
+ for (const refusal of audit.refusals) log(`[${row.agentName}] ${refusal}`);
166
+ return false;
167
+ };
168
+
152
169
  // The mint refuses to clobber an existing token (409 token_exists); the
153
170
  // binding to THIS machine is the owner's explicit takeover choice (D3), so
154
171
  // that refusal is answered with rotate:true — loudly.
@@ -162,6 +179,7 @@ export const createDaemonSupervisor = ({
162
179
  // once at boot). A row with NO declared model leaves the record alone —
163
180
  // never strip an operator's hand-set environment.
164
181
  const wanted = environmentFor(row);
182
+ if (wanted?.declared && !admitDeclared(row, wanted.value, existing)) return false;
165
183
  const declaredAdapter = row.runtime && typeof row.runtime === 'object'
166
184
  && typeof row.runtime.adapter === 'string'
167
185
  ? row.runtime.adapter.trim().toLowerCase()
@@ -259,6 +277,8 @@ export const createDaemonSupervisor = ({
259
277
  return false;
260
278
  }
261
279
  }
280
+ const declaredAtMint = environmentFor(row);
281
+ if (declaredAtMint?.declared && !admitDeclared(row, declaredAtMint.value, null)) return false;
262
282
  const body = { agentName: row.agentName, instanceId: row.instanceId };
263
283
  let minted;
264
284
  try {
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Guard for a server-declared `environment.mcp` before the daemon adopts it.
3
+ *
4
+ * The daemon projects `AgentInstallation.config.environment` onto the OWNER's
5
+ * machine: every declared stdio server is spawned as the operator, and every
6
+ * declared http server is handed the seat token wherever the declaration puts
7
+ * the `${COMMONLY_AGENT_TOKEN}` placeholder. The registry PATCH that writes
8
+ * that declaration was pod-member gated (Vera, Connectors 69500, 2026-09-18),
9
+ * so a plain member could run a command on the owner's laptop or ship the
10
+ * token to a host they control. The server fix is owner/admin gating; this is
11
+ * the daemon's own layer, which must hold even if the server is wrong again.
12
+ *
13
+ * Two rules, both fail-closed:
14
+ * stdio — the ENTRY must be the shipped commonly MCP server — command
15
+ * `npx -y @commonlyai/mcp@<tag>`, env limited to the two canonical
16
+ * placeholders, no args/cwd — or equal, as a whole entry, to one
17
+ * the operator already installed by hand in the local token record.
18
+ * Command alone is not enough: the shipped command with
19
+ * `NODE_OPTIONS=--import=data:…` executes code, with
20
+ * `npm_config_registry` fetches the package from an attacker, and
21
+ * with a literal `COMMONLY_API_URL=https://attacker…` posts the
22
+ * token there (sprint-review, Sharpen 69526/69534).
23
+ * http — the url, with ONLY the two instance placeholders resolved and
24
+ * nothing else expanded, must parse to the instance's own origin
25
+ * (scheme + host + port). The grant broker declares
26
+ * `${COMMONLY_API_URL}/api/mcp/grants/…`, so it passes.
27
+ * The rule is origin-based, not placeholder-based, because the claude CLI
28
+ * expands `${VAR}` and `${VAR:-default}` in url and headers from its own
29
+ * environment: `?t=${COMMONLY_AGENT_TOKEN:-}` is not the literal placeholder
30
+ * and still becomes the token, and outside the public sandbox that
31
+ * environment is the operator's, so `?k=${GITHUB_TOKEN}` leaks too (Vera,
32
+ * Connectors 69519). So any `${` other than the three known placeholders,
33
+ * anywhere in an entry — url, headers, command, env — is refused outright.
34
+ */
35
+
36
+ const URL_PLACEHOLDERS = ['${COMMONLY_API_URL}', '${COMMONLY_INSTANCE_URL}'];
37
+ const KNOWN_PLACEHOLDERS = [...URL_PLACEHOLDERS, '${COMMONLY_AGENT_TOKEN}'];
38
+ const SHIPPED_PACKAGE = /^@commonlyai\/mcp(@[A-Za-z0-9._-]+)?$/;
39
+
40
+ export const isShippedCommonlyMcpCommand = (command) => (
41
+ Array.isArray(command)
42
+ && command.length === 3
43
+ && command[0] === 'npx'
44
+ && command[1] === '-y'
45
+ && typeof command[2] === 'string'
46
+ && SHIPPED_PACKAGE.test(command[2])
47
+ );
48
+
49
+ const CANONICAL_STDIO_ENV = {
50
+ COMMONLY_API_URL: '${COMMONLY_API_URL}',
51
+ COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}',
52
+ };
53
+
54
+ // The execution-relevant shape of a stdio entry: everything that decides what
55
+ // runs and with what. `name` and `transport` are identity, not execution.
56
+ const executionShape = (server) => JSON.stringify({
57
+ command: Array.isArray(server.command) ? server.command : null,
58
+ args: Array.isArray(server.args) && server.args.length ? server.args : null,
59
+ cwd: typeof server.cwd === 'string' ? server.cwd : null,
60
+ env: server.env && typeof server.env === 'object'
61
+ ? Object.fromEntries(Object.entries(server.env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
62
+ : null,
63
+ });
64
+
65
+ // The shipped server exactly: its command, only its own env keys at their
66
+ // canonical placeholder values, nothing else that changes what executes.
67
+ export const isShippedCommonlyMcpEntry = (server) => {
68
+ if (!server || typeof server !== 'object') return false;
69
+ if (!isShippedCommonlyMcpCommand(server.command)) return false;
70
+ if (Array.isArray(server.args) && server.args.length) return false;
71
+ if (server.cwd !== undefined) return false;
72
+ const env = server.env && typeof server.env === 'object' ? server.env : {};
73
+ return Object.entries(env).every(([key, value]) => CANONICAL_STDIO_ENV[key] === value);
74
+ };
75
+
76
+ // True when a string still contains `${` after the known placeholders are
77
+ // removed — a `${VAR}`, `${VAR:-default}` or any other expansion the CLI
78
+ // would resolve from an environment this declaration does not own.
79
+ const hasForeignExpansion = (value) => {
80
+ if (typeof value !== 'string') return false;
81
+ let rest = value;
82
+ for (const placeholder of KNOWN_PLACEHOLDERS) rest = rest.split(placeholder).join('');
83
+ return rest.includes('${');
84
+ };
85
+
86
+ const stringsOf = (server) => {
87
+ const out = [];
88
+ if (typeof server.url === 'string') out.push(server.url);
89
+ for (const bag of [server.headers, server.env]) {
90
+ if (bag && typeof bag === 'object') out.push(...Object.values(bag));
91
+ }
92
+ if (Array.isArray(server.command)) out.push(...server.command);
93
+ if (Array.isArray(server.args)) out.push(...server.args);
94
+ return out.filter((v) => typeof v === 'string');
95
+ };
96
+
97
+ const originOf = (url, instanceUrl) => {
98
+ let expanded = String(url);
99
+ for (const placeholder of URL_PLACEHOLDERS) expanded = expanded.split(placeholder).join(instanceUrl);
100
+ try {
101
+ return new URL(expanded).origin;
102
+ } catch {
103
+ return null;
104
+ }
105
+ };
106
+
107
+ /**
108
+ * @returns {{ ok: boolean, refusals: string[] }} — one refusal line per
109
+ * offending server, naming it, so the daemon log says exactly what was kept
110
+ * off the machine.
111
+ */
112
+ export const auditDeclaredMcp = (environment, { instanceUrl, allowedStdioEntries = [] } = {}) => {
113
+ const refusals = [];
114
+ const servers = environment && typeof environment === 'object' && Array.isArray(environment.mcp)
115
+ ? environment.mcp : [];
116
+ let instanceOrigin = null;
117
+ try {
118
+ instanceOrigin = new URL(instanceUrl).origin;
119
+ } catch {
120
+ instanceOrigin = null;
121
+ }
122
+
123
+ servers.forEach((server, index) => {
124
+ if (!server || typeof server !== 'object') {
125
+ refusals.push(`mcp[${index}] is not an object`);
126
+ return;
127
+ }
128
+ const name = typeof server.name === 'string' && server.name ? server.name : `mcp[${index}]`;
129
+ const transport = server.transport || 'stdio';
130
+ // One shape per entry. The adapters classified by which field was present,
131
+ // so `{transport:'http', url:<instance>, command:['sh','-c',…]}` passed an
132
+ // origin check here and ran as stdio there with the token substituted
133
+ // (#1764 fixes the adapters; the guard refuses the shape outright).
134
+ if (server.command !== undefined && server.url !== undefined) {
135
+ refusals.push(`'${name}': declares both a command and a url; one entry is one transport`);
136
+ return;
137
+ }
138
+ if (transport === 'stdio' && server.url !== undefined) {
139
+ refusals.push(`'${name}': stdio entry carries a url`);
140
+ return;
141
+ }
142
+ if ((transport === 'http' || transport === 'sse') && server.command !== undefined) {
143
+ refusals.push(`'${name}': ${transport} entry carries a command`);
144
+ return;
145
+ }
146
+ const foreign = stringsOf(server).find(hasForeignExpansion);
147
+ if (foreign !== undefined) {
148
+ refusals.push(`'${name}': ${JSON.stringify(foreign)} contains an expansion other than the instance placeholders; the CLI would resolve it from this machine's environment`);
149
+ return;
150
+ }
151
+ if (transport === 'stdio') {
152
+ if (isShippedCommonlyMcpEntry(server)) return;
153
+ const shape = executionShape(server);
154
+ if (allowedStdioEntries.some((allowed) => executionShape(allowed) === shape)) return;
155
+ const why = isShippedCommonlyMcpCommand(server.command)
156
+ ? `carries env/args/cwd beyond the shipped server's own (${Object.keys(server.env || {}).filter((k) => CANONICAL_STDIO_ENV[k] !== server.env[k]).join(', ') || 'args/cwd'})`
157
+ : `command ${JSON.stringify(server.command)} is not the shipped commonly MCP server`;
158
+ refusals.push(`'${name}': declared stdio entry ${why}, and no entry installed on this machine matches it as a whole`);
159
+ return;
160
+ }
161
+ if (transport === 'http' || transport === 'sse') {
162
+ const origin = originOf(server.url, instanceUrl);
163
+ if (origin && instanceOrigin && origin === instanceOrigin) return;
164
+ refusals.push(`'${name}': ${transport} server ${JSON.stringify(server.url)} resolves to origin ${origin || '(unparseable)'}, not this instance (${instanceOrigin || instanceUrl}); a declared http server may only be the instance itself`);
165
+ return;
166
+ }
167
+ refusals.push(`'${name}': unknown transport ${JSON.stringify(transport)}`);
168
+ });
169
+
170
+ return { ok: refusals.length === 0, refusals };
171
+ };
172
+
173
+ /** The stdio entries an operator has already placed in a local token record. */
174
+ export const installedStdioEntries = (record) => (
175
+ Array.isArray(record?.environment?.mcp)
176
+ ? record.environment.mcp
177
+ .filter((s) => s && typeof s === 'object' && (s.transport || 'stdio') === 'stdio' && Array.isArray(s.command))
178
+ : []
179
+ );