@commonlyai/cli 0.1.61 → 0.1.64

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.61",
3
+ "version": "0.1.64",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -46,6 +46,7 @@ import {
46
46
  DEFAULT_HOOK_TIMEOUT_MS,
47
47
  clampHookTimeoutMs,
48
48
  forwardHookEvent,
49
+ resolveHookToken,
49
50
  writeHooksConfig,
50
51
  } from '../lib/hooks-config.js';
51
52
  import {
@@ -2863,7 +2864,14 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
2863
2864
  const record = loadAgentToken(name);
2864
2865
  const podId = opts.pod || record?.podId;
2865
2866
  const instanceUrl = record?.instanceUrl || process.env.COMMONLY_API_URL || resolveInstanceUrl(undefined);
2866
- const token = process.env.COMMONLY_AGENT_TOKEN;
2867
+ // Read the credential the way the runtime that spawned this hook carries
2868
+ // it: the launcher FILE first, the value variable second. Reading the bare
2869
+ // variable is what this did, and `resolveHookToken` therefore existed,
2870
+ // was tested, and never ran on a real hook — so on a seat whose MCP
2871
+ // declaration moved to the file, the token was absent, `forwardHookEvent`
2872
+ // returned `hook_unavailable`, and the hook silently stopped deciding
2873
+ // anything (the fail-open posture makes that a silence, not an error).
2874
+ const token = resolveHookToken({ env: process.env });
2867
2875
  const chunks = [];
2868
2876
  if (!process.stdin.isTTY) {
2869
2877
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -72,6 +72,8 @@ import {
72
72
  publicClaudeStateRoot,
73
73
  wrapArgvWithSeatbelt,
74
74
  } from '../sandbox/seatbelt.js';
75
+ import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
76
+ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
75
77
  import { buildMemoryPreamble } from '../memory-bridge.js';
76
78
 
77
79
  // See codex.js for the rationale on bumping the default + env override.
@@ -176,7 +178,7 @@ const preparePublicClaudeState = async (ctx) => {
176
178
  return { statePath, tmpPath, configPath };
177
179
  };
178
180
 
179
- const buildClaudeEnv = (input, state, expansionEnv = {}) => {
181
+ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null) => {
180
182
  const source = input || process.env;
181
183
  const output = state ? {} : { ...source };
182
184
  if (state) {
@@ -190,7 +192,16 @@ const buildClaudeEnv = (input, state, expansionEnv = {}) => {
190
192
  output.XDG_DATA_HOME = join(state.statePath, '.local', 'share');
191
193
  }
192
194
  Object.assign(output, expansionEnv);
193
- return output;
195
+ // `source` is usually the process environment, which is where the documented
196
+ // bootstrap export lives — so the value has to be taken out here rather than
197
+ // merely left out of the declaration rewrite. It survives only when THIS
198
+ // spawn's declaration still references it (claude substitutes args, url and
199
+ // headers literally, and `buildMcpExpansionEnv` reports that by carrying the
200
+ // key); the file path goes in either way so a hook child can name it.
201
+ return withholdRuntimeCredential(output, {
202
+ credentialFile,
203
+ keepsValue: expansionEnv[CREDENTIAL_KEY] !== undefined,
204
+ });
194
205
  };
195
206
 
196
207
  const absoluteToolDeny = (path) => `Read(/${path}/**)`;
@@ -270,19 +281,41 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
270
281
  // readable to any co-confined child allowed to read that config directory.
271
282
  //
272
283
  // Recognised placeholders:
273
- // ${COMMONLY_AGENT_TOKEN} — the per-(agent, pod) cm_agent_* runtime token
284
+ // ${COMMONLY_TOKEN_FILE} — the PATH of this spawn's credential file
274
285
  // ${COMMONLY_API_URL} — the instance URL the agent is attached to
275
286
  // ${COMMONLY_INSTANCE_URL} — alias for COMMONLY_API_URL (clearer in context)
276
287
  //
288
+ // The credential itself is deliberately NOT among these values (TASK-083). It
289
+ // used to be, and that put the bearer token in claude's environment, where every
290
+ // MCP child and every hook inherited it — measured on a live seat: the pi seat's
291
+ // MCP child carried COMMONLY_AGENT_TOKEN and COMMONLY_LITELLM_KEY. What the
292
+ // child gets instead is a path to a 0600 file that only this spawn can name,
293
+ // written into the SAME per-spawn directory claude is already handed for
294
+ // --mcp-config. That directory is not a coincidence: it is the one path outside
295
+ // the workspace that the Seatbelt profile admits (sandbox/seatbelt.js admits
296
+ // `subpath(mcpConfigDir)` and nothing under ~/.commonly but the seat's own
297
+ // statePath), so a credential file placed anywhere else is unreadable by the
298
+ // very child it is written for.
299
+ //
277
300
  // Only values actually referenced by this MCP declaration are added to the
278
301
  // child environment. Unknown placeholders remain in JSON so Claude's parser
279
302
  // fails clearly instead of receiving a silent empty string.
280
303
  const buildMcpExpansionEnv = (mcpConfig, ctx) => {
281
304
  const serialized = JSON.stringify(mcpConfig);
282
305
  const values = {
283
- COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
306
+ [CREDENTIAL_FILE_VAR]: ctx.credentialFile || '',
284
307
  COMMONLY_API_URL: ctx.instanceUrl || '',
285
308
  COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
309
+ // Whether THIS one is exposed is decided by the loop below, not here: it is
310
+ // added only when the value is non-empty AND the declaration still references
311
+ // it — and `serialized` is the config AFTER the credential rewrite, which is
312
+ // what moves the default declaration off this value and onto the file. A
313
+ // reference the rewrite cannot move (args, url, headers, or a string that
314
+ // merely contains the placeholder) keeps the value for that spawn, because
315
+ // claude substitutes those literally and there is no file channel for them.
316
+ // That is the measured carve-out, and createMcpConfig warns when it applies
317
+ // so the seat still carrying the value is named rather than assumed fixed.
318
+ COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
286
319
  };
287
320
  const output = {};
288
321
  for (const [key, value] of Object.entries(values)) {
@@ -291,7 +324,7 @@ const buildMcpExpansionEnv = (mcpConfig, ctx) => {
291
324
  return output;
292
325
  };
293
326
 
294
- const buildMcpConfig = (mcpServers) => {
327
+ const buildMcpConfig = (mcpServers, ctx = {}) => {
295
328
  // Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
296
329
  // config, which claude's `--mcp-config` reads directly.
297
330
  const mcpServersMap = {};
@@ -303,7 +336,15 @@ const buildMcpConfig = (mcpServers) => {
303
336
  entry.command = command;
304
337
  if (args.length) entry.args = args;
305
338
  }
306
- if (server.env) entry.env = { ...server.env };
339
+ // A declared credential is rewritten to the file channel here, in the
340
+ // config claude actually reads, so the value never has to exist in the
341
+ // runtime's environment at all (TASK-083).
342
+ if (server.env) {
343
+ entry.env = deliverSeatCredential(server, {
344
+ credentialFile: ctx.credentialFile,
345
+ label: 'claude',
346
+ }).env;
347
+ }
307
348
  if (server.headers) entry.headers = { ...server.headers };
308
349
  mcpServersMap[server.name] = entry;
309
350
  }
@@ -318,9 +359,15 @@ const buildMcpConfig = (mcpServers) => {
318
359
  const createMcpConfig = async (mcpServers, ctx = {}) => {
319
360
  const dir = await mkdtemp(join(tmpdir(), 'commonly-claude-mcp-'));
320
361
  const file = join(dir, 'mcp-config.json');
321
- const config = buildMcpConfig(mcpServers);
362
+ let credential = null;
322
363
  try {
323
364
  await chmod(dir, 0o700);
365
+ // Written BEFORE the config, because the config may only reference it. It
366
+ // lives inside `dir` so the spawn's existing finally removes it with the
367
+ // config — no second lifecycle to get wrong — and so it is inside the one
368
+ // non-workspace path the Seatbelt profile admits.
369
+ credential = writeCredentialFile(ctx.runtimeToken, { agentName: ctx.agentName || 'agent', root: dir });
370
+ const config = buildMcpConfig(mcpServers, { ...ctx, credentialFile: credential?.path || null });
324
371
  await writeFile(
325
372
  file,
326
373
  JSON.stringify(config, null, 2),
@@ -329,11 +376,20 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
329
376
  // writeFile's mode only applies when creating. Pin the final mode too so
330
377
  // this stays correct if the implementation ever starts reusing the path.
331
378
  await chmod(file, 0o600);
332
- return {
333
- dir,
334
- file,
335
- expansionEnv: buildMcpExpansionEnv(config, ctx),
336
- };
379
+ const expansionEnv = buildMcpExpansionEnv(config, {
380
+ ...ctx,
381
+ credentialFile: credential?.path || null,
382
+ });
383
+ if (expansionEnv[CREDENTIAL_KEY]) {
384
+ // eslint-disable-next-line no-console
385
+ console.warn(
386
+ `[claude] this declaration references ${CREDENTIAL_KEY} in a field claude `
387
+ + 'substitutes literally (args, url, headers, or a larger string), so the '
388
+ + "value stays in claude's environment for this spawn and every MCP child "
389
+ + `inherits it. Put the credential on the entry's env to get the file channel.`,
390
+ );
391
+ }
392
+ return { dir, file, credential, expansionEnv };
337
393
  } catch (err) {
338
394
  try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
339
395
  throw err;
@@ -610,6 +666,7 @@ export default {
610
666
  mcpConfig = await createMcpConfig(ctx.environment.mcp, {
611
667
  runtimeToken: ctx.runtimeToken,
612
668
  instanceUrl: ctx.instanceUrl,
669
+ agentName: ctx.agentName,
613
670
  });
614
671
  }
615
672
  const spawnCtx = {
@@ -621,6 +678,7 @@ export default {
621
678
  ctx.env,
622
679
  publicClaudeState,
623
680
  mcpConfig?.expansionEnv,
681
+ mcpConfig?.credential?.path || null,
624
682
  ),
625
683
  };
626
684
  const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx);
@@ -654,6 +712,7 @@ export default {
654
712
  ctx.env,
655
713
  publicClaudeState,
656
714
  mcpConfig?.expansionEnv,
715
+ mcpConfig?.credential?.path || null,
657
716
  ),
658
717
  });
659
718
  const stdout = await runClaude({
@@ -56,6 +56,8 @@ import {
56
56
  join,
57
57
  resolve as pathResolve,
58
58
  } from 'path';
59
+ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
60
+ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
59
61
  import { buildMemoryPreamble } from '../memory-bridge.js';
60
62
  import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
61
63
 
@@ -107,7 +109,7 @@ const buildPrompt = buildMemoryPreamble;
107
109
  // argv override. Command lines are visible to other same-user processes
108
110
  // unless the OS sandbox blocks process inspection; keeping bearer tokens
109
111
  // out of argv is an independent defense.
110
- const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
112
+ const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_TOKEN_FILE', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
111
113
  const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
112
114
 
113
115
  const substitutePlaceholders = (value, ctx) => {
@@ -115,6 +117,10 @@ const substitutePlaceholders = (value, ctx) => {
115
117
  if (!value.includes('${COMMONLY_')) return value;
116
118
  const subs = {
117
119
  COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
120
+ // The path, not the token: a declared server that can open a file reads the
121
+ // credential from there, and the PATH is not a secret, so it may ride in the
122
+ // argv override below (TASK-083).
123
+ COMMONLY_TOKEN_FILE: ctx.credentialFile || '',
118
124
  COMMONLY_API_URL: ctx.instanceUrl || '',
119
125
  COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
120
126
  };
@@ -131,10 +137,43 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
131
137
  const flags = [];
132
138
  const forwardedEnv = {};
133
139
  for (const server of mcpServers || []) {
140
+ // A declared credential is rewritten to the file channel before anything is
141
+ // substituted, so for our server the token is not token-bearing at all: it
142
+ // lands in `env={...}` as a path and never reaches `env_vars`, which is the
143
+ // one path by which the value ends up in codex's own environment and from
144
+ // there in every MCP child it spawns (TASK-083, measured on a live codex
145
+ // seat before the change).
146
+ const declaredEnv = deliverSeatCredential(server, {
147
+ credentialFile: ctx.credentialFile,
148
+ label: 'codex',
149
+ }).env;
134
150
  const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
135
151
  if (!server?.name || transport !== 'stdio'
136
152
  || !Array.isArray(server.command) || !server.command.length) continue;
137
153
  const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
154
+ // The doc block above promises bearer tokens never ride in argv, and this is
155
+ // the place that had to hold it: an env value that carries the token is
156
+ // diverted to env_vars, but a COMMAND ARGUMENT has no such route — codex
157
+ // substitutes it literally, and a command line is readable by every
158
+ // same-user process. So an entry that needs the token in its argv is
159
+ // refused whole (skipped, with the reason) rather than half-wired: emitting
160
+ // its other flags would leave a server the guard approved and the seat
161
+ // cannot use, and emitting this one would publish the secret. Measured
162
+ // before writing this: substitution did put `cm_agent_*` into
163
+ // `mcp_servers.<name>.args` on the -c command line (TASK-083).
164
+ const carriesTokenInArgv = (value) => !!ctx.runtimeToken
165
+ && typeof value === 'string'
166
+ && value.includes(ctx.runtimeToken);
167
+ if (carriesTokenInArgv(command) || rest.some(carriesTokenInArgv)) {
168
+ // eslint-disable-next-line no-console
169
+ console.warn(
170
+ `[codex] MCP server ${server.name} wants the seat credential as a command `
171
+ + 'argument, where codex substitutes a literal and every same-user process '
172
+ + 'can read it. Skipping that entry: declare the credential on the entry\'s '
173
+ + 'env, where it rides as a file path instead of a value.',
174
+ );
175
+ continue;
176
+ }
138
177
  flags.push('-c', `mcp_servers.${server.name}.command=${toml(command)}`);
139
178
  // The user opted into every server present in the environment spec.
140
179
  // Public permission profiles + approval_policy=never otherwise auto-deny
@@ -151,7 +190,7 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
151
190
  }
152
191
  const envEntries = [];
153
192
  const envVars = [];
154
- for (const [key, rawValue] of Object.entries(server.env || {})) {
193
+ for (const [key, rawValue] of Object.entries(declaredEnv)) {
155
194
  const value = substitutePlaceholders(rawValue, ctx);
156
195
  const carriesRuntimeToken = !!ctx.runtimeToken
157
196
  && typeof value === 'string'
@@ -175,6 +214,19 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
175
214
  flags.push('-c', `mcp_servers.${server.name}.env_vars=[${envVars.map(toml).join(',')}]`);
176
215
  }
177
216
  }
217
+ if (Object.keys(forwardedEnv).length > 0) {
218
+ // The value is forwarded only for a declaration that needs the token as a
219
+ // LITERAL somewhere a file path is not a value — command args, or a string
220
+ // that merely contains it. That is the measured carve-out (TASK-082), and it
221
+ // is not silent: this is the seat whose environment still carries a secret.
222
+ // eslint-disable-next-line no-console
223
+ console.warn(
224
+ `[codex] this declaration needs ${CREDENTIAL_KEY} as a literal (command args, or a `
225
+ + 'value that contains it), so the token is forwarded to codex for this spawn '
226
+ + "and every MCP child inherits it. Put the credential on the entry's env to "
227
+ + 'get the file channel.',
228
+ );
229
+ }
178
230
  return { flags, forwardedEnv };
179
231
  };
180
232
 
@@ -441,11 +493,20 @@ export default {
441
493
  // so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
442
494
  const dir = await mkdtemp(join(tmpdir(), 'commonly-codex-'));
443
495
  const outputFile = join(dir, 'last-message.txt');
496
+ // Written into the per-spawn directory this adapter already creates and
497
+ // removes: codex reads and writes inside it for --output-last-message, so a
498
+ // child it spawns can reach a file there, which is the one property this
499
+ // file has to have. Outside it the path is an unreadable promise.
500
+ const credential = writeCredentialFile(ctx.runtimeToken, {
501
+ agentName: ctx.agentName || 'agent',
502
+ root: dir,
503
+ });
444
504
 
445
505
  try {
446
506
  const mcp = buildMcpOverrideArgs(ctx.environment?.mcp, {
447
507
  runtimeToken: ctx.runtimeToken,
448
508
  instanceUrl: ctx.instanceUrl,
509
+ credentialFile: credential?.path || null,
449
510
  });
450
511
  // A derived record stores `trust: 'public'` and no mode (the block is
451
512
  // platform-independent; see cli/src/lib/default-environment.js). Codex's
@@ -475,6 +536,15 @@ export default {
475
536
  effort: ctx.environment?.effort,
476
537
  });
477
538
  const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
539
+ // Derived from the process environment, so the bootstrap export has to be
540
+ // taken back out: the declaration above moved our server onto the file,
541
+ // and a value left here is a value codex's own children inherit. Kept only
542
+ // when the substitution genuinely needed it (`forwardedEnv` names that
543
+ // carve-out, and the warning above it is emitted for the same spawn).
544
+ withholdRuntimeCredential(childEnv, {
545
+ credentialFile: credential?.path || null,
546
+ keepsValue: mcp.forwardedEnv[CREDENTIAL_KEY] !== undefined,
547
+ });
478
548
  if (publicSandboxMode !== null) {
479
549
  childEnv.CODEX_HOME = await preparePublicCodexHome(ctx);
480
550
  }
@@ -18,10 +18,12 @@
18
18
  */
19
19
 
20
20
  import { spawn } from 'node:child_process';
21
+ import { closeSync, readFileSync } from 'node:fs';
21
22
  import {
22
- closeSync, readFileSync, existsSync,
23
- } from 'node:fs';
24
- import { dirname, join } from 'node:path';
23
+ MCP_PACKAGE, PIPE_READER_VERSION, describeMcpCommand, versionOlderThan,
24
+ } from '../mcp-server-version.js';
25
+
26
+ import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY } from '../credential-file.js';
25
27
 
26
28
  /**
27
29
  * The grant broker's path. wren's ruling for the daemon-side half of TASK-063:
@@ -104,94 +106,90 @@ export const connectMcp = (server, opts = {}) => (typeof server?.url === 'string
104
106
  * carries only a pointer to it (`COMMONLY_TOKEN_FD=3`) — not a secret. The
105
107
  * child end of that pipe is read to EOF.
106
108
  *
109
+ * WHERE THE TOKEN COMES FROM. The launcher writes a per-spawn 0600 file and the
110
+ * declaration names it with `${COMMONLY_TOKEN_FILE}` — a PATH, not a secret — so
111
+ * the runtime's own environment never holds the token (TASK-082/083). A
112
+ * declaration that instead carries the literal token in `COMMONLY_AGENT_TOKEN`
113
+ * still works: that is the older channel, and it is the operator's to declare.
114
+ * Whichever named the credential, the child gets it on the pipe and its own
115
+ * environment is left clean.
116
+ *
107
117
  * THE PIPE IS ONLY AVAILABLE WHERE WE SPAWN. This is the pi path; claude and
108
118
  * codex let their own CLI start the server (claude expands `${VAR}` in its own
109
- * process env, codex rides `mcp_servers.*.env_vars`), so a pipe opened here
110
- * never reaches that grandchild. Those two still hand the token over in their
111
- * runtime's environment, which is a separate, still-open half of the same row.
119
+ * process env, codex writes a plain `mcp_servers.*.env` entry), so a pipe opened
120
+ * here never reaches that grandchild. Those two deliver the PATH instead, which
121
+ * is what let the token leave their runtime environments too.
112
122
  *
113
123
  * THE OLD SERVER STILL WORKS. `@commonlyai/mcp` only learned to read the pipe in
114
- * 0.3.11, and a seat may pin an older one — the sprint seats run a staging
115
- * checkout of 0.3.4 so a declaration whose command names an older
124
+ * 0.3.11, and a seat may pin an older one — the five staging seats ran a checkout
125
+ * of 0.3.7 when this was measured (2026-09-19; the doc here said 0.3.4, which was
126
+ * true two weeks earlier) — so a declaration whose command names an older
116
127
  * `@commonlyai/mcp` keeps the environment variable AS WELL, with a warning that
117
128
  * names the pin. An operator can also opt out explicitly with
118
129
  * `COMMONLY_TOKEN_CHANNEL=env` in the entry's own env. Otherwise the token is
119
130
  * piped and the environment is left clean.
120
131
  */
121
- export const CREDENTIAL_KEY = 'COMMONLY_AGENT_TOKEN';
132
+ export { CREDENTIAL_KEY, CREDENTIAL_FILE_VAR };
133
+
122
134
  export const CREDENTIAL_FD_VAR = 'COMMONLY_TOKEN_FD';
123
135
  export const CREDENTIAL_CHANNEL_VAR = 'COMMONLY_TOKEN_CHANNEL';
124
136
  export const CREDENTIAL_FD = 3;
125
137
 
126
- /** The `@commonlyai/mcp` release whose `loadConfig` reads the pipe channel. */
127
- export const PIPE_READER_VERSION = [0, 3, 11];
128
-
129
- const MCP_PACKAGE = '@commonlyai/mcp';
130
-
131
- const parseVersion = (spec) => {
132
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(spec || '').trim());
133
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
134
- };
135
-
136
- const olderThanPipeReader = (version) => {
137
- if (!version) return null;
138
- for (let i = 0; i < 3; i += 1) {
139
- if (version[i] !== PIPE_READER_VERSION[i]) return version[i] < PIPE_READER_VERSION[i];
140
- }
141
- return false;
142
- };
143
-
144
138
  /**
145
- * What an `@commonlyai/mcp` command would run, or null when the command cannot
146
- * be identified as that package at all.
139
+ * The only variables a spawned MCP server inherits from us.
147
140
  *
148
- * Two shapes matter: `npx [-y] @commonlyai/mcp@<spec>` (a spec is a version, or
149
- * `latest`/absent, which resolves to whatever is published never treated as
150
- * old), and a local checkout, `node <path>/src/index.js`, which is what the
151
- * staging seats run; for that one the package.json beside it is the only honest
152
- * answer, and a package.json naming something else means this is not our server.
141
+ * This list is DERIVED, not guessed: each entry exists because a real child
142
+ * failed without it, and a child that only fails at spawn is the worst failure
143
+ * shape there is (TASK-083). `PATH` runs the command (and, for a seat, carries
144
+ * the seat-launch shim directory prepended, so it must be passed through rather
145
+ * than canonicalised); `HOME` is where `npx` keeps the cache it fetches
146
+ * `@commonlyai/mcp` into, which a cold start needs; `TMPDIR` was measured in a
147
+ * live child on macOS, where /var/folders is not /tmp; the proxy and CA names
148
+ * are absent on this machine, so an operator behind a proxy or a private CA is
149
+ * the case that cannot be exercised here — including them unset costs nothing,
150
+ * and without them that operator's cold `npx` would fail in a way that reads as
151
+ * this change regressing.
153
152
  *
154
- * `{ isCommonly: true, version: null }` means "our server, version unknown"
155
- * an unpinned npx spec, whose whole point is that it tracks the published one.
156
- * `null` as the return value means "not identifiable as our server", which is a
157
- * different answer and takes a different branch: a stranger's server gets its
158
- * declaration honoured unchanged.
153
+ * Deliberately NOT a prefix match: a variable named `COMMONLY_*` is ours to hand
154
+ * over explicitly, and a variable named like a secret is exactly what an
155
+ * allowlist exists to drop.
159
156
  */
160
- export const describeMcpCommand = (command, { readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null) } = {}) => {
161
- if (!Array.isArray(command) || command.length === 0) return null;
162
- const parts = command.map(String);
163
- const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
164
- if (pkgArg) {
165
- const at = pkgArg.lastIndexOf('@');
166
- if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
167
- return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
168
- }
169
- const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
170
- if (!scriptPath) return null;
171
- // `src/index.js` `../package.json`; also try one level further up, because a
172
- // bin shim can live in `bin/` beside `src/`.
173
- for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
174
- let raw;
175
- try {
176
- raw = readTextFile(candidate);
177
- } catch {
178
- raw = null;
179
- }
180
- if (!raw) continue;
181
- try {
182
- const pkg = JSON.parse(raw);
183
- if (!pkg || typeof pkg !== 'object') continue;
184
- if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
185
- // A package.json that names another package settles it: not ours, so its
186
- // declaration is none of this function's business.
187
- return null;
188
- } catch {
189
- // A malformed package.json is not an answer; keep looking.
190
- }
157
+ export const CHILD_ENV_ALLOWLIST = Object.freeze([
158
+ 'PATH',
159
+ 'HOME',
160
+ 'TMPDIR',
161
+ 'HTTPS_PROXY',
162
+ 'HTTP_PROXY',
163
+ 'NO_PROXY',
164
+ 'NODE_EXTRA_CA_CERTS',
165
+ ]);
166
+
167
+ /**
168
+ * The environment a spawned MCP server gets: the allowlist above, then whatever
169
+ * the entry itself declared, and nothing else. The daemon's environment is not a
170
+ * channel into a child — `...process.env` used to make it one, which is how a
171
+ * third-party server came to hold our seat token and the model key.
172
+ */
173
+ export const buildChildEnv = (parentEnv, declaredEnv) => {
174
+ const out = {};
175
+ for (const key of CHILD_ENV_ALLOWLIST) {
176
+ const value = parentEnv ? parentEnv[key] : undefined;
177
+ if (value !== undefined) out[key] = value;
191
178
  }
192
- return null;
179
+ return Object.assign(out, declaredEnv || {});
193
180
  };
194
181
 
182
+ /**
183
+ * The channel predicates live in `../mcp-server-version.js`, because the claude
184
+ * and codex adapters have to answer the same question and a second copy of it
185
+ * would drift silently in one of them.
186
+ */
187
+ export {
188
+ MCP_PACKAGE, PIPE_READER_VERSION, FILE_READER_VERSION, parseVersion, describeMcpCommand,
189
+ } from '../mcp-server-version.js';
190
+
191
+ const olderThanPipeReader = (version) => versionOlderThan(version, PIPE_READER_VERSION);
192
+
195
193
  /**
196
194
  * Split a declared env map into the child's environment and the credential to
197
195
  * hand over the pipe.
@@ -201,16 +199,44 @@ export const describeMcpCommand = (command, { readTextFile = (p) => (existsSync(
201
199
  * server, or the declaration opted out explicitly. Everything else gets the
202
200
  * pointer variable and no secret.
203
201
  */
204
- export const splitCredential = (env, command, { onWarn = (m) => process.stderr.write(`${m}\n`) } = {}) => {
202
+ export const splitCredential = (env, command, {
203
+ onWarn = (m) => process.stderr.write(`${m}\n`),
204
+ readCredentialFile = (path) => readFileSync(path, 'utf8'),
205
+ } = {}) => {
205
206
  const declared = { ...(env || {}) };
206
- const credential = declared[CREDENTIAL_KEY];
207
+ const declaredFile = declared[CREDENTIAL_FILE_VAR];
207
208
  const requested = String(declared[CREDENTIAL_CHANNEL_VAR] || '').trim().toLowerCase();
208
209
  delete declared[CREDENTIAL_CHANNEL_VAR];
210
+ let credential = null;
211
+ let fromFile = false;
212
+ // A declaration that names a file is using the launcher channel, and that is
213
+ // the one to honour: if it ALSO carries a literal token, the literal is the
214
+ // leftover of an older declaration, and preferring it would keep the secret in
215
+ // the very place this exists to empty.
216
+ if (declaredFile !== undefined && String(declaredFile).trim() !== '') {
217
+ // The launcher channel: the declaration names a path, and the credential is
218
+ // read here so the child never needs the file (or the token) at all.
219
+ const path = String(declaredFile).trim();
220
+ let raw;
221
+ try {
222
+ raw = readCredentialFile(path);
223
+ } catch (err) {
224
+ throw new Error(`the declared credential file could not be read: ${path}: ${err.message}`);
225
+ }
226
+ credential = String(raw ?? '').trim();
227
+ if (!credential) throw new Error(`the declared credential file carried nothing: ${path}`);
228
+ fromFile = true;
229
+ } else if (declared[CREDENTIAL_KEY]) {
230
+ credential = declared[CREDENTIAL_KEY];
231
+ }
209
232
  if (!credential) {
210
233
  delete declared[CREDENTIAL_KEY];
234
+ delete declared[CREDENTIAL_FILE_VAR];
211
235
  return { env: declared, credential: null, keepInEnv: false };
212
236
  }
213
237
  if (requested === 'env') {
238
+ declared[CREDENTIAL_KEY] = credential;
239
+ delete declared[CREDENTIAL_FILE_VAR];
214
240
  return { env: declared, credential: null, keepInEnv: true };
215
241
  }
216
242
  const server = describeMcpCommand(command);
@@ -223,25 +249,32 @@ export const splitCredential = (env, command, { onWarn = (m) => process.stderr.w
223
249
  }
224
250
  if (server.version && olderThanPipeReader(server.version) === true) {
225
251
  onWarn(`[pi-mcp-client] ${command[0]} runs ${MCP_PACKAGE} ${server.version.join('.')}, which predates the pipe channel (0.3.11): keeping the token in the child environment. Unpin it, or set ${CREDENTIAL_CHANNEL_VAR}=env to say so on purpose.`);
252
+ declared[CREDENTIAL_KEY] = credential;
253
+ delete declared[CREDENTIAL_FILE_VAR];
226
254
  return { env: declared, credential: null, keepInEnv: true };
227
255
  }
228
256
  delete declared[CREDENTIAL_KEY];
257
+ // The pipe carries the credential, so the child needs neither the token nor the
258
+ // path: one declared channel, and it is the fd.
259
+ delete declared[CREDENTIAL_FILE_VAR];
229
260
  declared[CREDENTIAL_FD_VAR] = String(CREDENTIAL_FD);
230
- return { env: declared, credential, keepInEnv: false };
261
+ return { env: declared, credential, keepInEnv: false, fromFile };
231
262
  };
232
263
 
233
264
  /** A minimal MCP stdio client: initialize, tools/list, tools/call. */
234
265
  export const connectStdioMcp = ({
235
266
  name, command, env,
236
267
  }, {
237
- spawnImpl = spawn, timeoutMs = 60_000, onWarn,
268
+ spawnImpl = spawn, timeoutMs = 60_000, onWarn, parentEnv = process.env, readCredentialFile,
238
269
  } = {}) => {
239
270
  const [cmd, ...args] = command;
240
- const { env: declaredEnv, credential, keepInEnv } = splitCredential(env, command, onWarn ? { onWarn } : {});
241
- // The inherited environment is stripped of the key unless an old server has to
242
- // read it there: the daemon's own environment is not a channel into a child,
243
- // and `...process.env` used to make it one.
244
- const childEnv = { ...process.env, ...declaredEnv };
271
+ const opts = onWarn ? { onWarn } : {};
272
+ if (readCredentialFile) opts.readCredentialFile = readCredentialFile;
273
+ const { env: declaredEnv, credential, keepInEnv } = splitCredential(env, command, opts);
274
+ // The child gets an ALLOWLIST plus its own declaration — never the daemon's
275
+ // environment. `...process.env` is what let a third-party MCP server inherit
276
+ // both the seat token and the model key (TASK-083).
277
+ const childEnv = buildChildEnv(parentEnv, declaredEnv);
245
278
  if (!keepInEnv) delete childEnv[CREDENTIAL_KEY];
246
279
  const stdio = credential ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'];
247
280
  const proc = spawnImpl(cmd, args, { env: childEnv, stdio });