@commonlyai/cli 0.1.1 → 0.1.4

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.
@@ -8,9 +8,14 @@
8
8
  *
9
9
  * Environment (ADR-008 Phase 1): if ctx.environment is present, the adapter
10
10
  * symlinks declared Claude skills into `<cwd>/.claude/skills/`, writes an MCP
11
- * config file at `<cwd>/.commonly/mcp-config.json` when `mcp` is declared,
12
- * and wraps the argv with bwrap when `sandbox.mode === 'bwrap'`. The spawn
13
- * binary becomes `bwrap` in that case — claude moves to the inner argv.
11
+ * config file into a mode-0700 per-spawn temp directory outside the workspace
12
+ * when `mcp` is declared, and wraps the argv with bwrap when
13
+ * `sandbox.mode === 'bwrap'`. The spawn binary becomes `bwrap` in that case —
14
+ * claude moves to the inner argv.
15
+ * A public `workspace` / `read-only` sandbox maps to an outer deny-default
16
+ * Seatbelt profile on macOS. Claude gets an isolated HOME, a credential-starved
17
+ * child environment, and an explicit tool allow/deny policy; Claude and every
18
+ * declared MCP child inherit the same kernel filesystem boundary.
14
19
  * When ctx.environment is absent, behaviour is identical to pre-ADR-008.
15
20
  *
16
21
  * Session continuity (IMPORTANT — the two claude flags are not interchangeable):
@@ -38,12 +43,29 @@
38
43
  */
39
44
 
40
45
  import { spawn as childSpawn, spawnSync } from 'child_process';
41
- import { randomUUID } from 'crypto';
42
- import { mkdir, writeFile } from 'fs/promises';
43
- import { join } from 'path';
46
+ import { createHash, randomUUID } from 'crypto';
47
+ import { realpathSync } from 'fs';
48
+ import {
49
+ chmod,
50
+ lstat,
51
+ mkdir,
52
+ mkdtemp,
53
+ readFile,
54
+ readlink,
55
+ rm,
56
+ symlink,
57
+ unlink,
58
+ writeFile,
59
+ } from 'fs/promises';
60
+ import { homedir, tmpdir } from 'os';
61
+ import { isAbsolute, join } from 'path';
44
62
 
45
63
  import { linkSkills } from '../environment.js';
46
64
  import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
65
+ import {
66
+ publicClaudeStateRoot,
67
+ wrapArgvWithSeatbelt,
68
+ } from '../sandbox/seatbelt.js';
47
69
 
48
70
  // See codex.js for the rationale on bumping the default + env override.
49
71
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -61,8 +83,149 @@ const buildPrompt = (prompt, memoryLongTerm) => {
61
83
  return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
62
84
  };
63
85
 
86
+ const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
87
+ const PUBLIC_DENIED_TOOLS = [
88
+ 'WebSearch',
89
+ 'WebFetch',
90
+ 'Bash',
91
+ 'Write',
92
+ 'Edit',
93
+ 'NotebookEdit',
94
+ 'Task',
95
+ ];
96
+ const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
97
+
98
+ const statOrNull = async (path) => {
99
+ try {
100
+ return await lstat(path);
101
+ } catch (err) {
102
+ if (err?.code === 'ENOENT') return null;
103
+ throw err;
104
+ }
105
+ };
106
+
107
+ // Claude's macOS OAuth credential lives in Keychain, but Claude also needs a
108
+ // small amount of non-secret account metadata from ~/.claude.json to locate
109
+ // it. Never bind or copy the operator's full file: it contains project paths,
110
+ // MCP configuration, usage history, and other host-private metadata. Public
111
+ // wrappers get a per-identity 0700 HOME carrying only this whitelist.
112
+ const preparePublicClaudeState = async (ctx) => {
113
+ const identity = ctx.agentName || ctx.cwd || 'anonymous';
114
+ const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
115
+ const statePath = ctx._publicClaudeState
116
+ || publicClaudeStateRoot(identityHash);
117
+ const tmpPath = join(statePath, 'tmp');
118
+ const configPath = join(statePath, '.claude.json');
119
+ const stateLibrary = join(statePath, 'Library');
120
+ const stateKeychains = join(stateLibrary, 'Keychains');
121
+ const operatorKeychains = join(homedir(), 'Library', 'Keychains');
122
+
123
+ await mkdir(tmpPath, { recursive: true, mode: 0o700 });
124
+ await mkdir(stateLibrary, { recursive: true, mode: 0o700 });
125
+ await chmod(statePath, 0o700);
126
+ await chmod(tmpPath, 0o700);
127
+ await chmod(stateLibrary, 0o700);
128
+
129
+ // `/usr/bin/security` resolves the default login keychain relative to HOME.
130
+ // Keep HOME isolated while pointing that one standard location at the
131
+ // operator's encrypted keychain database. Seatbelt admits the keychain
132
+ // directory read-only; no bearer credential is copied into agent state.
133
+ const keychainLink = await statOrNull(stateKeychains);
134
+ if (keychainLink && !keychainLink.isSymbolicLink()) {
135
+ throw new Error(`refusing to replace non-symlink Claude keychain path: ${stateKeychains}`);
136
+ }
137
+ if (keychainLink) {
138
+ const target = await readlink(stateKeychains);
139
+ if (target !== operatorKeychains) {
140
+ await unlink(stateKeychains);
141
+ await symlink(operatorKeychains, stateKeychains);
142
+ }
143
+ } else {
144
+ await symlink(operatorKeychains, stateKeychains);
145
+ }
146
+
147
+ const operatorConfigPath = join(homedir(), '.claude.json');
148
+ const sourceStat = await statOrNull(operatorConfigPath);
149
+ let source = {};
150
+ if (sourceStat) {
151
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
152
+ throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`);
153
+ }
154
+ try {
155
+ source = JSON.parse(await readFile(operatorConfigPath, 'utf8'));
156
+ } catch (err) {
157
+ throw new Error(`failed to read operator Claude auth metadata: ${err.message}`);
158
+ }
159
+ }
160
+
161
+ const sanitized = {
162
+ hasCompletedOnboarding: true,
163
+ };
164
+ for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) {
165
+ if (source[key] !== undefined) sanitized[key] = source[key];
166
+ }
167
+ await writeFile(
168
+ configPath,
169
+ `${JSON.stringify(sanitized, null, 2)}\n`,
170
+ { encoding: 'utf8', mode: 0o600 },
171
+ );
172
+ await chmod(configPath, 0o600);
173
+ return { statePath, tmpPath, configPath };
174
+ };
175
+
176
+ const buildClaudeEnv = (input, state, expansionEnv = {}) => {
177
+ const source = input || process.env;
178
+ const output = state ? {} : { ...source };
179
+ if (state) {
180
+ for (const [key, value] of Object.entries(source)) {
181
+ if (PUBLIC_SAFE_ENV.test(key)) output[key] = value;
182
+ }
183
+ output.HOME = state.statePath;
184
+ output.TMPDIR = state.tmpPath;
185
+ output.XDG_CACHE_HOME = join(state.statePath, '.cache');
186
+ output.XDG_CONFIG_HOME = join(state.statePath, '.config');
187
+ output.XDG_DATA_HOME = join(state.statePath, '.local', 'share');
188
+ }
189
+ Object.assign(output, expansionEnv);
190
+ return output;
191
+ };
192
+
193
+ const absoluteToolDeny = (path) => `Read(/${path}/**)`;
194
+
195
+ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
196
+ const home = homedir();
197
+ const deniedReads = [
198
+ absoluteToolDeny(join(home, '.commonly')),
199
+ absoluteToolDeny(join(home, '.claude')),
200
+ absoluteToolDeny(join(home, '.codex')),
201
+ absoluteToolDeny(join(home, '.ssh')),
202
+ absoluteToolDeny(join(home, '.aws')),
203
+ absoluteToolDeny(join(home, '.config')),
204
+ 'Read(//private/tmp/**)',
205
+ 'Read(./.commonly/**)',
206
+ 'Read(./.codex/**)',
207
+ 'Read(./.env)',
208
+ ];
209
+ return [
210
+ // HOME points at an isolated, sanitized state directory. Ignore settings
211
+ // from user/project/local sources and disable slash-command extensions;
212
+ // unlike --safe-mode, this preserves the explicit --mcp-config capability.
213
+ '--setting-sources', '',
214
+ '--disable-slash-commands',
215
+ '--strict-mcp-config',
216
+ '--no-chrome',
217
+ '--permission-mode', 'dontAsk',
218
+ '--allowedTools', 'Read(./**)', ...mcpToolPatterns,
219
+ '--disallowedTools', ...PUBLIC_DENIED_TOOLS, ...deniedReads,
220
+ ];
221
+ };
222
+
64
223
  const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
65
- const proc = spawnImpl(cmd, args, { cwd, env });
224
+ const proc = spawnImpl(cmd, args, {
225
+ cwd,
226
+ env,
227
+ stdio: ['ignore', 'pipe', 'pipe'],
228
+ });
66
229
  let stdout = '';
67
230
  let stderr = '';
68
231
  let timedOut = false;
@@ -88,70 +251,80 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
88
251
 
89
252
  // ── MCP config write — claude consumes this via --mcp-config <path> ─────────
90
253
 
91
- // Substitute Commonly-supplied placeholders in MCP env values, command args,
92
- // and URLs so users don't have to hand-paste secrets into their env file
93
- // every time they re-attach. Surfaced during the 2026-04-17 cross-agent demo:
94
- // every spec referencing commonly-mcp had to be rewritten with the agent's
95
- // runtime token after attach, because the token is minted at attach time and
96
- // only known to the wrapper.
254
+ // Keep Commonly placeholders in the MCP JSON and expose their values only in
255
+ // Claude's per-spawn environment. Claude Code natively expands ${VAR} in MCP
256
+ // command/args/env/url fields. Substituting here used to materialize the raw
257
+ // cm_agent_* bearer token in a transient JSON file, which made the token
258
+ // readable to any co-confined child allowed to read that config directory.
97
259
  //
98
- // Recognised placeholders (substituted everywhere a string appears in the
99
- // MCP config):
260
+ // Recognised placeholders:
100
261
  // ${COMMONLY_AGENT_TOKEN} — the per-(agent, pod) cm_agent_* runtime token
101
262
  // ${COMMONLY_API_URL} — the instance URL the agent is attached to
102
263
  // ${COMMONLY_INSTANCE_URL} — alias for COMMONLY_API_URL (clearer in context)
103
264
  //
104
- // Substitution is one-pass + literal no nested expansion, no shell quoting.
105
- // Unknown placeholders are left intact so the user sees a clear runtime error
106
- // from the MCP server rather than a silent empty string.
107
- const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
108
- const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
109
-
110
- const substitutePlaceholders = (value, ctx) => {
111
- if (typeof value !== 'string') return value;
112
- if (!value.includes('${COMMONLY_')) return value;
113
- const subs = {
265
+ // Only values actually referenced by this MCP declaration are added to the
266
+ // child environment. Unknown placeholders remain in JSON so Claude's parser
267
+ // fails clearly instead of receiving a silent empty string.
268
+ const buildMcpExpansionEnv = (mcpConfig, ctx) => {
269
+ const serialized = JSON.stringify(mcpConfig);
270
+ const values = {
114
271
  COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
115
272
  COMMONLY_API_URL: ctx.instanceUrl || '',
116
273
  COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
117
274
  };
118
- return value.replace(PLACEHOLDER_RE, (whole, key) => (
119
- SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole
120
- ));
275
+ const output = {};
276
+ for (const [key, value] of Object.entries(values)) {
277
+ if (value && serialized.includes(`\${${key}}`)) output[key] = value;
278
+ }
279
+ return output;
121
280
  };
122
281
 
123
- const buildMcpConfig = (mcpServers, ctx = {}) => {
282
+ const buildMcpConfig = (mcpServers) => {
124
283
  // Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
125
284
  // config, which claude's `--mcp-config` reads directly.
126
285
  const mcpServersMap = {};
127
286
  for (const server of mcpServers) {
128
287
  const entry = { type: server.transport || 'stdio' };
129
- if (server.url) entry.url = substitutePlaceholders(server.url, ctx);
288
+ if (server.url) entry.url = server.url;
130
289
  if (server.command) {
131
290
  const [command, ...args] = server.command;
132
291
  entry.command = command;
133
- if (args.length) entry.args = args.map((a) => substitutePlaceholders(a, ctx));
134
- }
135
- if (server.env) {
136
- entry.env = {};
137
- for (const [k, v] of Object.entries(server.env)) {
138
- entry.env[k] = substitutePlaceholders(v, ctx);
139
- }
292
+ if (args.length) entry.args = args;
140
293
  }
294
+ if (server.env) entry.env = { ...server.env };
141
295
  mcpServersMap[server.name] = entry;
142
296
  }
143
297
  return { mcpServers: mcpServersMap };
144
298
  };
145
299
 
146
- // Regenerated on every spawn from the env spec; do not hand-edit — the file
147
- // is overwritten before each `claude` invocation, so any local changes are
148
- // silently clobbered. ADR-008 §invariant #5 (edits propagate on next spawn).
149
- const writeMcpConfig = async (cwd, mcpServers, ctx = {}) => {
150
- const dir = join(cwd, '.commonly');
151
- await mkdir(dir, { recursive: true });
300
+ // Materialized once per spawn outside the workspace, then removed in the
301
+ // spawn's finally block. The JSON contains placeholders, never resolved
302
+ // Commonly credentials; Claude expands them from its one-run environment.
303
+ // Directory/file modes stay strict because this config still declares the
304
+ // agent's trusted MCP capabilities and endpoints.
305
+ const createMcpConfig = async (mcpServers, ctx = {}) => {
306
+ const dir = await mkdtemp(join(tmpdir(), 'commonly-claude-mcp-'));
152
307
  const file = join(dir, 'mcp-config.json');
153
- await writeFile(file, JSON.stringify(buildMcpConfig(mcpServers, ctx), null, 2), 'utf8');
154
- return file;
308
+ const config = buildMcpConfig(mcpServers);
309
+ try {
310
+ await chmod(dir, 0o700);
311
+ await writeFile(
312
+ file,
313
+ JSON.stringify(config, null, 2),
314
+ { encoding: 'utf8', mode: 0o600 },
315
+ );
316
+ // writeFile's mode only applies when creating. Pin the final mode too so
317
+ // this stays correct if the implementation ever starts reusing the path.
318
+ await chmod(file, 0o600);
319
+ return {
320
+ dir,
321
+ file,
322
+ expansionEnv: buildMcpExpansionEnv(config, ctx),
323
+ };
324
+ } catch (err) {
325
+ try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
326
+ throw err;
327
+ }
155
328
  };
156
329
 
157
330
  // ── argv preparation — environment-aware ────────────────────────────────────
@@ -169,23 +342,29 @@ const resolveClaudePath = () => {
169
342
  try { which = spawnSync('which', ['claude'], { encoding: 'utf8' }); } catch { /* ignore */ }
170
343
  if (which && which.status === 0) {
171
344
  const p = (which.stdout || '').trim();
172
- if (p) return p;
345
+ if (p) {
346
+ try {
347
+ return realpathSync(p);
348
+ } catch {
349
+ return p;
350
+ }
351
+ }
173
352
  }
174
353
  return 'claude';
175
354
  };
176
355
 
177
356
  const prepareArgv = async (innerArgv, ctx) => {
178
357
  const env = ctx.environment;
179
- if (!env) return { cmd: 'claude', args: innerArgv };
358
+ if (!env) return { cmd: 'claude', args: innerArgv, env: ctx.claudeEnv };
180
359
 
181
- if (Array.isArray(env.mcp) && env.mcp.length > 0 && ctx.cwd) {
182
- const configPath = await writeMcpConfig(ctx.cwd, env.mcp, {
183
- runtimeToken: ctx.runtimeToken,
184
- instanceUrl: ctx.instanceUrl,
185
- });
360
+ let allowedPatterns = [];
361
+ if (Array.isArray(env.mcp) && env.mcp.length > 0) {
362
+ if (!ctx.mcpConfigPath) {
363
+ throw new Error('claude MCP config was declared but no per-spawn config path was prepared');
364
+ }
186
365
  // Insert --mcp-config immediately after the subcommand-style `-p` block
187
366
  // so claude parses it before prompt collection begins.
188
- innerArgv = [...innerArgv, '--mcp-config', configPath];
367
+ innerArgv = [...innerArgv, '--mcp-config', ctx.mcpConfigPath];
189
368
  // Pre-approve every MCP tool from the declared servers (`mcp__<name>__*`).
190
369
  // Without this claude runs in non-interactive `-p` mode and asks for
191
370
  // permission before invoking any MCP tool — the wrapper has no way to
@@ -193,25 +372,65 @@ const prepareArgv = async (innerArgv, ctx) => {
193
372
  // already opted into these servers by declaring them in the env spec, so
194
373
  // auto-allowing the corresponding tool prefix is the honest default.
195
374
  // Surfaced live during the 2026-04-17 cross-agent demo validation.
196
- const allowedPatterns = env.mcp
375
+ allowedPatterns = env.mcp
197
376
  .map((server) => server && server.name)
198
377
  .filter(Boolean)
199
378
  .map((name) => `mcp__${name}__*`);
200
- if (allowedPatterns.length > 0) {
201
- innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
202
- }
203
379
  }
204
380
 
205
381
  const sandboxMode = env.sandbox?.mode;
382
+ const sandboxTrust = env.sandbox?.trust;
383
+ const publicNativeSandbox = sandboxTrust === 'public'
384
+ && PUBLIC_SANDBOX_MODES.has(sandboxMode);
385
+ if (sandboxTrust === 'public' && sandboxMode === 'none') {
386
+ throw new Error('public Claude agents require an enforced sandbox mode');
387
+ }
388
+ if (publicNativeSandbox) {
389
+ if (process.platform !== 'darwin') {
390
+ throw new Error(
391
+ `public Claude sandbox.mode=${sandboxMode} maps to Seatbelt on macOS; `
392
+ + 'use sandbox.mode=bwrap on Linux',
393
+ );
394
+ }
395
+ if (!ctx.publicClaudeState) {
396
+ throw new Error('public Claude state was not prepared before argv construction');
397
+ }
398
+ innerArgv = [
399
+ ...innerArgv,
400
+ ...buildPublicClaudePolicyArgs(allowedPatterns),
401
+ ];
402
+ const claudeBin = resolveClaudePath();
403
+ const mcpExecutables = (env.mcp || [])
404
+ .map((server) => server?.command?.[0])
405
+ .filter((command) => isAbsolute(command));
406
+ const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], {
407
+ workspacePath: ctx.cwd,
408
+ workspaceAccess: sandboxMode === 'read-only' ? 'read' : 'write',
409
+ claudePath: claudeBin,
410
+ statePath: ctx.publicClaudeState.statePath,
411
+ mcpConfigDir: ctx.mcpConfigDir,
412
+ executablePaths: mcpExecutables,
413
+ });
414
+ return {
415
+ cmd: wrapped[0],
416
+ args: wrapped.slice(1),
417
+ env: ctx.claudeEnv,
418
+ };
419
+ }
420
+
421
+ if (allowedPatterns.length > 0) {
422
+ innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
423
+ }
206
424
  if (sandboxMode === 'bwrap') {
207
425
  const claudeBin = resolveClaudePath();
208
426
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
209
427
  workspacePath: ctx.cwd,
428
+ readOnlyPaths: ctx.mcpConfigDir ? [ctx.mcpConfigDir] : [],
210
429
  });
211
- return { cmd: wrapped[0], args: wrapped.slice(1) };
430
+ return { cmd: wrapped[0], args: wrapped.slice(1), env: ctx.claudeEnv };
212
431
  }
213
432
 
214
- return { cmd: 'claude', args: innerArgv };
433
+ return { cmd: 'claude', args: innerArgv, env: ctx.claudeEnv };
215
434
  };
216
435
 
217
436
  export default {
@@ -259,14 +478,37 @@ export default {
259
478
  if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills);
260
479
  }
261
480
 
262
- const { cmd, args } = await prepareArgv(baseArgs, ctx);
263
-
481
+ let mcpConfig = null;
482
+ let publicClaudeState = null;
264
483
  try {
484
+ const publicNativeSandbox = ctx.environment?.sandbox?.trust === 'public'
485
+ && PUBLIC_SANDBOX_MODES.has(ctx.environment?.sandbox?.mode);
486
+ if (publicNativeSandbox) {
487
+ publicClaudeState = await preparePublicClaudeState(ctx);
488
+ }
489
+ if (Array.isArray(ctx.environment?.mcp) && ctx.environment.mcp.length > 0) {
490
+ mcpConfig = await createMcpConfig(ctx.environment.mcp, {
491
+ runtimeToken: ctx.runtimeToken,
492
+ instanceUrl: ctx.instanceUrl,
493
+ });
494
+ }
495
+ const spawnCtx = {
496
+ ...ctx,
497
+ mcpConfigPath: mcpConfig?.file || null,
498
+ mcpConfigDir: mcpConfig?.dir || null,
499
+ publicClaudeState,
500
+ claudeEnv: buildClaudeEnv(
501
+ ctx.env,
502
+ publicClaudeState,
503
+ mcpConfig?.expansionEnv,
504
+ ),
505
+ };
506
+ const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx);
265
507
  const stdout = await runClaude({
266
508
  cmd,
267
509
  args,
268
510
  cwd: ctx.cwd,
269
- env: ctx.env,
511
+ env,
270
512
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
271
513
  spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
272
514
  });
@@ -279,18 +521,32 @@ export default {
279
521
  if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
280
522
  const freshId = randomUUID();
281
523
  const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
282
- const retry = await prepareArgv(retryBase, ctx);
524
+ const retry = await prepareArgv(retryBase, {
525
+ ...ctx,
526
+ mcpConfigPath: mcpConfig?.file || null,
527
+ mcpConfigDir: mcpConfig?.dir || null,
528
+ publicClaudeState,
529
+ claudeEnv: buildClaudeEnv(
530
+ ctx.env,
531
+ publicClaudeState,
532
+ mcpConfig?.expansionEnv,
533
+ ),
534
+ });
283
535
  const stdout = await runClaude({
284
536
  cmd: retry.cmd,
285
537
  args: retry.args,
286
538
  cwd: ctx.cwd,
287
- env: ctx.env,
539
+ env: retry.env,
288
540
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
289
541
  spawnImpl: ctx._spawnImpl,
290
542
  });
291
543
  return { text: stdout.trim(), newSessionId: freshId };
292
544
  }
293
545
  throw err;
546
+ } finally {
547
+ if (mcpConfig?.dir) {
548
+ try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
549
+ }
294
550
  }
295
551
  },
296
552
  };