@commonlyai/cli 0.1.2 → 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 +1 -1
- package/src/commands/agent.js +59 -4
- package/src/lib/adapters/claude.js +320 -64
- package/src/lib/adapters/codex.js +185 -21
- package/src/lib/environment.js +10 -2
- package/src/lib/sandbox/bwrap.js +11 -0
- package/src/lib/sandbox/seatbelt.js +417 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -33,6 +33,7 @@ import { detectMemorySources, composeImport, importMemory } from '../lib/memory-
|
|
|
33
33
|
import { detectSkills, importSkills } from '../lib/skills-import.js';
|
|
34
34
|
import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
|
|
35
35
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
36
|
+
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
36
37
|
|
|
37
38
|
// ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
|
|
38
39
|
|
|
@@ -126,6 +127,21 @@ const PROMPT_EVENT_TYPES = new Set([
|
|
|
126
127
|
// commonly_* tools of its own). `stub` does not. Returning null means "no
|
|
127
128
|
// default" — the wrapper proceeds with environment=null exactly like before.
|
|
128
129
|
const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex']);
|
|
130
|
+
const CODEX_PERMISSION_PROFILE_MIN_VERSION = [0, 138, 0];
|
|
131
|
+
|
|
132
|
+
const versionAtLeast = (version, minimum) => {
|
|
133
|
+
const parts = String(version || '')
|
|
134
|
+
.match(/\d+(?:\.\d+){1,2}/)?.[0]
|
|
135
|
+
.split('.')
|
|
136
|
+
.map(Number);
|
|
137
|
+
if (!parts) return false;
|
|
138
|
+
for (let i = 0; i < minimum.length; i += 1) {
|
|
139
|
+
const current = parts[i] || 0;
|
|
140
|
+
if (current > minimum[i]) return true;
|
|
141
|
+
if (current < minimum[i]) return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
};
|
|
129
145
|
|
|
130
146
|
// The commonly behavior skill ships inside this package (cli/skills/commonly)
|
|
131
147
|
// so a fresh attach can mount it into the agent without a network fetch.
|
|
@@ -205,6 +221,12 @@ export const performAttach = async ({
|
|
|
205
221
|
log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
|
|
206
222
|
|
|
207
223
|
const sandboxMode = environment.sandbox?.mode || 'none';
|
|
224
|
+
const sandboxTrust = environment.sandbox?.trust;
|
|
225
|
+
if (sandboxTrust === 'public' && sandboxMode === 'none') {
|
|
226
|
+
throw new Error(
|
|
227
|
+
'sandbox.trust=public requires an enforced sandbox mode; refusing to attach unsandboxed',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
208
230
|
if (sandboxMode === 'bwrap') {
|
|
209
231
|
const bwrap = detectBwrap();
|
|
210
232
|
if (!bwrap.available) {
|
|
@@ -216,10 +238,41 @@ export const performAttach = async ({
|
|
|
216
238
|
+ 'are advisory in Phase 1. Use sandbox.mode=container for enforced policy.',
|
|
217
239
|
);
|
|
218
240
|
}
|
|
241
|
+
} else if (sandboxMode === 'workspace' || sandboxMode === 'read-only') {
|
|
242
|
+
if (sandboxTrust !== 'public' || !['codex', 'claude'].includes(adapterName)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`sandbox.mode=${sandboxMode} is currently implemented only for public `
|
|
245
|
+
+ `codex or Claude adapters`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (adapterName === 'codex') {
|
|
249
|
+
if (!versionAtLeast(detected.version, CODEX_PERMISSION_PROFILE_MIN_VERSION)) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`public codex sandbox requires Codex >=0.138.0 permission profiles; `
|
|
252
|
+
+ `detected ${detected.version || 'unknown'}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
log(
|
|
256
|
+
`sandbox: public ${sandboxMode} via Codex native permission profile `
|
|
257
|
+
+ '(deny-by-default filesystem + network)',
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
const seatbelt = detectSeatbelt();
|
|
261
|
+
if (!seatbelt.available) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`public Claude sandbox.mode=${sandboxMode} requires macOS Seatbelt: `
|
|
264
|
+
+ `${seatbelt.error}. On Linux use sandbox.mode=bwrap.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
log(
|
|
268
|
+
`sandbox: public ${sandboxMode} via macOS Seatbelt `
|
|
269
|
+
+ '(deny-by-default host filesystem; Claude/MCP network retained)',
|
|
270
|
+
);
|
|
271
|
+
}
|
|
219
272
|
} else if (sandboxMode !== 'none' && sandboxMode !== undefined) {
|
|
220
273
|
throw new Error(
|
|
221
274
|
`sandbox.mode=${sandboxMode} is not yet implemented in the local-CLI driver. `
|
|
222
|
-
+ `
|
|
275
|
+
+ `Supported locally: none, bwrap, and public codex/Claude workspace/read-only.`,
|
|
223
276
|
);
|
|
224
277
|
}
|
|
225
278
|
} else {
|
|
@@ -474,11 +527,13 @@ export const performRun = ({
|
|
|
474
527
|
env: process.env,
|
|
475
528
|
memoryLongTerm,
|
|
476
529
|
environment,
|
|
477
|
-
// Runtime context the
|
|
478
|
-
//
|
|
479
|
-
//
|
|
530
|
+
// Runtime context the Claude/Codex adapters expose only to their
|
|
531
|
+
// per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders
|
|
532
|
+
// literal on disk and lets its native MCP parser expand them, so the
|
|
533
|
+
// bearer token never enters the generated config file.
|
|
480
534
|
runtimeToken: token,
|
|
481
535
|
instanceUrl,
|
|
536
|
+
agentName,
|
|
482
537
|
metadata: { event },
|
|
483
538
|
});
|
|
484
539
|
|
|
@@ -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
|
};
|