@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.
- package/package.json +2 -2
- package/src/commands/agent.js +122 -32
- package/src/lib/adapters/claude.js +320 -64
- package/src/lib/adapters/codex.js +234 -11
- package/src/lib/environment.js +10 -2
- package/src/lib/sandbox/bwrap.js +11 -0
- package/src/lib/sandbox/seatbelt.js +417 -0
- package/src/lib/session-store.js +63 -0
|
@@ -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
|
|
12
|
-
* and wraps the argv with bwrap when
|
|
13
|
-
* binary becomes `bwrap` in that case —
|
|
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 {
|
|
43
|
-
import {
|
|
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, {
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
|
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
|
-
//
|
|
105
|
-
// Unknown placeholders
|
|
106
|
-
//
|
|
107
|
-
const
|
|
108
|
-
const
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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)
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
};
|