@commonlyai/cli 0.1.63 → 0.1.65
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 +92 -0
- package/src/lib/hooks-config.js +32 -1
- package/src/lib/mcp-credential-delivery.js +121 -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 });
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -40,6 +40,8 @@ import { dirname, join } from 'path';
|
|
|
40
40
|
import { fileURLToPath } from 'url';
|
|
41
41
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
42
42
|
import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
|
|
43
|
+
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
44
|
+
import { removeCredentialFile, writeCredentialFile } from '../credential-file.js';
|
|
43
45
|
|
|
44
46
|
const DEFAULT_TIMEOUT_MS = (() => {
|
|
45
47
|
const fallback = 15 * 60 * 1000;
|
|
@@ -68,12 +70,17 @@ const BRIDGE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'pi-commonly-m
|
|
|
68
70
|
// Same substitution contract as claude.js / codex.js: ${COMMONLY_*}
|
|
69
71
|
// placeholders in the declared MCP env are the wrapper's per-(agent, pod)
|
|
70
72
|
// runtime values, filled at spawn time.
|
|
71
|
-
const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
|
|
73
|
+
const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_TOKEN_FILE', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
|
|
72
74
|
const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
|
|
73
75
|
const substitutePlaceholders = (value, ctx) => {
|
|
74
76
|
if (typeof value !== 'string' || !value.includes('${COMMONLY_')) return value;
|
|
75
77
|
const subs = {
|
|
76
78
|
COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
|
|
79
|
+
// The PATH of this spawn's credential file. The bridge reads the file and
|
|
80
|
+
// hands the VALUE to the server on fd 3, so the token still never reaches a
|
|
81
|
+
// child's environment — but the payload that travels to the bridge carries a
|
|
82
|
+
// path rather than the secret (TASK-083).
|
|
83
|
+
COMMONLY_TOKEN_FILE: ctx.credentialFile || '',
|
|
77
84
|
COMMONLY_API_URL: ctx.instanceUrl || '',
|
|
78
85
|
COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
|
|
79
86
|
};
|
|
@@ -198,7 +205,14 @@ export const resolveMcpServers = (mcpServers, ctx = {}) => {
|
|
|
198
205
|
carried.push({
|
|
199
206
|
name: server.name,
|
|
200
207
|
command: server.command.map((a) => substitutePlaceholders(a, ctx)),
|
|
201
|
-
|
|
208
|
+
// Rewritten before substitution, so our own server is handed the file
|
|
209
|
+
// (whose value the bridge pipes) instead of the token itself.
|
|
210
|
+
env: Object.fromEntries(Object.entries(
|
|
211
|
+
deliverSeatCredential(server, {
|
|
212
|
+
credentialFile: ctx.credentialFile,
|
|
213
|
+
label: 'pi',
|
|
214
|
+
}).env,
|
|
215
|
+
).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
|
|
202
216
|
});
|
|
203
217
|
continue;
|
|
204
218
|
}
|
|
@@ -384,27 +398,55 @@ export default {
|
|
|
384
398
|
const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: !isResume });
|
|
385
399
|
await writeFile(join(agentDir, 'models.json'), `${JSON.stringify(buildModelsJson(provider, model), null, 2)}\n`, { mode: 0o600 });
|
|
386
400
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
|
|
395
|
-
bridge: servers.length ? (ctx._bridgePath || BRIDGE_PATH) : null,
|
|
401
|
+
// One credential file per spawn, inside this seat's own 0700 home — pi has no
|
|
402
|
+
// enforced sandbox, so the seat's home is the narrowest place that is still
|
|
403
|
+
// readable by the bridge. The value the server receives still travels on fd 3
|
|
404
|
+
// (pi-mcp-client's fd channel); the file is where the launcher puts it.
|
|
405
|
+
const credential = writeCredentialFile(ctx.runtimeToken, {
|
|
406
|
+
agentName: ctx.agentName || 'agent',
|
|
407
|
+
root: join(home, 'credentials'),
|
|
396
408
|
});
|
|
409
|
+
try {
|
|
410
|
+
const servers = resolveMcpServers(ctx.environment?.mcp, {
|
|
411
|
+
...ctx,
|
|
412
|
+
credentialFile: credential?.path || null,
|
|
413
|
+
});
|
|
414
|
+
const childEnv = {
|
|
415
|
+
...baseEnv,
|
|
416
|
+
PI_CODING_AGENT_DIR: agentDir,
|
|
417
|
+
PI_SKIP_VERSION_CHECK: '1',
|
|
418
|
+
};
|
|
419
|
+
// `baseEnv` is normally the process environment, which carries the
|
|
420
|
+
// bootstrap export. Nothing below this process needs the value: the bridge
|
|
421
|
+
// is handed the servers, their resolved values included, over fd 3. It has
|
|
422
|
+
// to come out rather than merely stop being added, because pi's `bash`
|
|
423
|
+
// tool spawns children with `{ ...process.env }`, so a copy here is a copy
|
|
424
|
+
// in the seat's shell — and the file path goes in, so a hook resolves its
|
|
425
|
+
// credential without the value.
|
|
426
|
+
withholdRuntimeCredential(childEnv, {
|
|
427
|
+
credentialFile: credential?.path || null,
|
|
428
|
+
});
|
|
429
|
+
const args = buildArgs({
|
|
430
|
+
prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
|
|
431
|
+
bridge: servers.length ? (ctx._bridgePath || BRIDGE_PATH) : null,
|
|
432
|
+
});
|
|
397
433
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
434
|
+
const reply = await runPi({
|
|
435
|
+
args,
|
|
436
|
+
cwd: ctx.cwd,
|
|
437
|
+
env: childEnv,
|
|
438
|
+
payload: servers.length ? JSON.stringify(servers) : undefined,
|
|
439
|
+
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
440
|
+
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
441
|
+
});
|
|
442
|
+
// Empty text with a clean exit is a silent turn; the run loop treats it
|
|
443
|
+
// as NO_REPLY-shaped and re-delivers on its own rules.
|
|
444
|
+
return { text: reply.text, newSessionId: sessionId };
|
|
445
|
+
} finally {
|
|
446
|
+
// Best effort: the bridge has already read what it needs by the time the
|
|
447
|
+
// turn ends, and a token file that outlives its turn is a token file that
|
|
448
|
+
// sits in a home for the next one.
|
|
449
|
+
removeCredentialFile(credential);
|
|
450
|
+
}
|
|
409
451
|
},
|
|
410
452
|
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-spawn credential file: the launcher channel for the runtime token
|
|
3
|
+
* (TASK-082/083, ruled 2026-09-19 — "a credential file, only its PATH in the
|
|
4
|
+
* env").
|
|
5
|
+
*
|
|
6
|
+
* WHY A FILE AND NOT THE ENVIRONMENT. The token used to ride the runtime's own
|
|
7
|
+
* environment (claude expands `${COMMONLY_AGENT_TOKEN}` from it, codex forwards
|
|
8
|
+
* it through `mcp_servers.*.env_vars`), which means every child of the runtime
|
|
9
|
+
* inherited it — measured on 2026-09-19: a `@playwright/mcp` process held the
|
|
10
|
+
* seat's `COMMONLY_AGENT_TOKEN`, and a pi seat's MCP child held the daemon's
|
|
11
|
+
* `COMMONLY_LITELLM_KEY`. A PATH is not a secret, so handing the path to the
|
|
12
|
+
* runtime is safe even though the token it names is not, and an unrelated MCP
|
|
13
|
+
* server that inherits the runtime's environment gets nothing it can use.
|
|
14
|
+
*
|
|
15
|
+
* WHY PER SPAWN. The file lives only as long as one spawn of one runtime, in a
|
|
16
|
+
* directory only its owner can traverse (0700), with the file itself 0600. A
|
|
17
|
+
* fixed path would widen the window to "since the first spawn" and would let two
|
|
18
|
+
* concurrent seats share one credential by accident.
|
|
19
|
+
*
|
|
20
|
+
* The caller names the root; this module never invents one, because the only
|
|
21
|
+
* root it could invent is a directory no adapter cleans up.
|
|
22
|
+
*
|
|
23
|
+
* The reader side is `readToken` in `commonly-mcp/src/client.js`, which resolves
|
|
24
|
+
* fd, then file, then environment — by declaration, and refuses to fall through
|
|
25
|
+
* from a declared source that cannot be read.
|
|
26
|
+
*/
|
|
27
|
+
import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { randomBytes } from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
/** The variable a child reads to find the credential. Never carries the token. */
|
|
32
|
+
export const CREDENTIAL_FILE_VAR = 'COMMONLY_TOKEN_FILE';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The variable that USED to carry the token itself, and still does for a server
|
|
36
|
+
* that cannot read anything else. Kept beside the file var so the two channels
|
|
37
|
+
* are named in one place rather than one per adapter.
|
|
38
|
+
*/
|
|
39
|
+
export const CREDENTIAL_KEY = 'COMMONLY_AGENT_TOKEN';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write `token` to a fresh 0600 file under `root` and return its path, or null
|
|
43
|
+
* when there is no token to write (a seat bootstrapping without one).
|
|
44
|
+
*
|
|
45
|
+
* `root` is REQUIRED. It used to default to the CLI's own state directory, which
|
|
46
|
+
* meant a caller that forgot it wrote a live credential into
|
|
47
|
+
* `~/.commonly/credentials` — a directory no adapter cleans, because that is not
|
|
48
|
+
* where adapters put theirs. Measured 2026-09-20: 32 such directories on the
|
|
49
|
+
* fleet host, 24 of them written by this repo's own harnesses, all holding a
|
|
50
|
+
* seat credential. A missing root is now an error instead of a silent write into
|
|
51
|
+
* the operator's home. The token check stays first, so "no token, nothing
|
|
52
|
+
* written" remains true without a root.
|
|
53
|
+
*
|
|
54
|
+
* `fs` is injectable so tests can assert the mode and the path shape without
|
|
55
|
+
* writing into the operator's home.
|
|
56
|
+
*/
|
|
57
|
+
export const writeCredentialFile = (token, {
|
|
58
|
+
agentName = 'agent',
|
|
59
|
+
root,
|
|
60
|
+
fs = { mkdirSync, writeFileSync, chmodSync, rmSync },
|
|
61
|
+
now = () => Date.now(),
|
|
62
|
+
random = () => randomBytes(4).toString('hex'),
|
|
63
|
+
} = {}) => {
|
|
64
|
+
const value = typeof token === 'string' ? token.trim() : '';
|
|
65
|
+
if (!value) return null;
|
|
66
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'writeCredentialFile requires an explicit root: an omitted root used to fall back to '
|
|
69
|
+
+ '~/.commonly/credentials, where nothing sweeps the credential files it writes',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const dir = join(root, `${agentName}-${now()}-${random()}`);
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
74
|
+
const path = join(dir, 'token');
|
|
75
|
+
fs.writeFileSync(path, value, { mode: 0o600 });
|
|
76
|
+
// writeFileSync's mode is subject to the process umask, so a 0022 umask still
|
|
77
|
+
// produces 0644. chmod is the assertion, not a courtesy.
|
|
78
|
+
fs.chmodSync(path, 0o600);
|
|
79
|
+
return { path, dir };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Best-effort removal once the spawn that owns the file has ended. */
|
|
83
|
+
export const removeCredentialFile = (written, { fs = { rmSync } } = {}) => {
|
|
84
|
+
const dir = typeof written === 'string' ? dirname(written) : written?.dir;
|
|
85
|
+
if (!dir) return false;
|
|
86
|
+
try {
|
|
87
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
};
|
package/src/lib/hooks-config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from
|
|
|
2
2
|
import { dirname, join, resolve as pathResolve, isAbsolute, relative } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { createHash } from 'crypto';
|
|
5
|
+
import { CREDENTIAL_FILE_VAR } from './credential-file.js';
|
|
5
6
|
|
|
6
7
|
export const HOOK_EVENTS = ['PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop'];
|
|
7
8
|
export const DEFAULT_HOOK_TIMEOUT_MS = 3000;
|
|
@@ -155,9 +156,39 @@ export const writeHooksConfig = ({
|
|
|
155
156
|
* positive decision returned by the Commonly endpoint; D7 does not let a
|
|
156
157
|
* missing/slow ledger become an accidental write blocker.
|
|
157
158
|
*/
|
|
159
|
+
/**
|
|
160
|
+
* The hook's credential, resolved the way the runtime it runs inside resolves it.
|
|
161
|
+
*
|
|
162
|
+
* A hook is a child process of the seat's runtime, so it sees that runtime's
|
|
163
|
+
* environment — which is exactly the environment TASK-083 emptied of the token.
|
|
164
|
+
* Reading only COMMONLY_AGENT_TOKEN would therefore leave every hook on a
|
|
165
|
+
* migrated seat with no credential, and because this path fails open by design
|
|
166
|
+
* (the `hook_unavailable` return below), the symptom would be a tool-policy hook
|
|
167
|
+
* that silently stopped deciding anything. So the launcher file comes first, and
|
|
168
|
+
* the value variable stays as the fallback for a seat whose declaration has not
|
|
169
|
+
* migrated.
|
|
170
|
+
*
|
|
171
|
+
* Unlike the MCP reader, a declared-but-unreadable file does NOT throw here: a
|
|
172
|
+
* hook that dies is a hook the runtime reports as broken, and this function's
|
|
173
|
+
* documented posture is to fail open rather than become an accidental blocker.
|
|
174
|
+
*/
|
|
175
|
+
export const resolveHookToken = ({
|
|
176
|
+
env = process.env,
|
|
177
|
+
readTokenFile = (path) => readFileSync(path, 'utf8'),
|
|
178
|
+
} = {}) => {
|
|
179
|
+
const path = env[CREDENTIAL_FILE_VAR];
|
|
180
|
+
if (typeof path === 'string' && path.trim() !== '') {
|
|
181
|
+
try {
|
|
182
|
+
const fromFile = readTokenFile(path).trim();
|
|
183
|
+
if (fromFile) return fromFile;
|
|
184
|
+
} catch { /* fall through to the value channel */ }
|
|
185
|
+
}
|
|
186
|
+
return env.COMMONLY_AGENT_TOKEN;
|
|
187
|
+
};
|
|
188
|
+
|
|
158
189
|
export const forwardHookEvent = async ({
|
|
159
190
|
endpoint,
|
|
160
|
-
token =
|
|
191
|
+
token = resolveHookToken(),
|
|
161
192
|
input = '',
|
|
162
193
|
timeoutMs = DEFAULT_HOOK_TIMEOUT_MS,
|
|
163
194
|
fetchImpl = globalThis.fetch,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a declared MCP server receives the seat credential, for the two adapters
|
|
3
|
+
* that cannot hand it over on a pipe.
|
|
4
|
+
*
|
|
5
|
+
* The pi bridge spawns the server itself, so it can pipe the credential on an
|
|
6
|
+
* inherited fd (see `pi-mcp-client.mjs`). Claude and codex do not: each of them
|
|
7
|
+
* starts the server inside its own process tree, so the only two channels
|
|
8
|
+
* available are (a) a value in the runtime's environment, which the whole
|
|
9
|
+
* subtree inherits, or (b) a PATH in the declaration that the server itself
|
|
10
|
+
* reads. (b) is the one that leaves the token where it belongs.
|
|
11
|
+
*
|
|
12
|
+
* This module makes that choice in ONE place, because three call sites deciding
|
|
13
|
+
* version thresholds independently is how one of them ends up handing over the
|
|
14
|
+
* value while the others hand over the path.
|
|
15
|
+
*
|
|
16
|
+
* The rewrite is of the DECLARATION, not of the value: an entry that named
|
|
17
|
+
* `COMMONLY_AGENT_TOKEN` comes back naming `COMMONLY_TOKEN_FILE`, so the value
|
|
18
|
+
* never exists in the runtime's environment to be inherited, logged, or dumped
|
|
19
|
+
* by an unrelated MCP server that a seat was granted.
|
|
20
|
+
*/
|
|
21
|
+
import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY } from './credential-file.js';
|
|
22
|
+
import {
|
|
23
|
+
FILE_READER_VERSION, MCP_PACKAGE, describeMcpCommand, versionOlderThan,
|
|
24
|
+
} from './mcp-server-version.js';
|
|
25
|
+
|
|
26
|
+
/** What a declaration should say to receive the credential as a path. */
|
|
27
|
+
export const CREDENTIAL_FILE_PLACEHOLDER = '${COMMONLY_TOKEN_FILE}';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a runtime's OWN environment may carry, once the declarations are settled.
|
|
31
|
+
*
|
|
32
|
+
* The rewrite above decides what a CHILD is told. It does not decide what the
|
|
33
|
+
* runtime process itself carries, and that is a separate leak with the same
|
|
34
|
+
* symptom: the credential is exported for bootstrap (`agent run`, the daemon),
|
|
35
|
+
* so an adapter that derives its runtime environment from `process.env` hands
|
|
36
|
+
* the value back to the runtime — and to every MCP child, hook and shell below
|
|
37
|
+
* it — however the declaration was rewritten. Measured (Vera, 70455): four tests
|
|
38
|
+
* that pass in a runner without the variable fail with it set, and the value
|
|
39
|
+
* they saw was the runner's own.
|
|
40
|
+
*
|
|
41
|
+
* So the PATH of this spawn's file goes in (a path is not a secret, and a hook
|
|
42
|
+
* process resolves its credential from it — see `hooks-config.resolveHookToken`)
|
|
43
|
+
* and the VALUE comes out, unless a carve-out genuinely needs the value here:
|
|
44
|
+
* a field the adapter substitutes LITERALLY has no file channel, so `keepsValue`
|
|
45
|
+
* is passed in by the adapter rather than inferred, and the spawn that keeps a
|
|
46
|
+
* secret says so in its own warning.
|
|
47
|
+
*
|
|
48
|
+
* The PATH comes out too when there is no file for THIS spawn. The runtime
|
|
49
|
+
* environment is derived from `process.env`, so a launcher whose own process was
|
|
50
|
+
* spawned by another seat inherits that seat's `COMMONLY_TOKEN_FILE` and hands
|
|
51
|
+
* it to the runtime and every MCP child below it — a path to a credential this
|
|
52
|
+
* launcher did not mint. The value was already deleted here for that reason; the
|
|
53
|
+
* path is the same leak with a smaller blast radius.
|
|
54
|
+
*/
|
|
55
|
+
export const withholdRuntimeCredential = (env, { credentialFile = null, keepsValue = false } = {}) => {
|
|
56
|
+
if (credentialFile) {
|
|
57
|
+
env[CREDENTIAL_FILE_VAR] = credentialFile;
|
|
58
|
+
} else {
|
|
59
|
+
delete env[CREDENTIAL_FILE_VAR];
|
|
60
|
+
}
|
|
61
|
+
if (!keepsValue) delete env[CREDENTIAL_KEY];
|
|
62
|
+
return env;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** What a declaration says when it asks for the seat credential (the old shape). */
|
|
66
|
+
export const CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rewrite one server's declared environment so the credential arrives as a path.
|
|
70
|
+
*
|
|
71
|
+
* Returns `{ env, delivered }` where `delivered` is one of:
|
|
72
|
+
* 'path' — the declaration now names the file; the adapter expands it
|
|
73
|
+
* 'env' — the value stays in the environment, deliberately (see below)
|
|
74
|
+
* 'none' — nothing to deliver: the entry declares no credential
|
|
75
|
+
* 'unavailable' — no launcher credential file exists for this spawn
|
|
76
|
+
*
|
|
77
|
+
* `env` is a fresh object; the caller's declaration is never mutated.
|
|
78
|
+
*/
|
|
79
|
+
export const deliverSeatCredential = (server, {
|
|
80
|
+
credentialFile,
|
|
81
|
+
onWarn = (message) => process.stderr.write(`${message}\n`),
|
|
82
|
+
label = 'mcp',
|
|
83
|
+
} = {}) => {
|
|
84
|
+
const env = { ...((server || {}).env || {}) };
|
|
85
|
+
if (env[CREDENTIAL_FILE_VAR] !== undefined && String(env[CREDENTIAL_FILE_VAR]).trim() !== '') {
|
|
86
|
+
// Already on the launcher channel — an operator who set this by hand, or a
|
|
87
|
+
// record written after this shipped. Nothing to rewrite.
|
|
88
|
+
return { env, delivered: 'path' };
|
|
89
|
+
}
|
|
90
|
+
if (env[CREDENTIAL_KEY] === undefined) return { env, delivered: 'none' };
|
|
91
|
+
if (!credentialFile) {
|
|
92
|
+
// No launcher wrote a file, so there is nothing to point at. Leave the
|
|
93
|
+
// declaration exactly as it was rather than handing over a path to nowhere.
|
|
94
|
+
return { env, delivered: 'unavailable' };
|
|
95
|
+
}
|
|
96
|
+
const ours = describeMcpCommand((server || {}).command);
|
|
97
|
+
if (!ours) {
|
|
98
|
+
// Somebody else's server. Their declaration is theirs: we do not know their
|
|
99
|
+
// protocol, so replacing their variable with a path would break a server we
|
|
100
|
+
// have no business redefining.
|
|
101
|
+
onWarn(`[${label}] ${server?.name} is not ${MCP_PACKAGE} but declares ${CREDENTIAL_KEY}; leaving that declaration alone. Declare the seat credential on the commonly entry instead.`);
|
|
102
|
+
return { env, delivered: 'env' };
|
|
103
|
+
}
|
|
104
|
+
if (versionOlderThan(ours.version, FILE_READER_VERSION) === true) {
|
|
105
|
+
// Measured, not hypothetical: five seats run a hand-patched staging checkout
|
|
106
|
+
// at 0.3.7, whose reader only understands the environment. Handing it a path
|
|
107
|
+
// it cannot read would take its tools away rather than its secret.
|
|
108
|
+
onWarn(`[${label}] ${server.name} runs @commonlyai/mcp ${ours.version.join('.')}, which predates the credential file (${FILE_READER_VERSION.join('.')}): keeping the token in the environment. Unpin it, or move that seat off this checkout.`);
|
|
109
|
+
return { env, delivered: 'env' };
|
|
110
|
+
}
|
|
111
|
+
if (String(env[CREDENTIAL_KEY]) !== CREDENTIAL_PLACEHOLDER) {
|
|
112
|
+
// A literal token in a declaration is stale by construction — seat tokens
|
|
113
|
+
// rotate, and this one was read when the record was written. Superseding it
|
|
114
|
+
// with the live credential is a repair, but it IS a change in what the seat
|
|
115
|
+
// sends, so it is said out loud rather than done quietly.
|
|
116
|
+
onWarn(`[${label}] ${server.name} declares a literal ${CREDENTIAL_KEY}; superseding it with this spawn's credential file.`);
|
|
117
|
+
}
|
|
118
|
+
delete env[CREDENTIAL_KEY];
|
|
119
|
+
env[CREDENTIAL_FILE_VAR] = CREDENTIAL_FILE_PLACEHOLDER;
|
|
120
|
+
return { env, delivered: 'path' };
|
|
121
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `@commonlyai/mcp` a declared MCP command would run, and which channels
|
|
3
|
+
* that release understands.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the pi bridge (which decides whether to pipe a credential) and by
|
|
6
|
+
* the claude and codex adapters (which decide whether to hand over a PATH or the
|
|
7
|
+
* value): one predicate, because three copies of "is this old enough" would
|
|
8
|
+
* drift, and the drift would be silent in exactly one adapter.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const MCP_PACKAGE = '@commonlyai/mcp';
|
|
14
|
+
|
|
15
|
+
/** The release whose reader accepts the credential on an inherited fd. */
|
|
16
|
+
export const PIPE_READER_VERSION = [0, 3, 11];
|
|
17
|
+
|
|
18
|
+
/** The release whose reader accepts `COMMONLY_TOKEN_FILE` (a PATH, not a secret). */
|
|
19
|
+
export const FILE_READER_VERSION = [0, 3, 12];
|
|
20
|
+
|
|
21
|
+
export const parseVersion = (spec) => {
|
|
22
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(spec || '').trim());
|
|
23
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when `version` is older than `target`; null when there is no version to
|
|
28
|
+
* judge (an unpinned `@latest` tracks the published release, so it is never
|
|
29
|
+
* treated as old).
|
|
30
|
+
*/
|
|
31
|
+
export const versionOlderThan = (version, target) => {
|
|
32
|
+
if (!version) return null;
|
|
33
|
+
for (let i = 0; i < 3; i += 1) {
|
|
34
|
+
if (version[i] !== target[i]) return version[i] < target[i];
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What an `@commonlyai/mcp` command would run, or null when the command cannot
|
|
41
|
+
* be identified as that package at all.
|
|
42
|
+
*
|
|
43
|
+
* Two shapes matter: `npx [-y] @commonlyai/mcp@<spec>` (a spec is a version, or
|
|
44
|
+
* `latest`/absent, which resolves to whatever is published — never treated as
|
|
45
|
+
* old), and a local checkout, `node <path>/src/index.js`, which is what the
|
|
46
|
+
* staging seats run; for that one the package.json beside it is the only honest
|
|
47
|
+
* answer, and a package.json naming something else means this is not our server.
|
|
48
|
+
*
|
|
49
|
+
* `{ isCommonly: true, version: null }` means "our server, version unknown" —
|
|
50
|
+
* an unpinned npx spec, whose whole point is that it tracks the published one.
|
|
51
|
+
* `null` as the return value means "not identifiable as our server", which is a
|
|
52
|
+
* different answer and takes a different branch: a stranger's server gets its
|
|
53
|
+
* declaration honoured unchanged.
|
|
54
|
+
*/
|
|
55
|
+
export const describeMcpCommand = (command, {
|
|
56
|
+
readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null),
|
|
57
|
+
} = {}) => {
|
|
58
|
+
if (!Array.isArray(command) || command.length === 0) return null;
|
|
59
|
+
const parts = command.map(String);
|
|
60
|
+
const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
|
|
61
|
+
if (pkgArg) {
|
|
62
|
+
const at = pkgArg.lastIndexOf('@');
|
|
63
|
+
if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
|
|
64
|
+
return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
|
|
65
|
+
}
|
|
66
|
+
const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
|
|
67
|
+
if (!scriptPath) return null;
|
|
68
|
+
// `src/index.js` → `../package.json`; also try one level further up, because a
|
|
69
|
+
// bin shim can live in `bin/` beside `src/`.
|
|
70
|
+
for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = readTextFile(candidate);
|
|
74
|
+
} catch {
|
|
75
|
+
raw = null;
|
|
76
|
+
}
|
|
77
|
+
if (!raw) continue;
|
|
78
|
+
try {
|
|
79
|
+
const pkg = JSON.parse(raw);
|
|
80
|
+
if (!pkg || typeof pkg !== 'object') continue;
|
|
81
|
+
if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
|
|
82
|
+
// A package.json that names another package settles it: not ours, so its
|
|
83
|
+
// declaration is none of this function's business.
|
|
84
|
+
return null;
|
|
85
|
+
} catch {
|
|
86
|
+
// A malformed package.json is not an answer; keep looking.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|