@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 +1 -1
- package/src/commands/agent.js +9 -1
- package/src/lib/adapters/claude.js +71 -12
- package/src/lib/adapters/codex.js +72 -2
- package/src/lib/adapters/pi-mcp-client.mjs +113 -80
- package/src/lib/adapters/pi.js +64 -22
- package/src/lib/credential-file.js +78 -0
- package/src/lib/daemon-supervisor.js +26 -4
- package/src/lib/grant-broker-guard.js +268 -0
- package/src/lib/hooks-config.js +32 -1
- package/src/lib/mcp-credential-delivery.js +110 -0
- package/src/lib/mcp-server-version.js +90 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
// ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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(
|
|
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
|
-
|
|
23
|
-
} from '
|
|
24
|
-
|
|
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
|
|
110
|
-
* never reaches that grandchild. Those two
|
|
111
|
-
*
|
|
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
|
|
115
|
-
*
|
|
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
|
|
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
|
-
*
|
|
146
|
-
* be identified as that package at all.
|
|
139
|
+
* The only variables a spawned MCP server inherits from us.
|
|
147
140
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
|
|
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 });
|