@ravefox/space 0.2.1 → 0.4.0
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/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
|
@@ -1,2207 +0,0 @@
|
|
|
1
|
-
import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
|
-
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
|
-
import {
|
|
4
|
-
firstOutputWindowMs, killStallSurvivors, physicalPath, spawnStalledError, STALL_KILL_GRACE_MS, watchFirstOutput,
|
|
5
|
-
} from '../first-output-watchdog.js';
|
|
6
|
-
import {
|
|
7
|
-
descendantsOf, killProcessTree, readProcessTable, spawnInOwnGroup, wantsOwnProcessGroup,
|
|
8
|
-
} from '@commonlyai/seats/process-group';
|
|
9
|
-
import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/turn-processes';
|
|
10
|
-
/**
|
|
11
|
-
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
12
|
-
*
|
|
13
|
-
* Contract: ADR-005 §Adapter pattern.
|
|
14
|
-
*
|
|
15
|
-
* Memory preamble: the adapter prepends the kernel's long-term memory context
|
|
16
|
-
* on every turn. A fresh underlying session additionally receives the
|
|
17
|
-
* read-first and durable-state-at-boundary cues (§Memory bridge).
|
|
18
|
-
*
|
|
19
|
-
* Environment (ADR-008 Phase 1): if ctx.environment is present, the adapter
|
|
20
|
-
* symlinks declared Claude skills into `<cwd>/.claude/skills/`, writes an MCP
|
|
21
|
-
* config file into a mode-0700 per-spawn temp directory outside the workspace
|
|
22
|
-
* when `mcp` is declared, and wraps the argv with bwrap when
|
|
23
|
-
* `sandbox.mode === 'bwrap'`. The spawn binary becomes `bwrap` in that case —
|
|
24
|
-
* claude moves to the inner argv.
|
|
25
|
-
* A public `workspace` / `read-only` sandbox maps to an outer deny-default
|
|
26
|
-
* Seatbelt profile on macOS. Claude gets an isolated HOME, a credential-starved
|
|
27
|
-
* child environment, and an explicit tool allow/deny policy; Claude and every
|
|
28
|
-
* declared MCP child inherit the same kernel filesystem boundary.
|
|
29
|
-
* When ctx.environment is absent, behaviour is identical to pre-ADR-008.
|
|
30
|
-
*
|
|
31
|
-
* Session continuity (IMPORTANT — the two claude flags are not interchangeable):
|
|
32
|
-
* - First turn (no persisted id): mint a UUID and pass `--session-id <uuid>`.
|
|
33
|
-
* claude treats `--session-id` as "CREATE a session with this exact UUID"
|
|
34
|
-
* and rejects with "Session ID ... is already in use" if the UUID was
|
|
35
|
-
* already used.
|
|
36
|
-
* - Subsequent turns (persisted id present): pass `--resume <uuid>` instead.
|
|
37
|
-
* `--resume` means "continue this existing session."
|
|
38
|
-
* The wrapper persists the UUID so the SAME id is used across turns — this
|
|
39
|
-
* adapter just picks the right flag for it.
|
|
40
|
-
*
|
|
41
|
-
* Purity (§Load-bearing invariants #1): input = argv + env + prompt;
|
|
42
|
-
* output = text + session id. No direct network, no direct CAP calls.
|
|
43
|
-
*
|
|
44
|
-
* Test seam: `ctx._spawnImpl` is the sanctioned way for any adapter in this
|
|
45
|
-
* codebase to swap `child_process.spawn` out for a mock. Unit tests pass a
|
|
46
|
-
* fake that returns an EventEmitter; production never sets `_spawnImpl` so
|
|
47
|
-
* the default `childSpawn` is used. Future adapters should follow the same
|
|
48
|
-
* convention rather than inventing their own seam.
|
|
49
|
-
*
|
|
50
|
-
* Timeout caveat: we SIGTERM at `timeoutMs` and wait for `close`. If claude
|
|
51
|
-
* ignores SIGTERM the promise never resolves — the run loop stalls silently.
|
|
52
|
-
* SIGKILL escalation is a post-v1 concern (ADR-005 §Spawning semantics).
|
|
53
|
-
*/
|
|
54
|
-
|
|
55
|
-
import { requireSeatModel, seatModelRequired } from './seat-model.js';
|
|
56
|
-
import { claudeRunStats } from '../reply-receipt.js';
|
|
57
|
-
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
58
|
-
import {
|
|
59
|
-
createHash, randomBytes, randomUUID, timingSafeEqual,
|
|
60
|
-
} from 'crypto';
|
|
61
|
-
import { accessSync, constants, realpathSync } from 'fs';
|
|
62
|
-
import {
|
|
63
|
-
chmod,
|
|
64
|
-
lstat,
|
|
65
|
-
mkdir,
|
|
66
|
-
mkdtemp,
|
|
67
|
-
open,
|
|
68
|
-
readFile,
|
|
69
|
-
readdir,
|
|
70
|
-
readlink,
|
|
71
|
-
rename,
|
|
72
|
-
rm,
|
|
73
|
-
rmdir,
|
|
74
|
-
symlink,
|
|
75
|
-
unlink,
|
|
76
|
-
writeFile,
|
|
77
|
-
} from 'fs/promises';
|
|
78
|
-
import { createServer } from 'net';
|
|
79
|
-
import { homedir, tmpdir } from 'os';
|
|
80
|
-
import {
|
|
81
|
-
delimiter, dirname, isAbsolute, join,
|
|
82
|
-
} from 'path';
|
|
83
|
-
|
|
84
|
-
import {
|
|
85
|
-
isLegacySandboxTrust,
|
|
86
|
-
mountSkills,
|
|
87
|
-
normalizeSandboxTrust,
|
|
88
|
-
} from '../environment.js';
|
|
89
|
-
import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
|
|
90
|
-
import { refuseWorkspaceHoldingTurnDirs } from '../sandbox/turn-dir.js';
|
|
91
|
-
import { holdWorkspaceRealpath, workspacePinDir } from '../sandbox/workspace-pin.js';
|
|
92
|
-
import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from '../sandbox/mode.js';
|
|
93
|
-
import { UNSANDBOXED_FLAG } from '../sandbox/host-check.js';
|
|
94
|
-
import {
|
|
95
|
-
publicClaudeStateRoot,
|
|
96
|
-
wrapArgvWithSeatbelt,
|
|
97
|
-
} from '../sandbox/seatbelt.js';
|
|
98
|
-
import {
|
|
99
|
-
CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, armCredentialRelease, restoreCredentialFile, writeCredentialFile,
|
|
100
|
-
} from '../credential-file.js';
|
|
101
|
-
import { MCP_READY_FILE_ENV, commonlyMcpReadyFile } from '../mcp-ready.js';
|
|
102
|
-
import { supportedGitHooks } from '../commit-attribution.js';
|
|
103
|
-
import { workspaceGitAttachment, workspaceGitEnabled } from '../workspace-git.js';
|
|
104
|
-
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
105
|
-
import {
|
|
106
|
-
prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
|
|
107
|
-
} from '../mcp-home.js';
|
|
108
|
-
import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
109
|
-
import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
|
|
110
|
-
import { skillsAsText } from '../skill-prompt.js';
|
|
111
|
-
import { adapterFailure, redactKnownCredentials, spawnCredentials } from '../upstream-refusal.js';
|
|
112
|
-
import { PUBLIC_WITHHELD_TOOLS, publicSeatWithheldTools } from '../public-withheld-tools.js';
|
|
113
|
-
import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
|
|
114
|
-
import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
|
|
115
|
-
import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
|
|
116
|
-
import { startProviderProxy } from '../provider-proxy.js';
|
|
117
|
-
import { CLI } from '../cli-hint.js';
|
|
118
|
-
|
|
119
|
-
// See codex.js for the rationale on bumping the default + env override.
|
|
120
|
-
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
121
|
-
// same timeout posture regardless of which CLI is underneath.
|
|
122
|
-
const DEFAULT_TIMEOUT_MS = (() => {
|
|
123
|
-
const fallback = 15 * 60 * 1000;
|
|
124
|
-
const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
|
|
125
|
-
if (!raw) return fallback;
|
|
126
|
-
const parsed = Number(raw);
|
|
127
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
128
|
-
})();
|
|
129
|
-
|
|
130
|
-
const buildPrompt = buildMemoryPreamble;
|
|
131
|
-
|
|
132
|
-
// The working habits a private claude seat is taught on a fresh session
|
|
133
|
-
// (TASK-545). Parallel reads live here because the cue names this CLI's Agent
|
|
134
|
-
// tool; codex is passed row slicing and the background-process cue. A public
|
|
135
|
-
// seat is passed none: its argv allows Read and the MCP under dontAsk, so it
|
|
136
|
-
// has no subagent to spawn or shell to background from, and it answers
|
|
137
|
-
// strangers rather than working board rows.
|
|
138
|
-
const PARALLEL_READS_CUE = 'Parallel reads: when a row needs several files or sources read, '
|
|
139
|
-
+ 'use the Agent tool. Spawn read-only subagents in parallel, brief each one with the files '
|
|
140
|
-
+ 'to read and the question to answer, and keep only their conclusions.';
|
|
141
|
-
const WORK_CUES = [PARALLEL_READS_CUE, ROW_SLICING_CUE, BACKGROUND_PROCESS_CUE];
|
|
142
|
-
const RUNTIME_CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
|
|
143
|
-
|
|
144
|
-
const QUOTE_SHELL_ARGUMENT = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
145
|
-
|
|
146
|
-
// The helper takes the token from one of two places. With a socket path and a
|
|
147
|
-
// key file as its second and third arguments (a public seat, F461), it sends
|
|
148
|
-
// the key and the adapter, which holds the token in memory for the turn,
|
|
149
|
-
// answers with the token; the credential file can then go once the stdio
|
|
150
|
-
// servers have read it. Otherwise it reads the per-spawn file.
|
|
151
|
-
const HEADER_TOKEN_HELPER_SOURCE = `import { readFileSync } from 'node:fs';
|
|
152
|
-
import { createConnection } from 'node:net';
|
|
153
|
-
|
|
154
|
-
const fromChannel = (path, key) => new Promise((resolve, reject) => {
|
|
155
|
-
const chunks = [];
|
|
156
|
-
const socket = createConnection({ path }, () => socket.write(key + '\\n'));
|
|
157
|
-
socket.setTimeout(10000, () => socket.destroy(new Error('the header credential channel did not answer')));
|
|
158
|
-
socket.on('data', (chunk) => chunks.push(chunk));
|
|
159
|
-
socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
160
|
-
socket.on('error', reject);
|
|
161
|
-
});
|
|
162
|
-
const channel = process.argv[3];
|
|
163
|
-
let token;
|
|
164
|
-
if (channel) {
|
|
165
|
-
token = (await fromChannel(channel, readFileSync(process.argv[4], 'utf8').trim())).trim();
|
|
166
|
-
if (!token) throw new Error('the header credential channel sent nothing');
|
|
167
|
-
} else {
|
|
168
|
-
const credentialFile = process.env.${CREDENTIAL_FILE_VAR};
|
|
169
|
-
if (!credentialFile) throw new Error('missing ${CREDENTIAL_FILE_VAR}');
|
|
170
|
-
token = readFileSync(credentialFile, 'utf8').trim();
|
|
171
|
-
if (!token) throw new Error('empty ${CREDENTIAL_FILE_VAR}');
|
|
172
|
-
}
|
|
173
|
-
const templates = JSON.parse(readFileSync(process.argv[2], 'utf8'));
|
|
174
|
-
const headers = Object.fromEntries(
|
|
175
|
-
Object.entries(templates).map(([name, parts]) => [name, parts.join(token)]),
|
|
176
|
-
);
|
|
177
|
-
process.stdout.write(JSON.stringify(headers) + '\\n');
|
|
178
|
-
`;
|
|
179
|
-
|
|
180
|
-
const PUBLIC_DENIED_TOOLS = [
|
|
181
|
-
'WebSearch',
|
|
182
|
-
'WebFetch',
|
|
183
|
-
'Bash',
|
|
184
|
-
// Claude Code's shell tool on Windows. dontAsk and the Read(./**) allowlist
|
|
185
|
-
// already refuse it; named here so the deny list holds on its own (D245).
|
|
186
|
-
'PowerShell',
|
|
187
|
-
'Write',
|
|
188
|
-
'Edit',
|
|
189
|
-
'NotebookEdit',
|
|
190
|
-
'Task',
|
|
191
|
-
];
|
|
192
|
-
// TASK-294: a workspace seat is confined as a public one is, except that its
|
|
193
|
-
// folder is the person's project, which the jail holds read-only unless the
|
|
194
|
-
// seat was granted write, and whose .git it holds read-only either way.
|
|
195
|
-
const isConfinedTrust = (trust) => trust === 'public' || trust === 'workspace';
|
|
196
|
-
// What each `sandbox.allow` grant moves out of the denies. `web` grants
|
|
197
|
-
// WebFetch alone: WebSearch, NotebookEdit and Task stay denied at every tier.
|
|
198
|
-
// Bash and Write land wherever the jail lets them, so the jail, not this list,
|
|
199
|
-
// bounds a write.
|
|
200
|
-
const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
201
|
-
const WORKSPACE_ALLOW_TOOLS = {
|
|
202
|
-
write: ['Edit', 'Write', 'Bash'],
|
|
203
|
-
web: ['WebFetch'],
|
|
204
|
-
};
|
|
205
|
-
// The grants a seat's environment carries. They widen the workspace tier
|
|
206
|
-
// only, so any other trust gets none, whatever its record says.
|
|
207
|
-
const workspaceGrants = (sandbox) => (
|
|
208
|
-
sandbox?.trust === 'workspace' && Array.isArray(sandbox.allow) ? sandbox.allow : []
|
|
209
|
-
);
|
|
210
|
-
// A workspace seat reads its project's history through one read-only git tool
|
|
211
|
-
// (git-readonly-server.js, attached by workspace-git.js). The server runs
|
|
212
|
-
// inside the jail, so the jail admits its code and the real git binary. The
|
|
213
|
-
// tool is opt-in (workspaceGitEnabled): without it the seat simply has none.
|
|
214
|
-
const withWorkspaceGit = (ctx) => {
|
|
215
|
-
if (ctx.environment?.sandbox?.trust !== 'workspace' || !ctx.cwd) return ctx;
|
|
216
|
-
if (!workspaceGitEnabled()) return ctx;
|
|
217
|
-
const attachment = workspaceGitAttachment(ctx.cwd);
|
|
218
|
-
if (!attachment) {
|
|
219
|
-
// eslint-disable-next-line no-console
|
|
220
|
-
console.warn('[claude] git was not found on this machine, so this workspace seat has no git tool');
|
|
221
|
-
return ctx;
|
|
222
|
-
}
|
|
223
|
-
return {
|
|
224
|
-
...ctx,
|
|
225
|
-
environment: { ...ctx.environment, mcp: [...(ctx.environment.mcp || []), attachment.server] },
|
|
226
|
-
workspaceGit: {
|
|
227
|
-
executable: attachment.executable,
|
|
228
|
-
readDirectories: attachment.readDirectories,
|
|
229
|
-
readPaths: attachment.readPaths,
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
};
|
|
233
|
-
const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
|
|
234
|
-
// D245: what a Windows process needs to start at all, find its own login and
|
|
235
|
-
// open a socket. Windows keys are case-insensitive (Path, SystemRoot), so this
|
|
236
|
-
// one is matched without case. None of these carries a secret.
|
|
237
|
-
const PUBLIC_SAFE_ENV_WIN32 = /^(?:PATH|PATHEXT|SYSTEMROOT|SYSTEMDRIVE|WINDIR|COMSPEC|TEMP|TMP|USERPROFILE|USERNAME|USERDOMAIN|HOMEDRIVE|HOMEPATH|APPDATA|LOCALAPPDATA|PROGRAMDATA|PROGRAMFILES|PROGRAMFILES\(X86\)|COMMONPROGRAMFILES|NUMBER_OF_PROCESSORS|PROCESSOR_ARCHITECTURE|OS)$/i;
|
|
238
|
-
|
|
239
|
-
const statOrNull = async (path) => {
|
|
240
|
-
try {
|
|
241
|
-
return await lstat(path);
|
|
242
|
-
} catch (err) {
|
|
243
|
-
if (err?.code === 'ENOENT') return null;
|
|
244
|
-
throw err;
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
// A subfolder of a seat's state, which the confined child can also write: it
|
|
249
|
-
// must be a real directory (created 0700 if absent), never a link the child
|
|
250
|
-
// left. The chmod goes through a handle opened with O_NOFOLLOW|O_DIRECTORY, so
|
|
251
|
-
// a link swapped in after the check (Seatbelt has no PID namespace, and a
|
|
252
|
-
// process a prior turn left can still run) fails the open instead of being
|
|
253
|
-
// followed out of the folder. `_afterCheck` is a test seam for that swap.
|
|
254
|
-
export const ensurePrivateDir = async (dir, { _afterCheck } = {}) => {
|
|
255
|
-
const refuse = () => new Error(`refusing Claude state path that is not a directory: ${dir}`);
|
|
256
|
-
const existing = await statOrNull(dir);
|
|
257
|
-
if (existing && (existing.isSymbolicLink() || !existing.isDirectory())) throw refuse();
|
|
258
|
-
if (!existing) await mkdir(dir, { mode: 0o700 });
|
|
259
|
-
await _afterCheck?.();
|
|
260
|
-
let handle;
|
|
261
|
-
try {
|
|
262
|
-
handle = await open(dir, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY);
|
|
263
|
-
} catch (err) {
|
|
264
|
-
if (['ELOOP', 'ENOTDIR', 'ENOENT'].includes(err?.code)) throw refuse();
|
|
265
|
-
throw err;
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
if (!(await handle.stat()).isDirectory()) throw refuse();
|
|
269
|
-
await handle.chmod(0o700);
|
|
270
|
-
} finally {
|
|
271
|
-
await handle.close();
|
|
272
|
-
}
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
// The seat's state folder itself. A Seatbelt rule that lets the child write
|
|
276
|
-
// below a folder lets it write the folder too, so a child can remove it and
|
|
277
|
-
// leave a link in its place. Every later step of the next turn walks below this
|
|
278
|
-
// path (chmod, tmp, Library, the Keychains link, the auth metadata), so a link
|
|
279
|
-
// here refuses the turn before any of them runs. The parent is made by path;
|
|
280
|
-
// the folder is made and chmodded through a handle that does not follow a link.
|
|
281
|
-
const ensureStateFolder = async (statePath) => {
|
|
282
|
-
await mkdir(dirname(statePath), { recursive: true, mode: 0o700 });
|
|
283
|
-
const existing = await statOrNull(statePath);
|
|
284
|
-
if (existing?.isSymbolicLink()) {
|
|
285
|
-
throw new Error(`refusing Claude state folder that is a symbolic link: ${statePath}. `
|
|
286
|
-
+ 'A confined turn can replace its own state folder; remove the link (it is the seat\'s state, '
|
|
287
|
-
+ 'not the place it points to) and run the turn again, which recreates the folder.');
|
|
288
|
-
}
|
|
289
|
-
await ensurePrivateDir(statePath);
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Removes files the host put in a child-writable folder: the one named, or
|
|
293
|
-
// every name `match` accepts. The folder is checked first, and nothing is
|
|
294
|
-
// removed if the child swapped it for a link (to the person's own ~/.claude,
|
|
295
|
-
// say). The check is a path check, so it holds only while no confined process
|
|
296
|
-
// can swap the folder: on bwrap the folders the host removes from (.claude)
|
|
297
|
-
// are mount points inside every jail of the seat, which the child cannot
|
|
298
|
-
// rename or replace (EBUSY), and between turns no child of the seat runs.
|
|
299
|
-
const removePrivateFile = async (dir, name, { match } = {}) => {
|
|
300
|
-
const dirStat = await statOrNull(dir);
|
|
301
|
-
if (!dirStat) return;
|
|
302
|
-
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
303
|
-
// eslint-disable-next-line no-console
|
|
304
|
-
console.warn(`[claude] not removing ${name}: ${dir} is no longer a directory`);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
const names = match ? (await readdir(dir)).filter(match) : [name];
|
|
308
|
-
for (const entry of names) {
|
|
309
|
-
try {
|
|
310
|
-
await rm(join(dir, entry), { force: true });
|
|
311
|
-
} catch (err) {
|
|
312
|
-
// A directory under the name is the child's, not a copy the host wrote:
|
|
313
|
-
// it is left, and the rest of the removal goes on.
|
|
314
|
-
if (!['ERR_FS_EISDIR', 'EISDIR'].includes(err?.code)) throw err;
|
|
315
|
-
// eslint-disable-next-line no-console
|
|
316
|
-
console.warn(`[claude] not removing ${join(dir, entry)}: it is not a file`);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
// The staged name `replacePrivateFile` writes beside its target.
|
|
322
|
-
const stagedName = (name) => `.${name}.${randomUUID()}.tmp`;
|
|
323
|
-
const STAGED_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
324
|
-
const isStagedCopyOf = (name) => (entry) => entry.startsWith(`.${name}.`) && entry.endsWith('.tmp')
|
|
325
|
-
&& STAGED_UUID.test(entry.slice(name.length + 2, -4));
|
|
326
|
-
|
|
327
|
-
// The login copy and any staged copy of it a failed or killed turn left: both
|
|
328
|
-
// hold the access token.
|
|
329
|
-
const removeLoginCopy = async (configDir) => {
|
|
330
|
-
await removePrivateFile(configDir, '.credentials.json');
|
|
331
|
-
await removePrivateFile(configDir, '.credentials.json', { match: isStagedCopyOf('.credentials.json') });
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
// The non-secret account metadata Claude needs from ~/.claude.json to find its
|
|
335
|
-
// login, and nothing else from that file.
|
|
336
|
-
const claudeAuthMetadata = async () => {
|
|
337
|
-
const operatorConfigPath = join(homedir(), '.claude.json');
|
|
338
|
-
const sourceStat = await statOrNull(operatorConfigPath);
|
|
339
|
-
let source = {};
|
|
340
|
-
if (sourceStat) {
|
|
341
|
-
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
342
|
-
throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`);
|
|
343
|
-
}
|
|
344
|
-
try {
|
|
345
|
-
source = JSON.parse(await readFile(operatorConfigPath, 'utf8'));
|
|
346
|
-
} catch (err) {
|
|
347
|
-
throw new Error(`failed to read operator Claude auth metadata: ${err.message}`);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
const sanitized = { hasCompletedOnboarding: true };
|
|
351
|
-
for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) {
|
|
352
|
-
if (source[key] !== undefined) sanitized[key] = source[key];
|
|
353
|
-
}
|
|
354
|
-
return sanitized;
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
// Claude's macOS OAuth credential lives in Keychain, but Claude also needs a
|
|
358
|
-
// small amount of non-secret account metadata from ~/.claude.json to locate
|
|
359
|
-
// it. Never bind or copy the operator's full file: it contains project paths,
|
|
360
|
-
// MCP configuration, usage history, and other host-private metadata. Public
|
|
361
|
-
// wrappers get a per-identity 0700 HOME carrying only this whitelist.
|
|
362
|
-
// A seat that reaches the model through the provider proxy (F151) needs no
|
|
363
|
-
// keychain, so its HOME gets no link to one.
|
|
364
|
-
const preparePublicClaudeState = async (ctx, { keychain = true } = {}) => {
|
|
365
|
-
const identity = ctx.agentName || ctx.cwd || 'anonymous';
|
|
366
|
-
const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
|
|
367
|
-
const statePath = ctx._publicClaudeState
|
|
368
|
-
|| publicClaudeStateRoot(identityHash);
|
|
369
|
-
const tmpPath = join(statePath, 'tmp');
|
|
370
|
-
const configPath = join(statePath, '.claude.json');
|
|
371
|
-
const stateLibrary = join(statePath, 'Library');
|
|
372
|
-
const stateKeychains = join(stateLibrary, 'Keychains');
|
|
373
|
-
const operatorKeychains = join(homedir(), 'Library', 'Keychains');
|
|
374
|
-
|
|
375
|
-
// The jail writes inside this folder, so the folder or a subfolder may be a
|
|
376
|
-
// link a prior turn left: each is checked before the host chmods or links
|
|
377
|
-
// inside it.
|
|
378
|
-
await ensureStateFolder(statePath);
|
|
379
|
-
await ensurePrivateDir(tmpPath);
|
|
380
|
-
await ensurePrivateDir(stateLibrary);
|
|
381
|
-
|
|
382
|
-
// `/usr/bin/security` resolves the default login keychain relative to HOME.
|
|
383
|
-
// Keep HOME isolated while pointing that one standard location at the
|
|
384
|
-
// operator's encrypted keychain database. Seatbelt admits the keychain
|
|
385
|
-
// directory read-only; no bearer credential is copied into agent state.
|
|
386
|
-
// A key seat creates no link. One a keyless turn of the same agent left is
|
|
387
|
-
// kept for that turn: the key seat's profile admits no keychain file, so the
|
|
388
|
-
// link leads nowhere it can read.
|
|
389
|
-
const keychainLink = await statOrNull(stateKeychains);
|
|
390
|
-
if (keychainLink && !keychainLink.isSymbolicLink()) {
|
|
391
|
-
throw new Error(`refusing to replace non-symlink Claude keychain path: ${stateKeychains}`);
|
|
392
|
-
}
|
|
393
|
-
if (keychain && keychainLink) {
|
|
394
|
-
const target = await readlink(stateKeychains);
|
|
395
|
-
if (target !== operatorKeychains) {
|
|
396
|
-
await unlink(stateKeychains);
|
|
397
|
-
await symlink(operatorKeychains, stateKeychains);
|
|
398
|
-
}
|
|
399
|
-
} else if (keychain) {
|
|
400
|
-
await symlink(operatorKeychains, stateKeychains);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Staged and renamed, as on bwrap: a link at this name is replaced, never
|
|
404
|
-
// written through (F153).
|
|
405
|
-
await replacePrivateFile(statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`);
|
|
406
|
-
return { statePath, tmpPath, configPath };
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
// Writes a 0600 file into a directory the confined child can also write. The
|
|
410
|
-
// directory must still be a real directory, and the file is written beside the
|
|
411
|
-
// target and renamed over it, so a symlink the child left at either name is
|
|
412
|
-
// replaced, never followed into another of the person's files. The directory
|
|
413
|
-
// check is a path check: it holds while no confined process can swap the
|
|
414
|
-
// directory, which on bwrap the .claude mount point inside the jail ensures.
|
|
415
|
-
// A failed write or rename removes the staged copy, which holds the content.
|
|
416
|
-
// `_writeFile` is a test seam.
|
|
417
|
-
export const replacePrivateFile = async (dir, name, content, { _writeFile = writeFile } = {}) => {
|
|
418
|
-
const dirStat = await statOrNull(dir);
|
|
419
|
-
if (!dirStat || dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
420
|
-
throw new Error(`refusing to write Claude state into a non-directory: ${dir}`);
|
|
421
|
-
}
|
|
422
|
-
const target = join(dir, name);
|
|
423
|
-
const staged = join(dir, stagedName(name));
|
|
424
|
-
try {
|
|
425
|
-
// `wx` creates the file 0600 or fails; no chmod by path after it, which
|
|
426
|
-
// would follow a link a concurrent child swapped in for the staged name.
|
|
427
|
-
await _writeFile(staged, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
428
|
-
await rename(staged, target);
|
|
429
|
-
} catch (err) {
|
|
430
|
-
await rm(staged, { force: true });
|
|
431
|
-
throw err;
|
|
432
|
-
}
|
|
433
|
-
return target;
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
// The host's ANTHROPIC_BASE_URL, for a seat that reaches the model on the
|
|
437
|
-
// person's login. Only when it is the route the login uses: with an
|
|
438
|
-
// ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN beside it, it is that key's
|
|
439
|
-
// gateway, and the jail (which never gets the key) would send the person's
|
|
440
|
-
// subscription token there instead. A URL carrying a user or password is not
|
|
441
|
-
// forwarded either.
|
|
442
|
-
export const loginRouteBaseUrl = (source = {}) => {
|
|
443
|
-
if (!source.ANTHROPIC_BASE_URL || source.ANTHROPIC_API_KEY || source.ANTHROPIC_AUTH_TOKEN) return null;
|
|
444
|
-
try {
|
|
445
|
-
const url = new URL(source.ANTHROPIC_BASE_URL);
|
|
446
|
-
return url.username || url.password ? null : source.ANTHROPIC_BASE_URL;
|
|
447
|
-
} catch {
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
// Two turns of one public agent share its folder (a daemon and a separate
|
|
453
|
-
// `agent run`, or two events at once). Each registers itself in a sibling of
|
|
454
|
-
// that folder, outside the jail's writable bind so a turn cannot forge one,
|
|
455
|
-
// and the login copy is removed only by the last live turn to finish.
|
|
456
|
-
const registerLoginTurn = async (statePath) => {
|
|
457
|
-
const dir = `${statePath}.login-turns`;
|
|
458
|
-
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
459
|
-
const marker = join(dir, `${process.pid}-${randomUUID()}`);
|
|
460
|
-
await writeFile(marker, '', { mode: 0o600, flag: 'wx' });
|
|
461
|
-
return marker;
|
|
462
|
-
};
|
|
463
|
-
const pidAlive = (pid) => {
|
|
464
|
-
try {
|
|
465
|
-
process.kill(pid, 0);
|
|
466
|
-
return true;
|
|
467
|
-
} catch (err) {
|
|
468
|
-
return err?.code === 'EPERM';
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
const releaseLoginTurn = async (marker, loginPath) => {
|
|
472
|
-
await rm(marker, { force: true });
|
|
473
|
-
const dir = dirname(marker);
|
|
474
|
-
let others = [];
|
|
475
|
-
try {
|
|
476
|
-
others = await readdir(dir);
|
|
477
|
-
} catch { /* gone: nobody else is registered */ }
|
|
478
|
-
let live = 0;
|
|
479
|
-
for (const name of others) {
|
|
480
|
-
const pid = Number(name.split('-')[0]);
|
|
481
|
-
if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) live += 1;
|
|
482
|
-
else await rm(join(dir, name), { force: true });
|
|
483
|
-
}
|
|
484
|
-
if (!live) {
|
|
485
|
-
await removeLoginCopy(dirname(loginPath));
|
|
486
|
-
// Only an empty folder goes: a turn registering at this moment keeps it.
|
|
487
|
-
try { await rmdir(dir); } catch { /* not empty, or already gone */ }
|
|
488
|
-
}
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
// A seat on Linux runs under bwrap, which has no keychain. Claude Code on
|
|
492
|
-
// Linux keeps its login in <config dir>/.credentials.json, so the seat's own
|
|
493
|
-
// state dir gets a copy of the person's, taken fresh each spawn. Only the copy
|
|
494
|
-
// is inside the jail. Nothing is written back: the jail can write its copy, and
|
|
495
|
-
// a write-back would let it replace the person's login with one of its
|
|
496
|
-
// choosing. Since F96 the copy is access-only for every seat, workspace ones
|
|
497
|
-
// included: a jail that refreshed would spend the host's refresh token and
|
|
498
|
-
// leave the person signed out (CI, 11-04: Claude Code rewrites the jail's copy
|
|
499
|
-
// when its session check fails). A missing login removes the copy, so a person
|
|
500
|
-
// who logged out is not still signed in here.
|
|
501
|
-
const claudeLoginSource = (env) => join(
|
|
502
|
-
env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
|
|
503
|
-
'.credentials.json',
|
|
504
|
-
);
|
|
505
|
-
|
|
506
|
-
// Opened once, without following a symlink, and checked on the open handle,
|
|
507
|
-
// so the file read is the file checked (a FIFO cannot hold the open either).
|
|
508
|
-
// A public seat's copy (F96) holds the access token alone. Claude Code
|
|
509
|
-
// rotates the refresh token when it refreshes, so a jail holding one could
|
|
510
|
-
// spend it and leave the host signed out; without one it cannot refresh at
|
|
511
|
-
// all, so a login that would expire mid-turn is refused here instead.
|
|
512
|
-
// What a public seat's access-only login keeps: the token and the account
|
|
513
|
-
// metadata Claude Code reads beside it.
|
|
514
|
-
const ACCESS_ONLY_LOGIN_FIELDS = ['accessToken', 'expiresAt', 'scopes', 'subscriptionType', 'rateLimitTier'];
|
|
515
|
-
|
|
516
|
-
const accessOnlyLogin = (text, { source, mustOutlastMs, apiKeyHint = true }) => {
|
|
517
|
-
let login;
|
|
518
|
-
try {
|
|
519
|
-
login = JSON.parse(text);
|
|
520
|
-
} catch {
|
|
521
|
-
throw new Error(`refusing a Claude login that is not JSON: ${source}`);
|
|
522
|
-
}
|
|
523
|
-
const oauth = login?.claudeAiOauth;
|
|
524
|
-
if (!oauth || typeof oauth.accessToken !== 'string' || !oauth.accessToken) {
|
|
525
|
-
throw new Error(`refusing a Claude login with no access token: ${source}`);
|
|
526
|
-
}
|
|
527
|
-
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt - Date.now() < mustOutlastMs) {
|
|
528
|
-
const minutes = Math.max(0, Math.floor((oauth.expiresAt - Date.now()) / 60000));
|
|
529
|
-
throw new Error(`the Claude login on this machine expires in ${minutes} min, before this turn could end. `
|
|
530
|
-
+ 'Sign in again in your own terminal on this machine, outside the agent: run `claude`, then /login'
|
|
531
|
-
+ `${apiKeyHint ? '. Or set ANTHROPIC_API_KEY for the agent' : ''}.`);
|
|
532
|
-
}
|
|
533
|
-
// An allowlist, not "everything but refreshToken": a field the host record
|
|
534
|
-
// gains later, secret or not, never reaches the seat's writable HOME.
|
|
535
|
-
const access = Object.fromEntries(ACCESS_ONLY_LOGIN_FIELDS
|
|
536
|
-
.filter((field) => oauth[field] !== undefined)
|
|
537
|
-
.map((field) => [field, oauth[field]]));
|
|
538
|
-
return `${JSON.stringify({ claudeAiOauth: access })}\n`;
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
const carryClaudeLogin = async (env, configDir, { accessOnly = false, mustOutlastMs = 0, apiKeyHint = true } = {}) => {
|
|
542
|
-
const source = claudeLoginSource(env);
|
|
543
|
-
const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
|
|
544
|
-
let handle;
|
|
545
|
-
try {
|
|
546
|
-
handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
547
|
-
} catch (err) {
|
|
548
|
-
if (err?.code === 'ENOENT') {
|
|
549
|
-
await removeLoginCopy(configDir);
|
|
550
|
-
return null;
|
|
551
|
-
}
|
|
552
|
-
if (err?.code === 'ELOOP') throw refuse();
|
|
553
|
-
throw err;
|
|
554
|
-
}
|
|
555
|
-
try {
|
|
556
|
-
if (!(await handle.stat()).isFile()) throw refuse();
|
|
557
|
-
const text = await handle.readFile('utf8');
|
|
558
|
-
return replacePrivateFile(configDir, '.credentials.json', accessOnly
|
|
559
|
-
? accessOnlyLogin(text, { source, mustOutlastMs, apiKeyHint })
|
|
560
|
-
: text);
|
|
561
|
-
} finally {
|
|
562
|
-
await handle.close();
|
|
563
|
-
}
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
// The per-seat folder a bwrap seat runs in. A public seat (F96) gets its own,
|
|
567
|
-
// never the workspace seat's: that one holds the transcripts of private-pod
|
|
568
|
-
// turns, and the same agent can run both kinds of
|
|
569
|
-
// turn, even at once (a daemon and a separate `agent run`).
|
|
570
|
-
export const bwrapClaudeStatePath = (ctx, seat = 'workspace') => {
|
|
571
|
-
if (ctx._publicClaudeState) return ctx._publicClaudeState;
|
|
572
|
-
const identity = ctx.agentName || ctx.cwd || 'anonymous';
|
|
573
|
-
const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
|
|
574
|
-
return publicClaudeStateRoot(seat === 'public' ? `${identityHash}-public` : identityHash);
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
// State for a seat under bwrap, outside the person's project, with the auth
|
|
578
|
-
// metadata whitelist and the login copy. The jail binds this folder writable
|
|
579
|
-
// and makes it HOME, so transcripts land here rather than in the project.
|
|
580
|
-
// Every seat carries an access-only copy of the login, or a public seat none
|
|
581
|
-
// when an API key reaches the model through the provider proxy instead.
|
|
582
|
-
const prepareWorkspaceBwrapClaudeState = async (ctx, { login = 'carry', seat = 'workspace' } = {}) => {
|
|
583
|
-
const statePath = bwrapClaudeStatePath(ctx, seat);
|
|
584
|
-
const tmpPath = join(statePath, 'tmp');
|
|
585
|
-
const configDir = join(statePath, '.claude');
|
|
586
|
-
await ensureStateFolder(statePath);
|
|
587
|
-
for (const dir of [tmpPath, configDir]) await ensurePrivateDir(dir);
|
|
588
|
-
|
|
589
|
-
const configPath = await replacePrivateFile(
|
|
590
|
-
statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
|
|
591
|
-
);
|
|
592
|
-
if (login === 'none') {
|
|
593
|
-
await removeLoginCopy(configDir);
|
|
594
|
-
return { statePath, tmpPath, configPath, loginPath: null };
|
|
595
|
-
}
|
|
596
|
-
const loginPath = await carryClaudeLogin(ctx.env, configDir, login === 'access-only'
|
|
597
|
-
// A workspace seat's jail never sees the host's API key, so only a public
|
|
598
|
-
// seat is told to set one.
|
|
599
|
-
? { accessOnly: true, mustOutlastMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS, apiKeyHint: seat === 'public' }
|
|
600
|
-
: {});
|
|
601
|
-
return { statePath, tmpPath, configPath, loginPath };
|
|
602
|
-
};
|
|
603
|
-
|
|
604
|
-
const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false, platform = process.platform) => {
|
|
605
|
-
const source = input || process.env;
|
|
606
|
-
const output = state || publicSeat ? {} : { ...source };
|
|
607
|
-
if (state || publicSeat) {
|
|
608
|
-
const safe = platform === 'win32' ? PUBLIC_SAFE_ENV_WIN32 : PUBLIC_SAFE_ENV;
|
|
609
|
-
for (const [key, value] of Object.entries(source)) {
|
|
610
|
-
if (safe.test(key) || PUBLIC_SAFE_ENV.test(key)) output[key] = value;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
if (state) {
|
|
614
|
-
output.HOME = state.statePath;
|
|
615
|
-
output.TMPDIR = state.tmpPath;
|
|
616
|
-
// Claude keeps its temp root at /private/tmp/claude-<uid> unless told
|
|
617
|
-
// otherwise; the seat's Seatbelt profile admits only its own tmp.
|
|
618
|
-
output.CLAUDE_CODE_TMPDIR = state.tmpPath;
|
|
619
|
-
output.XDG_CACHE_HOME = join(state.statePath, '.cache');
|
|
620
|
-
output.XDG_CONFIG_HOME = join(state.statePath, '.config');
|
|
621
|
-
output.XDG_DATA_HOME = join(state.statePath, '.local', 'share');
|
|
622
|
-
}
|
|
623
|
-
Object.assign(output, expansionEnv);
|
|
624
|
-
// `source` is usually the process environment, which is where the documented
|
|
625
|
-
// bootstrap export lives — so the value has to be taken out here rather than
|
|
626
|
-
// merely left out of the declaration rewrite. It survives only when THIS
|
|
627
|
-
// spawn's declaration still references it (claude substitutes args, url and
|
|
628
|
-
// headers literally, and `buildMcpExpansionEnv` reports that by carrying the
|
|
629
|
-
// key); the file path goes in either way so a hook child can name it.
|
|
630
|
-
return withholdRuntimeCredential(output, {
|
|
631
|
-
credentialFile,
|
|
632
|
-
keepsValue: expansionEnv[CREDENTIAL_KEY] !== undefined,
|
|
633
|
-
});
|
|
634
|
-
};
|
|
635
|
-
|
|
636
|
-
const absoluteToolDeny = (path) => `Read(/${path}/**)`;
|
|
637
|
-
|
|
638
|
-
// D245: Claude Code matches permission paths in POSIX form on Windows
|
|
639
|
-
// (C:\Users\ada becomes /c/Users/ada), so an absolute deny written from a
|
|
640
|
-
// Windows path would match nothing.
|
|
641
|
-
const permissionPath = (path, platform) => (platform === 'win32'
|
|
642
|
-
? path.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
|
|
643
|
-
: path);
|
|
644
|
-
|
|
645
|
-
// The tools that read or write the seat's agent-wide memory.
|
|
646
|
-
const MEMORY_TOOLS = Object.freeze(['commonly_read_agent_memory', 'commonly_write_agent_memory', 'commonly_save_my_memory']);
|
|
647
|
-
|
|
648
|
-
const buildPublicClaudePolicyArgs = (mcpToolPatterns, {
|
|
649
|
-
trust, allow = [], memoryWithheld = false, ownerWoken = false, platform = process.platform, home: homeOverride = null,
|
|
650
|
-
noShell = false,
|
|
651
|
-
} = {}) => {
|
|
652
|
-
const home = homeOverride || homedir();
|
|
653
|
-
const deny = (sub) => absoluteToolDeny(permissionPath(join(home, sub), platform));
|
|
654
|
-
// `allow` is already narrowed to a workspace seat's grants (workspaceGrants).
|
|
655
|
-
// D245: with no OS jail (native Windows, --unsandboxed) a shell would run
|
|
656
|
-
// with the user's whole access and could read the seat's own token from its
|
|
657
|
-
// environment, so `write` grants edits only there.
|
|
658
|
-
// With no jail the edit tools are also scoped to the working folder: a bare
|
|
659
|
-
// Edit/Write could reach the user's profile (a Startup script, ~/.claude).
|
|
660
|
-
const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || [])
|
|
661
|
-
.filter((tool) => !(noShell && SHELL_TOOLS.has(tool)))
|
|
662
|
-
.map((tool) => (noShell && (tool === 'Edit' || tool === 'Write') ? `${tool}(./**)` : tool));
|
|
663
|
-
const deniedReads = [
|
|
664
|
-
deny('.commonly'),
|
|
665
|
-
deny('.claude'),
|
|
666
|
-
deny('.codex'),
|
|
667
|
-
deny('.ssh'),
|
|
668
|
-
deny('.aws'),
|
|
669
|
-
deny('.config'),
|
|
670
|
-
'Read(//private/tmp/**)',
|
|
671
|
-
'Read(./.commonly/**)',
|
|
672
|
-
'Read(./.codex/**)',
|
|
673
|
-
'Read(./.env)',
|
|
674
|
-
];
|
|
675
|
-
// A workspace seat reads a real project, so every .env variant at any depth,
|
|
676
|
-
// and what a .env directory holds, stays out of reach (the jail holds the
|
|
677
|
-
// same line). .git is denied too: its config can carry a remote URL with a
|
|
678
|
-
// token in it, and history comes through the read-only git tool, which runs
|
|
679
|
-
// in its own process. And the seat cannot install itself into a room partway
|
|
680
|
-
// through a turn: the per-turn check confirmed its pods private before the
|
|
681
|
-
// spawn.
|
|
682
|
-
const workspaceDenies = trust === 'workspace'
|
|
683
|
-
? [
|
|
684
|
-
'Read(./.env.*)', 'Read(./**/.env)', 'Read(./**/.env.*)',
|
|
685
|
-
'Read(./.env/**)', 'Read(./**/.env/**)', 'Read(./**/.env.*/**)',
|
|
686
|
-
'Read(./.git/**)', 'Read(./**/.git/**)',
|
|
687
|
-
...mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, 'commonly_self_install_into_pod')),
|
|
688
|
-
]
|
|
689
|
-
: [];
|
|
690
|
-
// A workspace seat's turn that runs public gets no memory in its prompt
|
|
691
|
-
// (agent.js), and no tool that would read or write it either.
|
|
692
|
-
const memoryDenies = memoryWithheld
|
|
693
|
-
? MEMORY_TOOLS.flatMap((tool) => mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, tool)))
|
|
694
|
-
: [];
|
|
695
|
-
// Nor, on any confined trust, the tools no public seat is offered
|
|
696
|
-
// (public-withheld-tools.js). A public seat also loses self-install and every
|
|
697
|
-
// memory write (F118), and gets the writes back on a turn its owner woke
|
|
698
|
-
// (F145). Only a workspace seat is called with its trust at every call site;
|
|
699
|
-
// the public ones pass none, so "not workspace" is public.
|
|
700
|
-
const withheldTools = trust === 'workspace'
|
|
701
|
-
? PUBLIC_WITHHELD_TOOLS
|
|
702
|
-
: publicSeatWithheldTools({ ownerWoken });
|
|
703
|
-
const withheldDenies = withheldTools
|
|
704
|
-
.flatMap((tool) => mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, tool)));
|
|
705
|
-
// A tool can be denied for two reasons (a withheld turn's memory write is
|
|
706
|
-
// also a public seat's); each deny is written once.
|
|
707
|
-
const toolDenies = [...new Set([...workspaceDenies, ...memoryDenies, ...withheldDenies])];
|
|
708
|
-
return [
|
|
709
|
-
// HOME points at an isolated, sanitized state directory. Ignore settings
|
|
710
|
-
// from user/project/local sources and disable slash-command extensions;
|
|
711
|
-
// unlike --safe-mode, this preserves the explicit --mcp-config capability.
|
|
712
|
-
'--setting-sources', '',
|
|
713
|
-
'--disable-slash-commands',
|
|
714
|
-
'--strict-mcp-config',
|
|
715
|
-
'--no-chrome',
|
|
716
|
-
'--permission-mode', 'dontAsk',
|
|
717
|
-
'--allowedTools', 'Read(./**)', ...granted, ...mcpToolPatterns,
|
|
718
|
-
'--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.some((g) => g === tool || g.startsWith(`${tool}(`))),
|
|
719
|
-
...deniedReads, ...toolDenies,
|
|
720
|
-
];
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
// A public seat's skills, as appended system-prompt text.
|
|
724
|
-
//
|
|
725
|
-
// `--disable-slash-commands` above is Claude's switch for ALL skills, and
|
|
726
|
-
// `--setting-sources ''` drops the project source the mounted copies live in.
|
|
727
|
-
// So the skills this adapter mounts into <cwd>/.claude/skills never load in a
|
|
728
|
-
// public seat: measured 2026-10-19 on a Bring your own page seat, which
|
|
729
|
-
// answered "I don't see a skill named commonly, or any skills list or Skill
|
|
730
|
-
// tool"; the same workspace with `--setting-sources project` found it. Every
|
|
731
|
-
// self-serve seat is public (COMMONLY_DEFAULT_SANDBOX), so every outsider's
|
|
732
|
-
// Claude ran without the house rules buildDefaultEnvironment mounts for it.
|
|
733
|
-
// Re-enabling skills would also hand a public seat the built-in ones
|
|
734
|
-
// (computer-use, the browser skills), so the flags stay and the declared text
|
|
735
|
-
// is delivered as prompt instead. Inline rather than a file: nothing new has to
|
|
736
|
-
// be admitted through Seatbelt. Linux caps one argv string at 128 KiB, so the
|
|
737
|
-
// total is capped well under it.
|
|
738
|
-
const PUBLIC_SKILL_PROMPT_MAX_BYTES = 96 * 1024;
|
|
739
|
-
const publicSkillPromptArgs = async (skillSources, preamble = '') => {
|
|
740
|
-
const included = [];
|
|
741
|
-
const skills = await skillsAsText(skillSources, {
|
|
742
|
-
maxBytes: PUBLIC_SKILL_PROMPT_MAX_BYTES,
|
|
743
|
-
label: 'claude',
|
|
744
|
-
onIncluded: (source) => included.push(source),
|
|
745
|
-
});
|
|
746
|
-
const text = [preamble, skills].filter(Boolean).join('\n\n');
|
|
747
|
-
return { args: text ? ['--append-system-prompt', text] : [], included };
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
// What a confined seat may do on this machine, as prompt text. The CLI logs
|
|
751
|
-
// the same facts to the owner's terminal (workspaceGrantLine in agent.js), but
|
|
752
|
-
// the model never saw them: on 2026-11-05 a read-only workspace seat told its
|
|
753
|
-
// user it could "read and edit files, run shell commands", which is Claude
|
|
754
|
-
// Code's usual kit, not this seat's. Each sentence follows the policy above:
|
|
755
|
-
// a write grant adds Edit, Write and Bash (no Bash on a host with no jail), a
|
|
756
|
-
// web grant adds WebFetch only, and WebSearch is always denied.
|
|
757
|
-
export const seatPermissionsText = ({
|
|
758
|
-
trust, allow = [], noShell = false, gitTool = false,
|
|
759
|
-
}) => {
|
|
760
|
-
const write = trust === 'workspace' && allow.includes('write');
|
|
761
|
-
const web = trust === 'workspace' && allow.includes('web');
|
|
762
|
-
const lines = ['## What you can do on this machine', ''];
|
|
763
|
-
if (write) {
|
|
764
|
-
lines.push(noShell
|
|
765
|
-
? 'You can read and edit the files in your working folder. You cannot run shell commands.'
|
|
766
|
-
: 'You can read and edit the files in your working folder and run shell commands there. '
|
|
767
|
-
+ 'Its .git is read-only, so you cannot commit.');
|
|
768
|
-
} else {
|
|
769
|
-
lines.push(`You can read the files in your working folder${
|
|
770
|
-
gitTool ? ', and read its history through the read-only git tool' : ''}.`);
|
|
771
|
-
lines.push(web
|
|
772
|
-
? 'You cannot edit or create files or run shell commands.'
|
|
773
|
-
: 'You cannot edit or create files, run shell commands, or use web search or fetch.');
|
|
774
|
-
}
|
|
775
|
-
if (web) lines.push('You can fetch web pages. You cannot use web search.');
|
|
776
|
-
else if (write) lines.push('You cannot use the web search or fetch tools.');
|
|
777
|
-
// A shell's commands can reach the network (workspaceGrantLine says so too),
|
|
778
|
-
// so a seat with one is never told it is offline.
|
|
779
|
-
if (write && !noShell) lines.push('Your shell commands can reach the network.');
|
|
780
|
-
lines.push('When someone asks what you can do, describe these limits, not Claude Code\'s usual tools.');
|
|
781
|
-
return lines.join('\n');
|
|
782
|
-
};
|
|
783
|
-
|
|
784
|
-
// `ownGroup` is set only under the run supervisor, which kills the child's
|
|
785
|
-
// whole group; unsupervised, the spawn options are exactly what they were.
|
|
786
|
-
// Stream-json so each model event is a sign of life for the watchdog. The
|
|
787
|
-
// partial messages put thinking deltas on the stream too, so a long think is
|
|
788
|
-
// visible while it runs. A hung provider writes only `system` retry events,
|
|
789
|
-
// and the watchdog does not count those.
|
|
790
|
-
const STREAM_ARGS = ['--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
791
|
-
|
|
792
|
-
const parseEvent = (line) => {
|
|
793
|
-
try {
|
|
794
|
-
const event = JSON.parse(line);
|
|
795
|
-
return event && typeof event === 'object' ? event : null;
|
|
796
|
-
} catch {
|
|
797
|
-
return null;
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
|
|
801
|
-
const STOPPED_TURN = 'the turn stopped before it finished';
|
|
802
|
-
|
|
803
|
-
// The reply is the `result` event's text. A turn with no result event stopped
|
|
804
|
-
// before it finished: SIGINT exits 0 without printing one (claude 2.1.318), with
|
|
805
|
-
// or without assistant text, and before startup it prints nothing at all. That
|
|
806
|
-
// text isn't an answer, since each assistant event may be an interim message on
|
|
807
|
-
// the way to a tool, and empty text would read as a turn with nothing to say.
|
|
808
|
-
// Other output with no JSON lines is plain text and returned as it arrived. A
|
|
809
|
-
// result flagged `is_error` is a failed turn, whatever its exit code, so it is
|
|
810
|
-
// raised here, outside the parse guard, and never returned as a reply.
|
|
811
|
-
export const claudeResultText = (stdout, { credentials = [] } = {}) => {
|
|
812
|
-
const text = String(stdout || '');
|
|
813
|
-
const events = text.split('\n').map(parseEvent);
|
|
814
|
-
if (text.trim() && !events.some(Boolean)) return text.trim();
|
|
815
|
-
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
816
|
-
if (events[i]?.type !== 'result') continue;
|
|
817
|
-
const reply = String(events[i].result ?? '').trim();
|
|
818
|
-
// An interrupted request reports error_during_execution with empty text.
|
|
819
|
-
if (events[i].is_error === true) throw adapterFailure('claude', reply || STOPPED_TURN, { credentials });
|
|
820
|
-
return reply;
|
|
821
|
-
}
|
|
822
|
-
throw adapterFailure('claude', STOPPED_TURN, { credentials });
|
|
823
|
-
};
|
|
824
|
-
|
|
825
|
-
// TASK-256: what a public seat's own stream says about its Commonly MCP server.
|
|
826
|
-
// Claude Code reports each server's state in the init event (`connected` or
|
|
827
|
-
// `failed`), and a failed tool call as a `tool_result` with `is_error: true`
|
|
828
|
-
// whose content is the server's text, the shipped MCP's `{status, body,
|
|
829
|
-
// message}` JSON (both measured on Claude Code 2.1.314; `-p` waits for a slow
|
|
830
|
-
// server, so init reports it connected rather than pending). Ready is a positive
|
|
831
|
-
// witness: `connected` in init, or `pending` followed by a Commonly call that
|
|
832
|
-
// returned, since a server may finish starting during the turn. A stream with no
|
|
833
|
-
// init event, or with a Commonly server in any other state, never showed the
|
|
834
|
-
// server working, so the turn is withheld. A failed
|
|
835
|
-
// Commonly call withholds it unless `isAnswerableToolError` names an answer the
|
|
836
|
-
// person can be given (the predicate the OpenCode adapter uses). Only servers
|
|
837
|
-
// that run the shipped Commonly MCP count. Returns the failure, or null.
|
|
838
|
-
const toolResultText = (content) => {
|
|
839
|
-
if (typeof content === 'string') return content;
|
|
840
|
-
if (!Array.isArray(content)) return '';
|
|
841
|
-
return content.filter((part) => part?.type === 'text').map((part) => String(part.text ?? '')).join('');
|
|
842
|
-
};
|
|
843
|
-
|
|
844
|
-
export const claudePublicMcpWitness = (stdout, mcpServers) => {
|
|
845
|
-
const names = (Array.isArray(mcpServers) ? mcpServers : [])
|
|
846
|
-
.filter((server) => server?.name && isShippedCommonlyMcpCommand(server.command))
|
|
847
|
-
.map((server) => String(server.name));
|
|
848
|
-
if (!names.length) return null;
|
|
849
|
-
const events = String(stdout || '').split('\n').map(parseEvent).filter(Boolean);
|
|
850
|
-
const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
|
|
851
|
-
const servers = Array.isArray(init?.mcp_servers) ? init.mcp_servers : [];
|
|
852
|
-
const toolNames = new Map();
|
|
853
|
-
for (const event of events) {
|
|
854
|
-
if (event.type !== 'assistant') continue;
|
|
855
|
-
for (const block of event.message?.content || []) {
|
|
856
|
-
if (block?.type === 'tool_use' && block.id) toolNames.set(block.id, String(block.name || ''));
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
const results = events
|
|
860
|
-
.filter((event) => event.type === 'user' && Array.isArray(event.message?.content))
|
|
861
|
-
.flatMap((event) => event.message.content)
|
|
862
|
-
.filter((block) => block?.type === 'tool_result')
|
|
863
|
-
.map((block) => ({ block, tool: toolNames.get(block.tool_use_id) || '' }));
|
|
864
|
-
// Init names a server as declared; tool names carry it with every character
|
|
865
|
-
// outside [A-Za-z0-9_-] turned to `_` (`commonly.me` → `mcp__commonly_me__`,
|
|
866
|
-
// measured on 2.1.314).
|
|
867
|
-
const prefixOf = (name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`;
|
|
868
|
-
for (const name of names) {
|
|
869
|
-
const status = servers.find((server) => server?.name === name)?.status;
|
|
870
|
-
const returned = status === 'pending'
|
|
871
|
-
&& results.some(({ block, tool }) => block.is_error !== true && tool.startsWith(prefixOf(name)));
|
|
872
|
-
if (status !== 'connected' && !returned) {
|
|
873
|
-
return { code: PUBLIC_MCP_NOT_READY_CODE, tool: name, error: init ? `status ${status || 'absent'}` : 'no init event' };
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
const prefixes = names.map(prefixOf);
|
|
877
|
-
for (const { block, tool } of results) {
|
|
878
|
-
if (block.is_error !== true || !prefixes.some((prefix) => tool.startsWith(prefix))) continue;
|
|
879
|
-
const error = toolResultText(block.content);
|
|
880
|
-
if (!isAnswerableToolError(error)) return { code: PUBLIC_TOOL_FAILED_CODE, tool, error };
|
|
881
|
-
}
|
|
882
|
-
return null;
|
|
883
|
-
};
|
|
884
|
-
|
|
885
|
-
const publicWitnessError = ({ code, tool, error }) => {
|
|
886
|
-
const reason = String(error).replace(/\s+/g, ' ').slice(0, 160) || '(empty error)';
|
|
887
|
-
const what = code === PUBLIC_MCP_NOT_READY_CODE
|
|
888
|
-
? `Space MCP server ${tool} was not shown connected`
|
|
889
|
-
: `Space tool ${tool} failed in a way the model must not narrate`;
|
|
890
|
-
const failure = new Error(
|
|
891
|
-
`public seat withheld this turn's reply: ${what} (${reason}), so the CLI posted no reply for it (posts the agent made through its tools stay) and the CLI does not ack the event (one the agent already checkpointed stays acked)`,
|
|
892
|
-
);
|
|
893
|
-
failure.code = code;
|
|
894
|
-
return failure;
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
// The reply a turn posts. A public seat's turn is witnessed first (TASK-256).
|
|
898
|
-
// Both ways out of a turn are redacted: a reply that echoes a credential the
|
|
899
|
-
// child was handed (the per-turn proxy token included), and the witness error.
|
|
900
|
-
const turnReply = (stdout, { credentials, publicMcp }) => {
|
|
901
|
-
if (publicMcp) {
|
|
902
|
-
const failure = claudePublicMcpWitness(stdout, publicMcp);
|
|
903
|
-
if (failure) throw publicWitnessError({ ...failure, error: redactKnownCredentials(String(failure.error), credentials) });
|
|
904
|
-
}
|
|
905
|
-
return redactKnownCredentials(claudeResultText(stdout, { credentials }), credentials);
|
|
906
|
-
};
|
|
907
|
-
|
|
908
|
-
// A line of stdout the CLI wrote as a failure, not as part of the turn's stream:
|
|
909
|
-
// prose, or an API body printed whole. A keep-list, because a typed event holds
|
|
910
|
-
// the model's words or a tool's. Unparsed text opening like an event is the last
|
|
911
|
-
// one, torn by the crash.
|
|
912
|
-
const isCliFailureLine = (line, event) => (event
|
|
913
|
-
? event.type === undefined || event.type === 'error'
|
|
914
|
-
: !line.trimStart().startsWith('{"'));
|
|
915
|
-
|
|
916
|
-
// The failure tail for a non-zero exit. The status scan, the failure class and
|
|
917
|
-
// the pod's notice all read this text, so it holds only the CLI's own words: a
|
|
918
|
-
// tool result quoting `| 429: ` once named the turn's status and cut the body
|
|
919
|
-
// past the JSON it sat in (F235). So the result text counts only when flagged
|
|
920
|
-
// is_error. At is_error false it's the model's answer, and a crash after it says
|
|
921
|
-
// nothing about the provider. A failed result with no text keeps its subtype.
|
|
922
|
-
// The cost: a crash with no result event loses a synthetic API-error assistant
|
|
923
|
-
// event, so it classes runtime, and the seat log loses the turn's last lines.
|
|
924
|
-
export const claudeFailureTail = (stdout) => {
|
|
925
|
-
const lines = String(stdout || '').split('\n');
|
|
926
|
-
const events = lines.map(parseEvent);
|
|
927
|
-
const result = [...events].reverse().find((event) => event?.type === 'result');
|
|
928
|
-
const failed = result?.is_error === true;
|
|
929
|
-
if (failed && String(result.result ?? '').trim()) return String(result.result).trim();
|
|
930
|
-
const subtype = failed && /^\w{1,40}$/.test(result.subtype) ? result.subtype : '';
|
|
931
|
-
const prose = lines.filter((line, i) => isCliFailureLine(line, events[i])).join('\n').trim();
|
|
932
|
-
return [prose, subtype].filter(Boolean).join('\n');
|
|
933
|
-
};
|
|
934
|
-
|
|
935
|
-
const runClaude = ({
|
|
936
|
-
cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
|
|
937
|
-
transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
|
|
938
|
-
processTable = readProcessTable, survivorIo, modelStream = true, turnReap = turnReapEnabled(),
|
|
939
|
-
commandResolution = {}, confined = false,
|
|
940
|
-
}) => new Promise((resolve, reject) => {
|
|
941
|
-
// Every process the turn starts inherits this marker however it detaches,
|
|
942
|
-
// so what it leaves running is stopped once the turn has closed. A confined
|
|
943
|
-
// seat's turn keeps nothing: COMMONLY_KEEP_PROCESS is not honoured there.
|
|
944
|
-
const turn = markTurn(env ?? process.env, { enabled: turnReap, confined });
|
|
945
|
-
// On Windows an npm `claude.cmd` is read, not run through cmd.exe: the
|
|
946
|
-
// prompt in `args` is pod content (#2141). Off Windows this is identity.
|
|
947
|
-
const launch = resolveLaunch(cmd, args, { env: turn.env, ...commandResolution });
|
|
948
|
-
const proc = spawnInOwnGroup(spawnImpl, launch.command, launch.args, {
|
|
949
|
-
cwd,
|
|
950
|
-
env: turn.env,
|
|
951
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
952
|
-
}, { ownGroup });
|
|
953
|
-
observeSpawn(proc, onSpawn, controls);
|
|
954
|
-
let stdout = '';
|
|
955
|
-
let stderr = '';
|
|
956
|
-
let timedOut = false;
|
|
957
|
-
|
|
958
|
-
const timer = setTimeout(() => {
|
|
959
|
-
timedOut = true;
|
|
960
|
-
proc.kill('SIGTERM');
|
|
961
|
-
}, timeoutMs);
|
|
962
|
-
// A turn that never starts is killed long before the run timeout.
|
|
963
|
-
let stalled = null;
|
|
964
|
-
let stallKill = null;
|
|
965
|
-
let stallSnapshot = [];
|
|
966
|
-
// SIGKILL snapshotted descendants that are still alive. Safe to repeat. It
|
|
967
|
-
// runs when the spawn exits, at the grace deadline and on close: a survivor
|
|
968
|
-
// still holding the spawn's stdout or stderr keeps close from ever firing,
|
|
969
|
-
// and once the spawn is reaped killProcessTree can no longer reach it.
|
|
970
|
-
const sweepStallSurvivors = () => {
|
|
971
|
-
if (stalled) killStallSurvivors(stallSnapshot, survivorIo);
|
|
972
|
-
};
|
|
973
|
-
const treeIo = (table) => ({ table, ...(survivorIo?.read ? { read: survivorIo.read } : {}) });
|
|
974
|
-
const watchdog = watchFirstOutput({
|
|
975
|
-
proc,
|
|
976
|
-
windowMs: firstOutputMs,
|
|
977
|
-
transcriptPath,
|
|
978
|
-
modelStream,
|
|
979
|
-
lockProbe,
|
|
980
|
-
onStall: (reason) => {
|
|
981
|
-
stalled = spawnStalledError({ label: 'claude', windowMs: firstOutputMs, reason });
|
|
982
|
-
// Snapshot the descendants first: once the spawn exits they are
|
|
983
|
-
// reparented and can no longer be found from it.
|
|
984
|
-
const table = Number.isInteger(proc.pid) ? processTable() : null;
|
|
985
|
-
if (table) stallSnapshot = descendantsOf(table, proc.pid);
|
|
986
|
-
killProcessTree(proc, 'SIGTERM', treeIo(() => table));
|
|
987
|
-
// A stopped process holds a SIGTERM it handles (claude does) until it is
|
|
988
|
-
// continued, so a turn frozen with SIGSTOP sat out the whole grace below:
|
|
989
|
-
// 191 s on a 180 s window (rivet, 2026-11-01). SIGCONT lets it act on the
|
|
990
|
-
// SIGTERM now. A process that is not stopped is unaffected unless it
|
|
991
|
-
// handles SIGCONT (a job-control shell does); the SIGKILL below still
|
|
992
|
-
// lands at the grace either way.
|
|
993
|
-
killProcessTree(proc, 'SIGCONT', treeIo(() => table));
|
|
994
|
-
// A process parked in a system call may not act on SIGTERM. SIGKILL the
|
|
995
|
-
// child, its group when it leads one, and its descendants.
|
|
996
|
-
stallKill = setTimeout(() => {
|
|
997
|
-
killProcessTree(proc, 'SIGKILL', treeIo(processTable));
|
|
998
|
-
sweepStallSurvivors();
|
|
999
|
-
// A pipe holder the snapshot missed (ps failed, or it was reparented
|
|
1000
|
-
// before the stall) would still keep close from firing. Dropping our
|
|
1001
|
-
// ends of the pipes bounds every stalled turn at window plus grace.
|
|
1002
|
-
proc.stdout?.destroy?.();
|
|
1003
|
-
proc.stderr?.destroy?.();
|
|
1004
|
-
}, stallKillGraceMs);
|
|
1005
|
-
stallKill.unref?.();
|
|
1006
|
-
},
|
|
1007
|
-
});
|
|
1008
|
-
proc.on('exit', sweepStallSurvivors);
|
|
1009
|
-
|
|
1010
|
-
proc.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
1011
|
-
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
1012
|
-
proc.on('error', (err) => {
|
|
1013
|
-
clearTimeout(timer);
|
|
1014
|
-
clearTimeout(stallKill);
|
|
1015
|
-
watchdog.stop();
|
|
1016
|
-
reject(err);
|
|
1017
|
-
});
|
|
1018
|
-
proc.on('close', (code) => {
|
|
1019
|
-
clearTimeout(timer);
|
|
1020
|
-
clearTimeout(stallKill);
|
|
1021
|
-
watchdog.stop();
|
|
1022
|
-
const reaped = describeTurnReap(turn.reap(proc));
|
|
1023
|
-
// eslint-disable-next-line no-console
|
|
1024
|
-
if (reaped) console.warn(`[claude] ${reaped}`);
|
|
1025
|
-
if (stalled) {
|
|
1026
|
-
sweepStallSurvivors();
|
|
1027
|
-
return reject(stalled);
|
|
1028
|
-
}
|
|
1029
|
-
if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
|
|
1030
|
-
if (code !== 0) {
|
|
1031
|
-
// Report stdout too, not just stderr. In `-p` mode claude writes terminal
|
|
1032
|
-
// conditions (usage limits especially) to stdout and exits non-zero with
|
|
1033
|
-
// stderr empty — 361 consecutive failures on 2026-08-03 carried no reason
|
|
1034
|
-
// at all because of this. It is not only a diagnosability problem: the
|
|
1035
|
-
// circuit breaker classifies from the error message, so a blank message
|
|
1036
|
-
// downgrades a hard quota failure to RUNTIME and its shortest backoff.
|
|
1037
|
-
//
|
|
1038
|
-
// TASK-103: the tail goes through the same reader pi's refusal log uses.
|
|
1039
|
-
// A non-JSON tail (which this normally is — the usage-limit line above is
|
|
1040
|
-
// prose) is kept whole and only exact-match redacted; a JSON body is
|
|
1041
|
-
// reduced to one named field. The status, when the tail names one, is
|
|
1042
|
-
// attached as `error.status` so a refusal classifies by field rather than
|
|
1043
|
-
// by regex.
|
|
1044
|
-
const detail = [stderr.trim(), claudeFailureTail(stdout)].filter(Boolean).join(' | ');
|
|
1045
|
-
return reject(adapterFailure('claude', detail, {
|
|
1046
|
-
credentials,
|
|
1047
|
-
exitCode: code,
|
|
1048
|
-
limit: 2000,
|
|
1049
|
-
}));
|
|
1050
|
-
}
|
|
1051
|
-
resolve(stdout);
|
|
1052
|
-
});
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
// ── MCP config write — claude consumes this via --mcp-config <path> ─────────
|
|
1056
|
-
|
|
1057
|
-
// Keep Commonly placeholders in the MCP JSON and expose their values only in
|
|
1058
|
-
// Claude's per-spawn environment. Claude Code natively expands ${VAR} in MCP
|
|
1059
|
-
// command/args/env/url/header fields. Token-bearing HTTP headers are routed
|
|
1060
|
-
// through headersHelper below, because a literal expansion would put the
|
|
1061
|
-
// credential in Claude's environment and every MCP child's environment.
|
|
1062
|
-
//
|
|
1063
|
-
// Recognised placeholders:
|
|
1064
|
-
// ${COMMONLY_TOKEN_FILE} — the PATH of this spawn's credential file
|
|
1065
|
-
// ${COMMONLY_API_URL} — the instance URL the agent is attached to
|
|
1066
|
-
// ${COMMONLY_INSTANCE_URL} — alias for COMMONLY_API_URL (clearer in context)
|
|
1067
|
-
//
|
|
1068
|
-
// The credential itself is deliberately NOT among these values (TASK-083). It
|
|
1069
|
-
// used to be, and that put the bearer token in claude's environment, where every
|
|
1070
|
-
// MCP child and every hook inherited it — measured on a live seat: the pi seat's
|
|
1071
|
-
// MCP child carried COMMONLY_AGENT_TOKEN and COMMONLY_LITELLM_KEY. What the
|
|
1072
|
-
// child gets instead is a path to a 0600 file that only this spawn can name,
|
|
1073
|
-
// written into the SAME per-spawn directory claude is already handed for
|
|
1074
|
-
// --mcp-config. That directory is not a coincidence: it is the one path outside
|
|
1075
|
-
// the workspace that the Seatbelt profile admits (sandbox/seatbelt.js admits
|
|
1076
|
-
// `subpath(mcpConfigDir)` and nothing under ~/.commonly but the seat's own
|
|
1077
|
-
// statePath), so a credential file placed anywhere else is unreadable by the
|
|
1078
|
-
// very child it is written for.
|
|
1079
|
-
//
|
|
1080
|
-
// Only values actually referenced by this MCP declaration are added to the
|
|
1081
|
-
// child environment. Unknown placeholders remain in JSON so Claude's parser
|
|
1082
|
-
// fails clearly instead of receiving a silent empty string.
|
|
1083
|
-
const buildMcpExpansionEnv = (mcpConfig, ctx) => {
|
|
1084
|
-
const serialized = JSON.stringify(mcpConfig);
|
|
1085
|
-
const values = {
|
|
1086
|
-
[CREDENTIAL_FILE_VAR]: ctx.credentialFile || '',
|
|
1087
|
-
COMMONLY_API_URL: ctx.instanceUrl || '',
|
|
1088
|
-
COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
|
|
1089
|
-
// Whether THIS one is exposed is decided by the loop below, not here: it is
|
|
1090
|
-
// added only when the value is non-empty AND the declaration still references
|
|
1091
|
-
// it — and `serialized` is the config AFTER the credential rewrite, which is
|
|
1092
|
-
// what moves the default declaration off this value and onto the file. A
|
|
1093
|
-
// reference that cannot use the file channel (args, url, unsupported or
|
|
1094
|
-
// non-HTTP headers, or a string that merely contains the placeholder) keeps
|
|
1095
|
-
// the value for that spawn. createMcpConfig warns when this carve-out applies.
|
|
1096
|
-
COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
|
|
1097
|
-
};
|
|
1098
|
-
const output = {};
|
|
1099
|
-
for (const [key, value] of Object.entries(values)) {
|
|
1100
|
-
const referenced = serialized.includes(`\${${key}}`)
|
|
1101
|
-
|| (key === CREDENTIAL_FILE_VAR && ctx.headersHelperUsesCredentialFile === true);
|
|
1102
|
-
if (value && referenced) output[key] = value;
|
|
1103
|
-
}
|
|
1104
|
-
return output;
|
|
1105
|
-
};
|
|
1106
|
-
|
|
1107
|
-
const isHttpTransport = (server) => {
|
|
1108
|
-
const transport = String(server?.transport || server?.type || '').trim().toLowerCase();
|
|
1109
|
-
return transport === 'http' || transport === 'streamable-http';
|
|
1110
|
-
};
|
|
1111
|
-
|
|
1112
|
-
const getTokenHeaderTemplates = (server) => {
|
|
1113
|
-
if (!isHttpTransport(server) || !server.headers || typeof server.headers !== 'object') return null;
|
|
1114
|
-
const templates = Object.fromEntries(
|
|
1115
|
-
Object.entries(server.headers)
|
|
1116
|
-
.filter(([, value]) => typeof value === 'string' && value.includes(RUNTIME_CREDENTIAL_PLACEHOLDER))
|
|
1117
|
-
.map(([name, value]) => [name, value.split(RUNTIME_CREDENTIAL_PLACEHOLDER)]),
|
|
1118
|
-
);
|
|
1119
|
-
return Object.keys(templates).length > 0 ? templates : null;
|
|
1120
|
-
};
|
|
1121
|
-
|
|
1122
|
-
const writePrivateFile = async (path, contents) => {
|
|
1123
|
-
await writeFile(path, contents, { encoding: 'utf8', mode: 0o600 });
|
|
1124
|
-
await chmod(path, 0o600);
|
|
1125
|
-
};
|
|
1126
|
-
|
|
1127
|
-
// F461. The longest socket path the platform takes (sun_path: 104 bytes on
|
|
1128
|
-
// macOS with its terminator; Linux allows 108).
|
|
1129
|
-
const MAX_SOCKET_PATH = 103;
|
|
1130
|
-
|
|
1131
|
-
// F461. A public seat's header helper asks this process for the token over a
|
|
1132
|
-
// Unix socket in the spawn's own folder (0700, the socket 0600), so the
|
|
1133
|
-
// credential file no longer has to outlive the stdio servers' first read. The
|
|
1134
|
-
// token stays in this process's memory.
|
|
1135
|
-
//
|
|
1136
|
-
// The socket answers only a client that first sends this turn's key, a 256-bit
|
|
1137
|
-
// random value in a 0600 file beside it. A Unix connect is not a file read: on
|
|
1138
|
-
// macOS another seat's Seatbelt jail, denied this folder, can still connect to
|
|
1139
|
-
// a socket whose path it learns (measured), because a seat's network is open.
|
|
1140
|
-
// The key keeps the boundary where the file had it, at who can read this
|
|
1141
|
-
// folder. Claude's Read tool cannot open a socket, so reading the key alone
|
|
1142
|
-
// gets it nothing.
|
|
1143
|
-
//
|
|
1144
|
-
// Windows keeps the file: a named pipe's default ACL grants read to Everyone. A
|
|
1145
|
-
// path too long for a socket, or a socket that cannot be set up, keeps the file
|
|
1146
|
-
// too, with a warning.
|
|
1147
|
-
const CHANNEL_IDLE_MS = 10_000;
|
|
1148
|
-
export const startHeaderCredentialChannel = async (dir, token, { platform = process.platform, warn } = {}) => {
|
|
1149
|
-
const value = typeof token === 'string' ? token.trim() : '';
|
|
1150
|
-
if (!value || platform === 'win32') return null;
|
|
1151
|
-
const path = join(dir, 'headers.sock');
|
|
1152
|
-
const keyPath = join(dir, 'headers.key');
|
|
1153
|
-
if (Buffer.byteLength(path) > MAX_SOCKET_PATH) {
|
|
1154
|
-
warn?.(`the header credential socket path is too long (${path}), so this turn keeps its credential file for its HTTP servers`);
|
|
1155
|
-
return null;
|
|
1156
|
-
}
|
|
1157
|
-
const key = randomBytes(32).toString('hex');
|
|
1158
|
-
const expected = Buffer.from(key);
|
|
1159
|
-
const sockets = new Set();
|
|
1160
|
-
const server = createServer((socket) => {
|
|
1161
|
-
sockets.add(socket);
|
|
1162
|
-
socket.on('close', () => sockets.delete(socket));
|
|
1163
|
-
socket.on('error', () => {});
|
|
1164
|
-
socket.setTimeout(CHANNEL_IDLE_MS, () => socket.destroy());
|
|
1165
|
-
socket.setEncoding('utf8');
|
|
1166
|
-
let received = '';
|
|
1167
|
-
socket.on('data', (chunk) => {
|
|
1168
|
-
received += chunk;
|
|
1169
|
-
const newline = received.indexOf('\n');
|
|
1170
|
-
if (newline === -1) {
|
|
1171
|
-
if (received.length > key.length) socket.destroy();
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
const sent = Buffer.from(received.slice(0, newline));
|
|
1175
|
-
socket.removeAllListeners('data');
|
|
1176
|
-
if (sent.length === expected.length && timingSafeEqual(sent, expected)) socket.end(value);
|
|
1177
|
-
else socket.destroy();
|
|
1178
|
-
});
|
|
1179
|
-
});
|
|
1180
|
-
// Ends every open connection too: server.close() alone waits for them.
|
|
1181
|
-
const close = () => new Promise((resolve) => {
|
|
1182
|
-
for (const socket of sockets) socket.destroy();
|
|
1183
|
-
server.close(() => resolve());
|
|
1184
|
-
});
|
|
1185
|
-
try {
|
|
1186
|
-
await writePrivateFile(keyPath, key);
|
|
1187
|
-
await new Promise((resolve, reject) => {
|
|
1188
|
-
server.once('error', reject);
|
|
1189
|
-
server.listen(path, () => { server.off('error', reject); resolve(); });
|
|
1190
|
-
});
|
|
1191
|
-
server.unref();
|
|
1192
|
-
await chmod(path, 0o600);
|
|
1193
|
-
} catch (err) {
|
|
1194
|
-
if (server.listening) await close();
|
|
1195
|
-
await rm(keyPath, { force: true }).catch(() => {});
|
|
1196
|
-
warn?.(`the header credential socket could not be set up (${err?.code || err?.message || err}), so this turn keeps its credential file for its HTTP servers`);
|
|
1197
|
-
return null;
|
|
1198
|
-
}
|
|
1199
|
-
return { path, keyPath, close };
|
|
1200
|
-
};
|
|
1201
|
-
|
|
1202
|
-
// Claude's headersHelper is invoked when an HTTP connection is established and
|
|
1203
|
-
// again after an authentication retry. Keep the token out of its config and
|
|
1204
|
-
// environment: the helper asks the channel above when there is one, and reads
|
|
1205
|
-
// the current per-spawn file otherwise, on every invocation.
|
|
1206
|
-
const createTokenHeaderHelpers = async (mcpServers, dir, channel = null) => {
|
|
1207
|
-
const servers = mcpServers
|
|
1208
|
-
.map((server, index) => ({ server, index, templates: getTokenHeaderTemplates(server) }))
|
|
1209
|
-
.filter((item) => item.templates);
|
|
1210
|
-
if (servers.length === 0) return new Map();
|
|
1211
|
-
|
|
1212
|
-
const scriptPath = join(dir, 'headers-helper.mjs');
|
|
1213
|
-
await writePrivateFile(scriptPath, HEADER_TOKEN_HELPER_SOURCE);
|
|
1214
|
-
const helpers = new Map();
|
|
1215
|
-
for (const { server, index, templates } of servers) {
|
|
1216
|
-
const templatesPath = join(dir, `headers-${index}.json`);
|
|
1217
|
-
await writePrivateFile(templatesPath, JSON.stringify(templates));
|
|
1218
|
-
helpers.set(server.name, {
|
|
1219
|
-
command: [
|
|
1220
|
-
QUOTE_SHELL_ARGUMENT(process.execPath),
|
|
1221
|
-
QUOTE_SHELL_ARGUMENT(scriptPath),
|
|
1222
|
-
QUOTE_SHELL_ARGUMENT(templatesPath),
|
|
1223
|
-
...(channel ? [QUOTE_SHELL_ARGUMENT(channel.path), QUOTE_SHELL_ARGUMENT(channel.keyPath)] : []),
|
|
1224
|
-
].join(' '),
|
|
1225
|
-
headerNames: Object.keys(templates),
|
|
1226
|
-
});
|
|
1227
|
-
}
|
|
1228
|
-
return helpers;
|
|
1229
|
-
};
|
|
1230
|
-
|
|
1231
|
-
const buildMcpConfig = (mcpServers, ctx = {}) => {
|
|
1232
|
-
// Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
|
|
1233
|
-
// config, which claude's `--mcp-config` reads directly.
|
|
1234
|
-
const mcpServersMap = {};
|
|
1235
|
-
for (const server of mcpServers) {
|
|
1236
|
-
// TASK-174: the command this seat will EXECUTE, not the one it declared —
|
|
1237
|
-
// `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>` when the seat's
|
|
1238
|
-
// MCP home has a warmed build, and otherwise the unchanged declaration. The
|
|
1239
|
-
// credential channel below is decided on that same command, so the two can
|
|
1240
|
-
// never disagree about which release is running.
|
|
1241
|
-
const spawnCommand = server.command
|
|
1242
|
-
? prepareMcpSpawn(server.command, {
|
|
1243
|
-
apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat,
|
|
1244
|
-
})
|
|
1245
|
-
: null;
|
|
1246
|
-
if (server.command && !spawnCommand) {
|
|
1247
|
-
ctx.unavailableMcp?.push(server.name);
|
|
1248
|
-
continue;
|
|
1249
|
-
}
|
|
1250
|
-
const executed = spawnCommand ? { ...server, command: spawnCommand } : server;
|
|
1251
|
-
const entry = { type: server.transport || 'stdio' };
|
|
1252
|
-
if (server.url) entry.url = server.url;
|
|
1253
|
-
if (spawnCommand) {
|
|
1254
|
-
// Win32 only: claude spawns this without a shell, so an `npx.cmd` is
|
|
1255
|
-
// resolved to its target here (#2141). Identity on every other platform.
|
|
1256
|
-
const [command, ...args] = resolveMcpCommand(spawnCommand, ctx.commandResolution, {
|
|
1257
|
-
label: 'claude', name: server.name,
|
|
1258
|
-
});
|
|
1259
|
-
entry.command = command;
|
|
1260
|
-
if (args.length) entry.args = args;
|
|
1261
|
-
}
|
|
1262
|
-
// A declared credential is rewritten to the file channel here, in the
|
|
1263
|
-
// config claude actually reads, so the value never has to exist in the
|
|
1264
|
-
// runtime's environment at all (TASK-083).
|
|
1265
|
-
if (server.env) {
|
|
1266
|
-
entry.env = deliverSeatCredential(executed, {
|
|
1267
|
-
credentialFile: ctx.credentialFile,
|
|
1268
|
-
label: 'claude',
|
|
1269
|
-
}).env;
|
|
1270
|
-
}
|
|
1271
|
-
// F460: on a public seat our server names the file it writes once it has
|
|
1272
|
-
// read the credential (a path, not a secret). An older warm build refuses.
|
|
1273
|
-
const readyPath = spawnCommand ? commonlyMcpReadyFile(server, spawnCommand, ctx, { env: entry.env }) : null;
|
|
1274
|
-
if (readyPath) entry.env = { ...(entry.env || {}), [MCP_READY_FILE_ENV]: readyPath };
|
|
1275
|
-
const headerHelper = ctx.dynamicHeaderHelpers?.get(server.name);
|
|
1276
|
-
if (server.headers) {
|
|
1277
|
-
const headers = { ...server.headers };
|
|
1278
|
-
for (const headerName of headerHelper?.headerNames || []) delete headers[headerName];
|
|
1279
|
-
if (Object.keys(headers).length > 0 || !headerHelper) entry.headers = headers;
|
|
1280
|
-
}
|
|
1281
|
-
if (headerHelper) entry.headersHelper = headerHelper.command;
|
|
1282
|
-
mcpServersMap[server.name] = entry;
|
|
1283
|
-
const versionDir = ctx.publicSeat && selectedMcpVersionDir(spawnCommand);
|
|
1284
|
-
if (versionDir) ctx.mcpReadDirectories?.push(versionDir);
|
|
1285
|
-
}
|
|
1286
|
-
return { mcpServers: mcpServersMap };
|
|
1287
|
-
};
|
|
1288
|
-
|
|
1289
|
-
// Materialized once per spawn outside the workspace, then removed in the
|
|
1290
|
-
// spawn's finally block. The JSON contains placeholders, never resolved
|
|
1291
|
-
// Commonly credentials; Claude expands them from its one-run environment.
|
|
1292
|
-
// Directory/file modes stay strict because this config still declares the
|
|
1293
|
-
// agent's trusted MCP capabilities and endpoints.
|
|
1294
|
-
const createMcpConfig = async (mcpServers, ctx = {}) => {
|
|
1295
|
-
const dir = await mkdtemp(join(tmpdir(), 'commonly-claude-mcp-'));
|
|
1296
|
-
const file = join(dir, 'mcp-config.json');
|
|
1297
|
-
let credential = null;
|
|
1298
|
-
let headerChannel = null;
|
|
1299
|
-
try {
|
|
1300
|
-
await chmod(dir, 0o700);
|
|
1301
|
-
// Written BEFORE the config, because the config may only reference it. It
|
|
1302
|
-
// lives inside `dir` so the spawn's existing finally removes it with the
|
|
1303
|
-
// config — no second lifecycle to get wrong — and so it is inside the one
|
|
1304
|
-
// non-workspace path the Seatbelt profile admits.
|
|
1305
|
-
credential = writeCredentialFile(ctx.runtimeToken, { agentName: ctx.agentName || 'agent', root: dir });
|
|
1306
|
-
// F461: only a seat that will release its credential (a public one, which
|
|
1307
|
-
// names a ready-file folder) needs the token from somewhere else.
|
|
1308
|
-
const wantsChannel = Boolean(credential?.path && ctx.readyFileDir)
|
|
1309
|
-
&& mcpServers.some((server) => getTokenHeaderTemplates(server));
|
|
1310
|
-
if (wantsChannel) {
|
|
1311
|
-
headerChannel = await startHeaderCredentialChannel(dir, ctx.runtimeToken, {
|
|
1312
|
-
platform: ctx.platform,
|
|
1313
|
-
// eslint-disable-next-line no-console
|
|
1314
|
-
warn: (text) => console.warn(`[claude] ${text}`),
|
|
1315
|
-
});
|
|
1316
|
-
}
|
|
1317
|
-
const dynamicHeaderHelpers = credential?.path
|
|
1318
|
-
? await createTokenHeaderHelpers(mcpServers, dir, headerChannel)
|
|
1319
|
-
: new Map();
|
|
1320
|
-
const unavailableMcp = [];
|
|
1321
|
-
const mcpReadDirectories = [];
|
|
1322
|
-
const config = buildMcpConfig(mcpServers, {
|
|
1323
|
-
...ctx,
|
|
1324
|
-
credentialFile: credential?.path || null,
|
|
1325
|
-
dynamicHeaderHelpers,
|
|
1326
|
-
unavailableMcp,
|
|
1327
|
-
mcpReadDirectories,
|
|
1328
|
-
});
|
|
1329
|
-
await writeFile(
|
|
1330
|
-
file,
|
|
1331
|
-
JSON.stringify(config, null, 2),
|
|
1332
|
-
{ encoding: 'utf8', mode: 0o600 },
|
|
1333
|
-
);
|
|
1334
|
-
// writeFile's mode only applies when creating. Pin the final mode too so
|
|
1335
|
-
// this stays correct if the implementation ever starts reusing the path.
|
|
1336
|
-
await chmod(file, 0o600);
|
|
1337
|
-
const expansionEnv = buildMcpExpansionEnv(config, {
|
|
1338
|
-
...ctx,
|
|
1339
|
-
credentialFile: credential?.path || null,
|
|
1340
|
-
headersHelperUsesCredentialFile: dynamicHeaderHelpers.size > 0 && !headerChannel,
|
|
1341
|
-
});
|
|
1342
|
-
if (expansionEnv[CREDENTIAL_KEY]) {
|
|
1343
|
-
// eslint-disable-next-line no-console
|
|
1344
|
-
console.warn(
|
|
1345
|
-
`[claude] this declaration references ${CREDENTIAL_KEY} in a field claude `
|
|
1346
|
-
+ 'substitutes literally (args, url, unsupported headers, or a larger string), so the '
|
|
1347
|
-
+ "value stays in claude's environment for this spawn and every MCP child "
|
|
1348
|
-
+ `inherits it. Put the credential on the entry's env to get the file channel.`,
|
|
1349
|
-
);
|
|
1350
|
-
}
|
|
1351
|
-
return {
|
|
1352
|
-
dir, file, credential, expansionEnv, unavailableMcp, mcpReadDirectories, headerChannel,
|
|
1353
|
-
// A headersHelper that reads the file on every HTTP (re)connect keeps it
|
|
1354
|
-
// for the turn. One on the channel (F461) does not.
|
|
1355
|
-
helpersReadCredentialFile: dynamicHeaderHelpers.size > 0 && !headerChannel,
|
|
1356
|
-
};
|
|
1357
|
-
} catch (err) {
|
|
1358
|
-
try { await headerChannel?.close(); } catch { /* ignore */ }
|
|
1359
|
-
try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1360
|
-
throw err;
|
|
1361
|
-
}
|
|
1362
|
-
};
|
|
1363
|
-
|
|
1364
|
-
// ── argv preparation — environment-aware ────────────────────────────────────
|
|
1365
|
-
|
|
1366
|
-
// Claude's npm installer commonly puts the binary in ~/.local/bin, while
|
|
1367
|
-
// launchd/supervisor environments intentionally use a small PATH. Keep the
|
|
1368
|
-
// command name for the legacy spawn contract, but add this directory to the
|
|
1369
|
-
// child environment and to every detection lookup so a daemon does not report
|
|
1370
|
-
// a false "adapter unavailable" (or hit spawn ENOENT) merely because it was
|
|
1371
|
-
// started outside an interactive shell.
|
|
1372
|
-
const withClaudePath = (input) => {
|
|
1373
|
-
const output = { ...(input || process.env) };
|
|
1374
|
-
const localBin = join(homedir(), '.local', 'bin');
|
|
1375
|
-
// Windows keys are case-insensitive, and a child given both `Path` and a
|
|
1376
|
-
// new `PATH` keeps only one of them, so update the key that is already there.
|
|
1377
|
-
const key = Object.keys(output).find((k) => k.toUpperCase() === 'PATH') || 'PATH';
|
|
1378
|
-
const entries = String(output[key] || '')
|
|
1379
|
-
.split(delimiter)
|
|
1380
|
-
.filter(Boolean);
|
|
1381
|
-
if (!entries.includes(localBin)) entries.push(localBin);
|
|
1382
|
-
output[key] = entries.join(delimiter);
|
|
1383
|
-
return output;
|
|
1384
|
-
};
|
|
1385
|
-
|
|
1386
|
-
// Resolve the absolute path of `claude` so bwrap's execvp doesn't depend on
|
|
1387
|
-
// PATH being correctly populated inside the sandbox namespace. Surfaced live
|
|
1388
|
-
// during the 2026-04-17 demo validation: bwrap silently inherits parent
|
|
1389
|
-
// PATH but cannot reach the user's `~/.local/bin` without an absolute path
|
|
1390
|
-
// argv[0], even when that directory is bound read-only into the sandbox.
|
|
1391
|
-
const resolveClaudePath = (env = process.env) => {
|
|
1392
|
-
// Windows has no `which`; the shim-aware resolver finds the same file.
|
|
1393
|
-
if (process.platform === 'win32') {
|
|
1394
|
-
return resolveWindowsCommandWithReason('claude', { env: withClaudePath(env) }).source || 'claude';
|
|
1395
|
-
}
|
|
1396
|
-
// Defensive: spawnSync can return undefined under aggressive mocks (the
|
|
1397
|
-
// adapters.claude.environment.test.mjs suite stubs child_process so no real
|
|
1398
|
-
// process runs). Treat any failure mode as "use the bare command name."
|
|
1399
|
-
let which;
|
|
1400
|
-
try {
|
|
1401
|
-
which = spawnSync('which', ['claude'], {
|
|
1402
|
-
encoding: 'utf8',
|
|
1403
|
-
env: withClaudePath(env),
|
|
1404
|
-
});
|
|
1405
|
-
} catch { /* ignore */ }
|
|
1406
|
-
if (which && which.status === 0) {
|
|
1407
|
-
const p = (which.stdout || '').trim();
|
|
1408
|
-
if (p) {
|
|
1409
|
-
try {
|
|
1410
|
-
return realpathSync(p);
|
|
1411
|
-
} catch {
|
|
1412
|
-
return p;
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
return 'claude';
|
|
1417
|
-
};
|
|
1418
|
-
|
|
1419
|
-
// Where the claude this host would run is installed: the folder the Linux
|
|
1420
|
-
// workspace jail has to bind, since it binds the system binary folders only.
|
|
1421
|
-
// Null when the binary cannot be located (a bare name, a dangling link).
|
|
1422
|
-
const resolveClaudeInstall = (env) => {
|
|
1423
|
-
const claudeBin = resolveClaudePath(env);
|
|
1424
|
-
if (!isAbsolute(claudeBin)) return { claudeBin, claudeInstall: null };
|
|
1425
|
-
try {
|
|
1426
|
-
return { claudeBin, claudeInstall: dirname(realpathSync(claudeBin)) };
|
|
1427
|
-
} catch {
|
|
1428
|
-
return { claudeBin, claudeInstall: null };
|
|
1429
|
-
}
|
|
1430
|
-
};
|
|
1431
|
-
|
|
1432
|
-
// A jail that cannot carry claude would fail inside at every spawn.
|
|
1433
|
-
const claudeInstallRefusal = (claudeBin) => new Error(
|
|
1434
|
-
'Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
|
|
1435
|
-
+ `\`${claudeBin}\` could not be resolved`,
|
|
1436
|
-
);
|
|
1437
|
-
|
|
1438
|
-
/**
|
|
1439
|
-
* D245: native Windows has no OS jail, and the user chose to run without one
|
|
1440
|
-
* (`--unsandboxed`). Read from the spawn context only, set by `agent run`, never
|
|
1441
|
-
* from the environment. The seat keeps its trust, so everything the trust
|
|
1442
|
-
* brings except the jail still applies.
|
|
1443
|
-
*/
|
|
1444
|
-
const policyOnlyHost = (ctx) => ctx.hostUnconfined === true
|
|
1445
|
-
&& (ctx._platform || process.platform) === 'win32'
|
|
1446
|
-
&& isConfinedTrust(ctx.environment?.sandbox?.trust);
|
|
1447
|
-
|
|
1448
|
-
// The loopback port a provider proxy listens on, for the Seatbelt rule that
|
|
1449
|
-
// admits it. Taken from its base URL when the proxy does not report one.
|
|
1450
|
-
const proxyPort = (proxy) => {
|
|
1451
|
-
if (!proxy) return null;
|
|
1452
|
-
const port = Number(proxy.port ?? new URL(proxy.baseURL).port);
|
|
1453
|
-
return Number.isInteger(port) && port > 0 ? port : null;
|
|
1454
|
-
};
|
|
1455
|
-
|
|
1456
|
-
const prepareArgv = async (innerArgv, ctx) => {
|
|
1457
|
-
const env = ctx.environment;
|
|
1458
|
-
const claudeEnv = withClaudePath(ctx.claudeEnv);
|
|
1459
|
-
if (!env) return { cmd: 'claude', args: innerArgv, env: claudeEnv };
|
|
1460
|
-
|
|
1461
|
-
let allowedPatterns = [];
|
|
1462
|
-
if (Array.isArray(env.mcp) && env.mcp.length > 0) {
|
|
1463
|
-
if (!ctx.mcpConfigPath) {
|
|
1464
|
-
throw new Error('claude MCP config was declared but no per-spawn config path was prepared');
|
|
1465
|
-
}
|
|
1466
|
-
// Insert --mcp-config immediately after the subcommand-style `-p` block
|
|
1467
|
-
// so claude parses it before prompt collection begins.
|
|
1468
|
-
innerArgv = [...innerArgv, '--mcp-config', ctx.mcpConfigPath];
|
|
1469
|
-
// Pre-approve every MCP tool from the declared servers (`mcp__<name>__*`).
|
|
1470
|
-
// Without this claude runs in non-interactive `-p` mode and asks for
|
|
1471
|
-
// permission before invoking any MCP tool — the wrapper has no way to
|
|
1472
|
-
// approve, so the model just narrates the request and bails. The user
|
|
1473
|
-
// already opted into these servers by declaring them in the env spec, so
|
|
1474
|
-
// auto-allowing the corresponding tool prefix is the honest default.
|
|
1475
|
-
// Surfaced live during the 2026-04-17 cross-agent demo validation.
|
|
1476
|
-
allowedPatterns = env.mcp
|
|
1477
|
-
.map((server) => server && server.name)
|
|
1478
|
-
.filter(Boolean)
|
|
1479
|
-
.map((name) => `mcp__${name}__*`);
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
const sandboxTrust = env.sandbox?.trust;
|
|
1483
|
-
const sandboxMode = resolvePublicSandboxMode(env.sandbox);
|
|
1484
|
-
const grants = workspaceGrants(env.sandbox);
|
|
1485
|
-
const publicNativeSandbox = isConfinedTrust(sandboxTrust)
|
|
1486
|
-
&& PUBLIC_SANDBOX_MODES.has(sandboxMode);
|
|
1487
|
-
// A public trust is ONE contract on both platforms: the jail bounds the
|
|
1488
|
-
// filesystem and the policy bounds the tools. The bwrap branch below used to
|
|
1489
|
-
// get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
|
|
1490
|
-
// the operator's own Claude settings inside the namespace.
|
|
1491
|
-
const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
|
|
1492
|
-
// A workspace seat on Linux: the same jail, with the workspace tier's policy
|
|
1493
|
-
// and a HOME of its own outside the project.
|
|
1494
|
-
const workspaceBwrapSandbox = sandboxTrust === 'workspace' && sandboxMode === 'bwrap';
|
|
1495
|
-
const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
|
|
1496
|
-
const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
|
|
1497
|
-
const commitHookExecutables = commitHooksPath
|
|
1498
|
-
? supportedGitHooks().map((hookName) => join(commitHooksPath, hookName))
|
|
1499
|
-
: [];
|
|
1500
|
-
const originalHookExecutables = originalHooksPath
|
|
1501
|
-
? supportedGitHooks()
|
|
1502
|
-
.map((hookName) => join(originalHooksPath, hookName))
|
|
1503
|
-
.filter((hookPath) => {
|
|
1504
|
-
try {
|
|
1505
|
-
accessSync(hookPath, constants.X_OK);
|
|
1506
|
-
return true;
|
|
1507
|
-
} catch {
|
|
1508
|
-
return false;
|
|
1509
|
-
}
|
|
1510
|
-
})
|
|
1511
|
-
: [];
|
|
1512
|
-
if (policyOnlyHost(ctx)) {
|
|
1513
|
-
// The bwrap branch's policy, without the jail: on a host with no OS
|
|
1514
|
-
// isolation the tool policy is the whole boundary, so it is never dropped.
|
|
1515
|
-
innerArgv = [
|
|
1516
|
-
...innerArgv,
|
|
1517
|
-
...buildPublicClaudePolicyArgs(allowedPatterns, {
|
|
1518
|
-
...(sandboxTrust === 'workspace' ? { trust: sandboxTrust, allow: grants } : {}),
|
|
1519
|
-
memoryWithheld: ctx.memoryWithheld === true,
|
|
1520
|
-
ownerWoken: ctx.ownerWoken,
|
|
1521
|
-
platform: ctx._platform || process.platform,
|
|
1522
|
-
home: ctx._home || null,
|
|
1523
|
-
noShell: true,
|
|
1524
|
-
}),
|
|
1525
|
-
...(ctx.publicSkillArgs || []),
|
|
1526
|
-
];
|
|
1527
|
-
return { cmd: 'claude', args: innerArgv, env: claudeEnv };
|
|
1528
|
-
}
|
|
1529
|
-
// A public trust MUST resolve to an enforced mode. Absent mode is resolved
|
|
1530
|
-
// above; anything else that lands here (the literal 'none', a typo, a
|
|
1531
|
-
// non-string) used to fall through to the bare `claude` spawn below — an
|
|
1532
|
-
// unconfined seat with a public record, which is how a record can claim
|
|
1533
|
-
// confinement it never applies.
|
|
1534
|
-
if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
|
|
1535
|
-
// D245: on native Windows, name Windows and the user's own opt-in, never
|
|
1536
|
-
// macOS or bwrap advice (host-check passes an unrecognised mode through).
|
|
1537
|
-
if ((ctx._platform || process.platform) === 'win32') {
|
|
1538
|
-
throw new Error(
|
|
1539
|
-
`${sandboxTrust} Claude agents run inside a sandbox, and Space has no sandbox it can enforce on Windows `
|
|
1540
|
-
+ `(got sandbox.mode=${JSON.stringify(sandboxMode)}). To run this agent without one, which is your choice, `
|
|
1541
|
-
+ `run ${CLI} agent run <name> ${UNSANDBOXED_FLAG} (or ${CLI} daemon install ${UNSANDBOXED_FLAG} for the agents `
|
|
1542
|
-
+ 'this computer\'s daemon runs), or choose "Run it here" on the Connect page for a hosted agent.',
|
|
1543
|
-
);
|
|
1544
|
-
}
|
|
1545
|
-
throw new Error(
|
|
1546
|
-
`${sandboxTrust} Claude agents require an enforced sandbox mode `
|
|
1547
|
-
+ `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
|
|
1548
|
-
);
|
|
1549
|
-
}
|
|
1550
|
-
if (publicNativeSandbox) {
|
|
1551
|
-
if (process.platform !== 'darwin') {
|
|
1552
|
-
throw new Error(
|
|
1553
|
-
`public Claude sandbox.mode=${sandboxMode} maps to Seatbelt on macOS; `
|
|
1554
|
-
+ 'use sandbox.mode=bwrap on Linux',
|
|
1555
|
-
);
|
|
1556
|
-
}
|
|
1557
|
-
if (!ctx.publicClaudeState) {
|
|
1558
|
-
throw new Error('public Claude state was not prepared before argv construction');
|
|
1559
|
-
}
|
|
1560
|
-
innerArgv = [
|
|
1561
|
-
...innerArgv,
|
|
1562
|
-
...buildPublicClaudePolicyArgs(allowedPatterns, {
|
|
1563
|
-
trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true, ownerWoken: ctx.ownerWoken,
|
|
1564
|
-
}),
|
|
1565
|
-
...(ctx.publicSkillArgs || []),
|
|
1566
|
-
];
|
|
1567
|
-
const claudeBin = resolveClaudePath(claudeEnv);
|
|
1568
|
-
// Only stdio servers have a `command`; an HTTP entry (the grant broker)
|
|
1569
|
-
// carries a `url` instead, and `isAbsolute(undefined)` throws — every
|
|
1570
|
-
// confined spawn of a seat with a broker grant died here (2026-09-18).
|
|
1571
|
-
const mcpExecutables = (env.mcp || [])
|
|
1572
|
-
.map((server) => server?.command?.[0])
|
|
1573
|
-
.filter((command) => typeof command === 'string' && isAbsolute(command));
|
|
1574
|
-
const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], {
|
|
1575
|
-
workspacePath: ctx.cwd,
|
|
1576
|
-
workspaceAccess: sandboxMode === 'read-only'
|
|
1577
|
-
|| (sandboxTrust === 'workspace' && !grants.includes('write')) ? 'read' : 'write',
|
|
1578
|
-
readOnlyWorkspaceEntries: sandboxTrust === 'workspace' ? ['.git'] : [],
|
|
1579
|
-
// A public seat's workspace is pinned: usually the scratch folder the
|
|
1580
|
-
// CLI made, and an `environment.workspace.path` it was given that is a
|
|
1581
|
-
// link refuses the turn (fail-closed). A workspace seat's folder is the
|
|
1582
|
-
// operator's own choice, which may be a link, and keeps following it.
|
|
1583
|
-
pinWorkspace: sandboxTrust === 'public',
|
|
1584
|
-
// F200: a workspace seat's folder is held to the realpath recorded on
|
|
1585
|
-
// its first turn, and that record is never writable from the jail.
|
|
1586
|
-
expectedWorkspaceRealpath: ctx.workspaceRealpath || null,
|
|
1587
|
-
denyWritePaths: ctx.workspacePinDirs || [],
|
|
1588
|
-
executablePath: claudeBin,
|
|
1589
|
-
statePath: ctx.publicClaudeState.statePath,
|
|
1590
|
-
mcpConfigDir: ctx.mcpConfigDir,
|
|
1591
|
-
executablePaths: [
|
|
1592
|
-
...mcpExecutables,
|
|
1593
|
-
...(ctx.workspaceGit ? [ctx.workspaceGit.executable] : []),
|
|
1594
|
-
...commitHookExecutables,
|
|
1595
|
-
...originalHookExecutables,
|
|
1596
|
-
],
|
|
1597
|
-
// F151: a key seat reaches the model only through the host's loopback
|
|
1598
|
-
// proxy, so its profile admits that port and no keychain; a login seat
|
|
1599
|
-
// keeps the keychain rules and needs no port.
|
|
1600
|
-
allowClaudeRuntimeAccess: !ctx.providerProxyPort,
|
|
1601
|
-
loopbackNetworkPorts: ctx.providerProxyPort ? [ctx.providerProxyPort] : [],
|
|
1602
|
-
readOnlyDirectories: [
|
|
1603
|
-
...(ctx.mcpReadDirectories || []),
|
|
1604
|
-
...(ctx.workspaceGit?.readDirectories || []),
|
|
1605
|
-
],
|
|
1606
|
-
readOnlyPaths: ctx.workspaceGit?.readPaths || [],
|
|
1607
|
-
});
|
|
1608
|
-
return {
|
|
1609
|
-
cmd: wrapped[0],
|
|
1610
|
-
args: wrapped.slice(1),
|
|
1611
|
-
env: claudeEnv,
|
|
1612
|
-
};
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
if (publicBwrapSandbox || workspaceBwrapSandbox) {
|
|
1616
|
-
// The mode is chosen here, so the mechanism is verified here: a missing
|
|
1617
|
-
// bwrap must fail the seat's derivation with an actionable message, not
|
|
1618
|
-
// every one of its spawns with a bare ENOENT. On a non-Linux
|
|
1619
|
-
// host this reports the macOS-only message, same as the wrapper would.
|
|
1620
|
-
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
1621
|
-
if (!bwrap.available) {
|
|
1622
|
-
throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
|
|
1623
|
-
}
|
|
1624
|
-
if (!ctx.publicClaudeState) {
|
|
1625
|
-
throw new Error(`${sandboxTrust} Claude state was not prepared before argv construction`);
|
|
1626
|
-
}
|
|
1627
|
-
innerArgv = [
|
|
1628
|
-
...innerArgv,
|
|
1629
|
-
...buildPublicClaudePolicyArgs(allowedPatterns, workspaceBwrapSandbox
|
|
1630
|
-
? { trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true, ownerWoken: ctx.ownerWoken }
|
|
1631
|
-
: { memoryWithheld: ctx.memoryWithheld === true, ownerWoken: ctx.ownerWoken }),
|
|
1632
|
-
...(ctx.publicSkillArgs || []),
|
|
1633
|
-
];
|
|
1634
|
-
} else if (allowedPatterns.length > 0) {
|
|
1635
|
-
innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
|
|
1636
|
-
}
|
|
1637
|
-
if (workspaceBwrapSandbox || publicBwrapSandbox) {
|
|
1638
|
-
// Seatbelt denies writes under .git by path, present or not. A bwrap mount
|
|
1639
|
-
// needs the path to exist, so a writable project without a .git could get
|
|
1640
|
-
// one from the child, hooks included, that the person's next git command
|
|
1641
|
-
// runs. Refuse that rather than create anything in their project.
|
|
1642
|
-
if (grants.includes('write')) {
|
|
1643
|
-
const gitEntry = await statOrNull(join(ctx.cwd, '.git'));
|
|
1644
|
-
if (!gitEntry || gitEntry.isSymbolicLink()) {
|
|
1645
|
-
throw new Error('workspace Claude agents with write on Linux need the project to be a git '
|
|
1646
|
-
+ `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`);
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
const { claudeBin, claudeInstall } = resolveClaudeInstall(claudeEnv);
|
|
1650
|
-
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1651
|
-
const { statePath } = ctx.publicClaudeState;
|
|
1652
|
-
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
1653
|
-
workspacePath: ctx.cwd,
|
|
1654
|
-
// F200: bwrap resolves a bind source when the jail starts, after the
|
|
1655
|
-
// check, so the checked realpath is the source, mounted at the path the
|
|
1656
|
-
// seat was given.
|
|
1657
|
-
workspaceSource: ctx.workspaceRealpath || ctx.cwd,
|
|
1658
|
-
workspaceReadOnly: !grants.includes('write'),
|
|
1659
|
-
readOnlyWorkspaceEntries: ['.git'],
|
|
1660
|
-
homePath: statePath,
|
|
1661
|
-
// .claude (the login copy) and tmp are bound again on their own, after
|
|
1662
|
-
// the state folder: a mount point cannot be renamed or replaced from
|
|
1663
|
-
// inside the jail (EBUSY), so a child, this turn's or a concurrent one's,
|
|
1664
|
-
// cannot swap either for a link while the host writes or removes the
|
|
1665
|
-
// login there. The state preparation made both real directories.
|
|
1666
|
-
writablePaths: [statePath, join(statePath, '.claude'), join(statePath, 'tmp')],
|
|
1667
|
-
readOnlyPaths: [
|
|
1668
|
-
ctx.mcpConfigDir, commitHooksPath, originalHooksPath,
|
|
1669
|
-
...(ctx.mcpReadDirectories || []),
|
|
1670
|
-
...(ctx.workspaceGit?.readDirectories || []),
|
|
1671
|
-
...(ctx.workspaceGit?.readPaths || []),
|
|
1672
|
-
].filter(Boolean),
|
|
1673
|
-
// The binaries the jail runs: claude (a user-local install is outside
|
|
1674
|
-
// the default binds, and its link points into a versions folder), the
|
|
1675
|
-
// node that runs the stdio MCP servers, and the git the history tool runs.
|
|
1676
|
-
optionalReadOnlyPaths: [...new Set([
|
|
1677
|
-
dirname(claudeBin), claudeInstall, dirname(process.execPath),
|
|
1678
|
-
ctx.workspaceGit ? dirname(ctx.workspaceGit.executable) : null,
|
|
1679
|
-
// A claude not found on PATH is left bare and fails at spawn; its
|
|
1680
|
-
// dirname is '.', which is not a path to bind.
|
|
1681
|
-
].filter((p) => p && isAbsolute(p)))],
|
|
1682
|
-
});
|
|
1683
|
-
// TASK-440 reads the transcript from the HOME the child wrote under.
|
|
1684
|
-
return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: statePath };
|
|
1685
|
-
}
|
|
1686
|
-
if (sandboxMode === 'bwrap') {
|
|
1687
|
-
const claudeBin = resolveClaudePath(claudeEnv);
|
|
1688
|
-
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
1689
|
-
workspacePath: ctx.cwd,
|
|
1690
|
-
readOnlyPaths: [ctx.mcpConfigDir, commitHooksPath, originalHooksPath, ...(ctx.mcpReadDirectories || [])].filter(Boolean),
|
|
1691
|
-
});
|
|
1692
|
-
// bwrap sets the child's HOME to the workspace (wrapArgvWithBwrap's
|
|
1693
|
-
// default), so the child's transcript lands under the workspace, not the
|
|
1694
|
-
// HOME the host process sees. TASK-440 reads from here.
|
|
1695
|
-
return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: ctx.cwd };
|
|
1696
|
-
}
|
|
1697
|
-
return { cmd: 'claude', args: innerArgv, env: claudeEnv };
|
|
1698
|
-
};
|
|
1699
|
-
|
|
1700
|
-
export default {
|
|
1701
|
-
get requiresModel() { return seatModelRequired(); },
|
|
1702
|
-
name: 'claude',
|
|
1703
|
-
// Identity-bearing runtime tag written into AgentInstallation.config.runtime
|
|
1704
|
-
// by `commonly agent attach`. The CLI command stays `commonly agent attach
|
|
1705
|
-
// claude` (ergonomic) but the persisted runtimeType matches AGENT_TYPES so
|
|
1706
|
-
// the inspector + API can classify it as the same thing as cloud Claude
|
|
1707
|
-
// Code. Pair with `host: 'byo'` (set in agent.js) to disambiguate from the
|
|
1708
|
-
// hosted variant.
|
|
1709
|
-
runtimeType: 'claude-code',
|
|
1710
|
-
|
|
1711
|
-
inventory: () => claudeRuntimeInventory({ env: withClaudePath(process.env) }),
|
|
1712
|
-
|
|
1713
|
-
// `opts` is a test seam only: `_commandResolution` overrides the Windows
|
|
1714
|
-
// resolver's platform/env/fs. Production calls detect() with no argument.
|
|
1715
|
-
async detect(opts = {}) {
|
|
1716
|
-
try {
|
|
1717
|
-
const claudeEnv = withClaudePath(process.env);
|
|
1718
|
-
const resolution = { env: claudeEnv, ...opts._commandResolution };
|
|
1719
|
-
const located = resolveWindowsCommandWithReason('claude', resolution);
|
|
1720
|
-
if (!located.command) {
|
|
1721
|
-
// Windows only: a `claude.cmd` this CLI cannot read safely, or none.
|
|
1722
|
-
process.stderr.write(`claude adapter: ${located.reason}\n`);
|
|
1723
|
-
return null;
|
|
1724
|
-
}
|
|
1725
|
-
const res = spawnSync(located.command, [...located.args, '--version'], {
|
|
1726
|
-
encoding: 'utf8', timeout: 5000,
|
|
1727
|
-
env: claudeEnv,
|
|
1728
|
-
});
|
|
1729
|
-
if (res.error || res.status !== 0) return null;
|
|
1730
|
-
// `claude --version` prints e.g. "2.5.1 (Claude Code)" — first token is enough
|
|
1731
|
-
const version = (res.stdout || '').trim().split(/\s+/)[0] || 'unknown';
|
|
1732
|
-
// Best-effort resolve of the binary path for clearer UX ("claude detected
|
|
1733
|
-
// at /usr/local/bin/claude"). Falls back to the bare command name on
|
|
1734
|
-
// platforms without `which`. On Windows the resolver already found it.
|
|
1735
|
-
if (located.source) return { path: located.source, version };
|
|
1736
|
-
const where = spawnSync('which', ['claude'], {
|
|
1737
|
-
encoding: 'utf8',
|
|
1738
|
-
env: claudeEnv,
|
|
1739
|
-
});
|
|
1740
|
-
const path = where.status === 0 ? (where.stdout || '').trim() || 'claude' : 'claude';
|
|
1741
|
-
return { path, version };
|
|
1742
|
-
} catch {
|
|
1743
|
-
return null;
|
|
1744
|
-
}
|
|
1745
|
-
},
|
|
1746
|
-
|
|
1747
|
-
async spawn(prompt, rawCtx = {}) {
|
|
1748
|
-
const seatModel = requireSeatModel(rawCtx.environment, 'claude');
|
|
1749
|
-
// `sandbox.trust: 'internal'` is refused for new declarations and read as
|
|
1750
|
-
// `public` for a record that already carries it; such a seat is confined
|
|
1751
|
-
// where it can be and refuses to derive where it cannot.
|
|
1752
|
-
// Resolved ONCE here and threaded to BOTH consumers — the public-state
|
|
1753
|
-
// preparation below and the argv builder — because two independent reads of
|
|
1754
|
-
// one declaration is exactly how this seat crashed with "public Claude
|
|
1755
|
-
// state was not prepared" instead of spawning confined.
|
|
1756
|
-
const ctx = rawCtx.environment
|
|
1757
|
-
? withWorkspaceGit({
|
|
1758
|
-
...rawCtx,
|
|
1759
|
-
environment: {
|
|
1760
|
-
...rawCtx.environment,
|
|
1761
|
-
sandbox: normalizeSandboxTrust(rawCtx.environment.sandbox),
|
|
1762
|
-
},
|
|
1763
|
-
})
|
|
1764
|
-
: rawCtx;
|
|
1765
|
-
if (isLegacySandboxTrust(rawCtx.environment?.sandbox)) {
|
|
1766
|
-
// eslint-disable-next-line no-console
|
|
1767
|
-
console.warn(
|
|
1768
|
-
'[claude] sandbox.trust=internal is no longer accepted: it reads as '
|
|
1769
|
-
+ 'confinement and engaged none. Resolving this seat as trust=public, so '
|
|
1770
|
-
+ 'it is confined or it refuses to derive — never unconfined (Wren 69585).',
|
|
1771
|
-
);
|
|
1772
|
-
}
|
|
1773
|
-
const isResume = !!ctx.sessionId;
|
|
1774
|
-
const sessionId = ctx.sessionId || randomUUID();
|
|
1775
|
-
// Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
|
|
1776
|
-
// `null || ''` is `''` — which routed the unreadable case straight into
|
|
1777
|
-
// the empty-memory branch and made the whole distinction unreachable from
|
|
1778
|
-
// the only two paths that build a real prompt. buildPrompt handles
|
|
1779
|
-
// undefined and '' as absence itself; it does not need a guard, it needs
|
|
1780
|
-
// the value.
|
|
1781
|
-
const publicTrust = isConfinedTrust(ctx.environment?.sandbox?.trust);
|
|
1782
|
-
// Only an MCP seat has a per-turn folder (createMcpConfig). Before skills
|
|
1783
|
-
// mount, because they write into the workspace.
|
|
1784
|
-
if (publicTrust && Array.isArray(ctx.environment?.mcp) && ctx.environment.mcp.length > 0) {
|
|
1785
|
-
await refuseWorkspaceHoldingTurnDirs({
|
|
1786
|
-
workspace: ctx.cwd, seat: `${ctx.environment.sandbox.trust} Claude agents`,
|
|
1787
|
-
});
|
|
1788
|
-
}
|
|
1789
|
-
// Only a stranger-facing seat is witnessed (TASK-256). A workspace seat is
|
|
1790
|
-
// confined too, but it answers its own team, and an outage it narrates
|
|
1791
|
-
// there is a signal, not a leak.
|
|
1792
|
-
const witnessedMcp = ctx.environment?.sandbox?.trust === 'public' ? ctx.environment?.mcp : null;
|
|
1793
|
-
// A stranger-facing seat keeps nothing a turn leaves running, whatever the
|
|
1794
|
-
// turn set (F197). A workspace seat keeps the setting for its own team's
|
|
1795
|
-
// long-running work in its own project.
|
|
1796
|
-
const reapEverything = ctx.environment?.sandbox?.trust === 'public';
|
|
1797
|
-
const workCues = publicTrust ? [] : WORK_CUES;
|
|
1798
|
-
// F118, F145: a public seat reads its memory, and is offered a write only
|
|
1799
|
-
// on a turn its owner woke.
|
|
1800
|
-
const memoryReadOnly = ctx.environment?.sandbox?.trust === 'public' && ctx.ownerWoken !== true;
|
|
1801
|
-
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
1802
|
-
freshSession: !isResume,
|
|
1803
|
-
workCues,
|
|
1804
|
-
memoryWithheld: ctx.memoryWithheld === true,
|
|
1805
|
-
memoryReadOnly,
|
|
1806
|
-
});
|
|
1807
|
-
const sessionFlag = isResume ? '--resume' : '--session-id';
|
|
1808
|
-
// Model pin from the ADR-008 environment spec. Absent it, claude picks its
|
|
1809
|
-
// own default — which is how a fleet of ten agents ended up running three
|
|
1810
|
-
// different Opus versions that nobody chose and nothing recorded, with one
|
|
1811
|
-
// seat named after a model it does not run.
|
|
1812
|
-
//
|
|
1813
|
-
// Computed ONCE and spread into BOTH arg builders. The session-recovery
|
|
1814
|
-
// path at the bottom of this function constructs its own array, and a model
|
|
1815
|
-
// present in one but not the other means a retry silently runs a different
|
|
1816
|
-
// model than the turn it is replacing — the same drifting-copy shape that
|
|
1817
|
-
// has bitten this codebase repeatedly.
|
|
1818
|
-
// `effort` rides in the same array for the same reason: an order
|
|
1819
|
-
// like "flip all fable agents into fable 5.1 with high or above
|
|
1820
|
-
// effort" needs both facts to reach every spawn, including the
|
|
1821
|
-
// session-recovery retry, or a retry runs at a different effort than the
|
|
1822
|
-
// turn it replaces.
|
|
1823
|
-
const modelArgs = [
|
|
1824
|
-
...(seatModel ? ['--model', seatModel] : []),
|
|
1825
|
-
...(ctx.environment?.effort ? ['--effort', String(ctx.environment.effort)] : []),
|
|
1826
|
-
];
|
|
1827
|
-
const baseArgs = ['-p', fullPrompt, ...STREAM_ARGS, sessionFlag, sessionId, ...modelArgs];
|
|
1828
|
-
|
|
1829
|
-
let mountedSkills = [];
|
|
1830
|
-
let skillMount = { mounted: [], conflicted: [] };
|
|
1831
|
-
// A workspace seat's folder is the person's project, and mountSkills writes
|
|
1832
|
-
// from this host process, outside any jail. Its skills are copied to the
|
|
1833
|
-
// run loop's scratch folder instead and reach it as prompt text.
|
|
1834
|
-
const skillDir = ctx.environment?.sandbox?.trust === 'workspace' ? ctx.skillMountDir : ctx.cwd;
|
|
1835
|
-
if (ctx.environment && skillDir) {
|
|
1836
|
-
const skills = await mountSkills(ctx.environment, skillDir);
|
|
1837
|
-
mountedSkills = skills.mounted;
|
|
1838
|
-
skillMount = skills;
|
|
1839
|
-
if (skills.conflicted.length > 0) {
|
|
1840
|
-
for (const c of skills.conflicted) {
|
|
1841
|
-
// eslint-disable-next-line no-console
|
|
1842
|
-
console.warn(`[claude] skill not mounted (${c.reason}): ${c.path}`);
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
let mcpConfig = null;
|
|
1848
|
-
let publicClaudeState = null;
|
|
1849
|
-
// F460: a public seat's credential goes once every MCP reader has it.
|
|
1850
|
-
let readyFileDir = null;
|
|
1851
|
-
let ownReadyDir = null;
|
|
1852
|
-
const readyFiles = [];
|
|
1853
|
-
let credentialRelease = null;
|
|
1854
|
-
let releaseFailure = null;
|
|
1855
|
-
let claudeChild = null;
|
|
1856
|
-
const spawnImpl = (...spawnCall) => {
|
|
1857
|
-
claudeChild = (ctx._spawnImpl || childSpawn)(...spawnCall);
|
|
1858
|
-
return claudeChild;
|
|
1859
|
-
};
|
|
1860
|
-
const armRelease = () => {
|
|
1861
|
-
if (!readyFileDir || !mcpConfig?.credential || mcpConfig.helpersReadCredentialFile) return;
|
|
1862
|
-
credentialRelease = armCredentialRelease(mcpConfig.credential, {
|
|
1863
|
-
readyFiles: readyFiles.map(({ path }) => path),
|
|
1864
|
-
label: 'public Claude seat',
|
|
1865
|
-
// eslint-disable-next-line no-console
|
|
1866
|
-
warn: (text) => console.warn(`[claude] ${text}`),
|
|
1867
|
-
onFailure: (failure) => { releaseFailure = failure; claudeChild?.kill?.('SIGKILL'); },
|
|
1868
|
-
...ctx._credentialRelease,
|
|
1869
|
-
});
|
|
1870
|
-
// A delete that failed at arm time ends the turn before Claude starts.
|
|
1871
|
-
if (credentialRelease.error) throw credentialRelease.error;
|
|
1872
|
-
};
|
|
1873
|
-
let providerProxy = null;
|
|
1874
|
-
// A login seat's gateway, when the host names one: config, not a secret.
|
|
1875
|
-
let publicLoginBaseUrl = null;
|
|
1876
|
-
let loginTurn = null;
|
|
1877
|
-
let publicSkillArgs = [];
|
|
1878
|
-
// D245: no jail to prepare for on a policy-only Windows host.
|
|
1879
|
-
const jailed = !policyOnlyHost(ctx);
|
|
1880
|
-
const publicNativeSandbox = jailed && publicTrust
|
|
1881
|
-
&& PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
|
|
1882
|
-
const publicBwrapSandbox = jailed && publicTrust
|
|
1883
|
-
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
1884
|
-
const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
|
|
1885
|
-
&& resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
|
|
1886
|
-
// F200: a jailed workspace seat's folder must still resolve where it did
|
|
1887
|
-
// on the seat's first turn, or a link the seat left in its place would
|
|
1888
|
-
// carry this turn's grant elsewhere. The record sits beside the seat's
|
|
1889
|
-
// state folder, the one bwrapClaudeStatePath names for both jails.
|
|
1890
|
-
const heldWorkspace = ctx.environment?.sandbox?.trust === 'workspace' && Boolean(ctx.cwd)
|
|
1891
|
-
&& (publicNativeSandbox || workspaceBwrapSandbox);
|
|
1892
|
-
const workspacePinDirs = heldWorkspace ? [workspacePinDir(bwrapClaudeStatePath(ctx))] : [];
|
|
1893
|
-
let workspaceRealpath = null;
|
|
1894
|
-
// The proxy's address and per-turn token, never the host's key.
|
|
1895
|
-
const withProvider = (env) => {
|
|
1896
|
-
if (providerProxy) {
|
|
1897
|
-
return Object.assign(env, { ANTHROPIC_BASE_URL: providerProxy.baseURL, ANTHROPIC_API_KEY: providerProxy.token });
|
|
1898
|
-
}
|
|
1899
|
-
return publicLoginBaseUrl ? Object.assign(env, { ANTHROPIC_BASE_URL: publicLoginBaseUrl }) : env;
|
|
1900
|
-
};
|
|
1901
|
-
// The values a failure tail or reply must never echo: the seat's own, and
|
|
1902
|
-
// the per-turn proxy token the child was handed in place of the host key.
|
|
1903
|
-
const turnCredentials = (spawnContext) => [
|
|
1904
|
-
...spawnCredentials(spawnContext), providerProxy?.token,
|
|
1905
|
-
].filter(Boolean);
|
|
1906
|
-
try {
|
|
1907
|
-
// Checked (and recorded, the first time) before the state is prepared
|
|
1908
|
-
// or anything is spawned.
|
|
1909
|
-
if (heldWorkspace) {
|
|
1910
|
-
workspaceRealpath = await holdWorkspaceRealpath({ cwd: ctx.cwd, statePath: bwrapClaudeStatePath(ctx) });
|
|
1911
|
-
}
|
|
1912
|
-
// Trust is normalized at entry (legacy `internal` reads as public). The
|
|
1913
|
-
// MCP policy follows trust alone, whatever confines the seat: a public
|
|
1914
|
-
// bwrap seat needs a host-prepared build exactly as a Seatbelt one does
|
|
1915
|
-
// (#2247).
|
|
1916
|
-
if (publicNativeSandbox) {
|
|
1917
|
-
// F151: Seatbelt strips the environment to the safe list, so a seat
|
|
1918
|
-
// configured with an API key used to start with neither key nor login
|
|
1919
|
-
// ("Not logged in"). The key stays on the host behind a per-turn
|
|
1920
|
-
// loopback proxy, as on bwrap; the jail gets the proxy's address and
|
|
1921
|
-
// token, and no keychain. Without a key, the keychain login as before.
|
|
1922
|
-
const source = ctx.env || process.env;
|
|
1923
|
-
if (source.ANTHROPIC_API_KEY) {
|
|
1924
|
-
publicClaudeState = await preparePublicClaudeState(ctx, { keychain: false });
|
|
1925
|
-
providerProxy = await (ctx._startProviderProxy || startProviderProxy)({
|
|
1926
|
-
provider: { baseURL: source.ANTHROPIC_BASE_URL || 'https://api.anthropic.com' },
|
|
1927
|
-
keyMaterial: source.ANTHROPIC_API_KEY,
|
|
1928
|
-
wire: 'anthropic-messages',
|
|
1929
|
-
adapterLabel: 'Claude',
|
|
1930
|
-
});
|
|
1931
|
-
} else {
|
|
1932
|
-
publicClaudeState = await preparePublicClaudeState(ctx);
|
|
1933
|
-
}
|
|
1934
|
-
} else if (workspaceBwrapSandbox) {
|
|
1935
|
-
// Checked before the state is prepared, so a host that cannot run the
|
|
1936
|
-
// jail never gets a copy of the login.
|
|
1937
|
-
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
1938
|
-
if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
|
|
1939
|
-
const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
|
|
1940
|
-
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1941
|
-
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only' });
|
|
1942
|
-
// The host's model route, when it is the one the login uses.
|
|
1943
|
-
publicLoginBaseUrl = loginRouteBaseUrl(ctx.env || process.env);
|
|
1944
|
-
} else if (publicBwrapSandbox) {
|
|
1945
|
-
// F96: the workspace tier's jail, reaching the model one of two ways.
|
|
1946
|
-
// An API key stays on the host behind a per-turn proxy; without one,
|
|
1947
|
-
// the seat gets an access-only copy of the login, deleted after the turn.
|
|
1948
|
-
// Either way the model is across the network: restricted networking
|
|
1949
|
-
// (--unshare-net) cuts off the host proxy and the vendor alike, so the
|
|
1950
|
-
// seat is refused before a proxy starts or a login is copied.
|
|
1951
|
-
if (ctx.environment?.sandbox?.network?.policy === 'restricted') {
|
|
1952
|
-
throw new Error('public Claude agents on Linux need shared bwrap networking to reach the model; '
|
|
1953
|
-
+ 'sandbox.network.policy "restricted" cuts it off.');
|
|
1954
|
-
}
|
|
1955
|
-
// A public seat sees its project and its own folder, nothing more: the
|
|
1956
|
-
// server's environment cannot bind more of the host into a jail that
|
|
1957
|
-
// strangers drive (goose refuses the same).
|
|
1958
|
-
for (const key of ['read-outside', 'write-outside']) {
|
|
1959
|
-
if (ctx.environment?.sandbox?.filesystem?.[key]?.length) {
|
|
1960
|
-
throw new Error(`public Claude agents on Linux do not allow sandbox.filesystem.${key}`);
|
|
1961
|
-
}
|
|
1962
|
-
}
|
|
1963
|
-
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
1964
|
-
if (!bwrap.available) throw new Error(`public Claude agents require bwrap: ${bwrap.error}`);
|
|
1965
|
-
const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
|
|
1966
|
-
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1967
|
-
const source = ctx.env || process.env;
|
|
1968
|
-
if (source.ANTHROPIC_API_KEY) {
|
|
1969
|
-
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'none', seat: 'public' });
|
|
1970
|
-
providerProxy = await (ctx._startProviderProxy || startProviderProxy)({
|
|
1971
|
-
provider: { baseURL: source.ANTHROPIC_BASE_URL || 'https://api.anthropic.com' },
|
|
1972
|
-
keyMaterial: source.ANTHROPIC_API_KEY,
|
|
1973
|
-
wire: 'anthropic-messages',
|
|
1974
|
-
adapterLabel: 'Claude',
|
|
1975
|
-
});
|
|
1976
|
-
} else {
|
|
1977
|
-
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only', seat: 'public' });
|
|
1978
|
-
publicLoginBaseUrl = loginRouteBaseUrl(source);
|
|
1979
|
-
if (!publicClaudeState.loginPath) {
|
|
1980
|
-
throw new Error('public Claude agents on Linux need ANTHROPIC_API_KEY in the agent\'s environment, '
|
|
1981
|
-
+ 'or sign in with `claude` on this machine first.');
|
|
1982
|
-
}
|
|
1983
|
-
loginTurn = await registerLoginTurn(publicClaudeState.statePath);
|
|
1984
|
-
}
|
|
1985
|
-
}
|
|
1986
|
-
if (publicTrust) {
|
|
1987
|
-
const prompt = await publicSkillPromptArgs(mountedSkills, seatPermissionsText({
|
|
1988
|
-
trust: ctx.environment?.sandbox?.trust === 'workspace' ? 'workspace' : 'public',
|
|
1989
|
-
allow: workspaceGrants(ctx.environment?.sandbox),
|
|
1990
|
-
noShell: policyOnlyHost(ctx),
|
|
1991
|
-
gitTool: Boolean(ctx.workspaceGit),
|
|
1992
|
-
}));
|
|
1993
|
-
publicSkillArgs = prompt.args;
|
|
1994
|
-
skillMount = {
|
|
1995
|
-
mounted: prompt.included,
|
|
1996
|
-
conflicted: [
|
|
1997
|
-
...skillMount.conflicted,
|
|
1998
|
-
...mountedSkills.filter((source) => !prompt.included.includes(source))
|
|
1999
|
-
.map((path) => ({ path, reason: 'not-inlined' })),
|
|
2000
|
-
],
|
|
2001
|
-
};
|
|
2002
|
-
}
|
|
2003
|
-
if (ctx.environment && ctx.cwd) ctx.onSkillsLinked?.(skillMount);
|
|
2004
|
-
if (Array.isArray(ctx.environment?.mcp) && ctx.environment.mcp.length > 0) {
|
|
2005
|
-
// F460: the jail writes the seat's state folder, so a public seat's MCP
|
|
2006
|
-
// servers write their ready files there (named per spawn, removed after).
|
|
2007
|
-
if (ctx.environment?.sandbox?.trust === 'public' && publicClaudeState?.statePath) {
|
|
2008
|
-
readyFileDir = join(publicClaudeState.statePath, 'mcp-ready');
|
|
2009
|
-
await mkdir(readyFileDir, { recursive: true, mode: 0o700 });
|
|
2010
|
-
} else if (ctx.environment?.sandbox?.trust === 'public') {
|
|
2011
|
-
// A policy-only host (D245) prepares no state folder and runs the MCP
|
|
2012
|
-
// children unjailed, so a folder of this turn's own serves.
|
|
2013
|
-
ownReadyDir = await mkdtemp(join(tmpdir(), 'commonly-claude-ready-'));
|
|
2014
|
-
await chmod(ownReadyDir, 0o700);
|
|
2015
|
-
readyFileDir = ownReadyDir;
|
|
2016
|
-
}
|
|
2017
|
-
mcpConfig = await createMcpConfig(ctx.environment.mcp, {
|
|
2018
|
-
readyFileDir,
|
|
2019
|
-
readyFiles,
|
|
2020
|
-
runtimeToken: ctx.runtimeToken,
|
|
2021
|
-
instanceUrl: ctx.instanceUrl,
|
|
2022
|
-
agentName: ctx.agentName,
|
|
2023
|
-
publicSeat: publicTrust,
|
|
2024
|
-
platform: ctx._platform || process.platform,
|
|
2025
|
-
commandResolution: { env: withClaudePath(ctx.env), ...ctx._commandResolution },
|
|
2026
|
-
});
|
|
2027
|
-
}
|
|
2028
|
-
if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
|
|
2029
|
-
const claudeEnv = withProvider(buildClaudeEnv(
|
|
2030
|
-
ctx.env,
|
|
2031
|
-
publicClaudeState,
|
|
2032
|
-
mcpConfig?.expansionEnv,
|
|
2033
|
-
mcpConfig?.credential?.path || null,
|
|
2034
|
-
publicTrust,
|
|
2035
|
-
ctx._platform || process.platform,
|
|
2036
|
-
));
|
|
2037
|
-
if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
|
|
2038
|
-
Object.assign(claudeEnv, ctx.commitAttributionEnv);
|
|
2039
|
-
}
|
|
2040
|
-
const spawnCtx = {
|
|
2041
|
-
...ctx,
|
|
2042
|
-
mcpConfigPath: mcpConfig?.file || null,
|
|
2043
|
-
mcpConfigDir: mcpConfig?.dir || null,
|
|
2044
|
-
mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
|
|
2045
|
-
publicClaudeState,
|
|
2046
|
-
publicSkillArgs,
|
|
2047
|
-
providerProxyPort: proxyPort(providerProxy),
|
|
2048
|
-
workspaceRealpath,
|
|
2049
|
-
workspacePinDirs,
|
|
2050
|
-
claudeEnv,
|
|
2051
|
-
};
|
|
2052
|
-
const { cmd, args, env, childHome } = await prepareArgv(baseArgs, spawnCtx);
|
|
2053
|
-
armRelease();
|
|
2054
|
-
const stdout = await runClaude({
|
|
2055
|
-
cmd,
|
|
2056
|
-
args,
|
|
2057
|
-
cwd: ctx.cwd,
|
|
2058
|
-
env,
|
|
2059
|
-
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
2060
|
-
// The values this spawn was handed, for the failure tail's exact-match
|
|
2061
|
-
// check. Snapshot before the spawn: claude is subscription-authenticated
|
|
2062
|
-
// and its provider key is not an env var here, so the seat credential is
|
|
2063
|
-
// the value a tail can echo back.
|
|
2064
|
-
credentials: turnCredentials(spawnCtx),
|
|
2065
|
-
spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
2066
|
-
ownGroup: wantsOwnProcessGroup(ctx),
|
|
2067
|
-
transcriptPath: claudeTranscriptPath({
|
|
2068
|
-
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
2069
|
-
}),
|
|
2070
|
-
lockProbe: ctx._lockProbe, // test seams only
|
|
2071
|
-
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
2072
|
-
processTable: ctx._processTable,
|
|
2073
|
-
survivorIo: ctx._survivorIo,
|
|
2074
|
-
turnReap: ctx._turnReap,
|
|
2075
|
-
commandResolution: ctx._commandResolution, // test seam only
|
|
2076
|
-
confined: reapEverything,
|
|
2077
|
-
});
|
|
2078
|
-
if (releaseFailure) throw releaseFailure;
|
|
2079
|
-
// TASK-440: the context this turn left, read from the transcript the
|
|
2080
|
-
// child wrote under its own env (an isolated HOME for public seats).
|
|
2081
|
-
const contextTokens = claudeContextTokens({
|
|
2082
|
-
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
2083
|
-
});
|
|
2084
|
-
return {
|
|
2085
|
-
text: turnReply(stdout, { credentials: turnCredentials(spawnCtx), publicMcp: witnessedMcp }),
|
|
2086
|
-
newSessionId: sessionId,
|
|
2087
|
-
contextTokens,
|
|
2088
|
-
// D245 item 4: what the run said about itself, for the reply receipt.
|
|
2089
|
-
stats: claudeRunStats(stdout),
|
|
2090
|
-
};
|
|
2091
|
-
} catch (err) {
|
|
2092
|
-
// F460: a credential that could not be deleted ends the turn; no retry.
|
|
2093
|
-
if (releaseFailure) throw releaseFailure;
|
|
2094
|
-
// A public seat's refusal is not a session-store failure, whatever its
|
|
2095
|
-
// message happens to contain: it must reach the run loop, never the
|
|
2096
|
-
// fresh-session retry below (#2247).
|
|
2097
|
-
if ([PUBLIC_MCP_UNAVAILABLE_CODE, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE].includes(err?.code)) throw err;
|
|
2098
|
-
// Self-heal against a corrupted session store. If the persisted id is
|
|
2099
|
-
// already in use (or claude has forgotten about it), discard it and
|
|
2100
|
-
// restart the turn with a fresh UUID. Without this, a single bad
|
|
2101
|
-
// session id poisons every subsequent event re-delivery.
|
|
2102
|
-
if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
|
|
2103
|
-
const freshId = randomUUID();
|
|
2104
|
-
// The retry creates a new underlying CLI session. Rebuild its prompt
|
|
2105
|
-
// as fresh too: otherwise a session-recovery path is the one fresh
|
|
2106
|
-
// session that misses the durable-state cue.
|
|
2107
|
-
const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
2108
|
-
freshSession: true,
|
|
2109
|
-
workCues,
|
|
2110
|
-
memoryWithheld: ctx.memoryWithheld === true,
|
|
2111
|
-
memoryReadOnly,
|
|
2112
|
-
});
|
|
2113
|
-
const retryBase = ['-p', freshPrompt,
|
|
2114
|
-
...STREAM_ARGS, '--session-id', freshId, ...modelArgs];
|
|
2115
|
-
const retryCtx = {
|
|
2116
|
-
...ctx,
|
|
2117
|
-
mcpConfigPath: mcpConfig?.file || null,
|
|
2118
|
-
mcpConfigDir: mcpConfig?.dir || null,
|
|
2119
|
-
mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
|
|
2120
|
-
publicClaudeState,
|
|
2121
|
-
publicSkillArgs,
|
|
2122
|
-
providerProxyPort: proxyPort(providerProxy),
|
|
2123
|
-
claudeEnv: withProvider(buildClaudeEnv(
|
|
2124
|
-
ctx.env,
|
|
2125
|
-
publicClaudeState,
|
|
2126
|
-
mcpConfig?.expansionEnv,
|
|
2127
|
-
mcpConfig?.credential?.path || null,
|
|
2128
|
-
publicTrust,
|
|
2129
|
-
ctx._platform || process.platform,
|
|
2130
|
-
)),
|
|
2131
|
-
};
|
|
2132
|
-
if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
|
|
2133
|
-
Object.assign(retryCtx.claudeEnv, ctx.commitAttributionEnv);
|
|
2134
|
-
}
|
|
2135
|
-
// F460: the first run may have released the credential once its
|
|
2136
|
-
// servers had it. The retry's servers read it from the same path, so it
|
|
2137
|
-
// is written there again and released again once they are ready.
|
|
2138
|
-
if (credentialRelease) {
|
|
2139
|
-
credentialRelease.stop();
|
|
2140
|
-
await Promise.all(readyFiles.map(({ path }) => rm(path, { force: true })));
|
|
2141
|
-
restoreCredentialFile(mcpConfig.credential, ctx.runtimeToken);
|
|
2142
|
-
}
|
|
2143
|
-
const retry = await prepareArgv(retryBase, retryCtx);
|
|
2144
|
-
if (credentialRelease) armRelease();
|
|
2145
|
-
const stdout = await runClaude({
|
|
2146
|
-
cmd: retry.cmd,
|
|
2147
|
-
args: retry.args,
|
|
2148
|
-
cwd: ctx.cwd,
|
|
2149
|
-
env: retry.env,
|
|
2150
|
-
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
2151
|
-
credentials: turnCredentials(retryCtx),
|
|
2152
|
-
spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
2153
|
-
ownGroup: wantsOwnProcessGroup(ctx),
|
|
2154
|
-
transcriptPath: claudeTranscriptPath({
|
|
2155
|
-
cwd: physicalPath(ctx.cwd), sessionId: freshId,
|
|
2156
|
-
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
2157
|
-
}),
|
|
2158
|
-
lockProbe: ctx._lockProbe,
|
|
2159
|
-
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
2160
|
-
processTable: ctx._processTable,
|
|
2161
|
-
survivorIo: ctx._survivorIo,
|
|
2162
|
-
turnReap: ctx._turnReap,
|
|
2163
|
-
commandResolution: ctx._commandResolution, // test seam only
|
|
2164
|
-
confined: reapEverything,
|
|
2165
|
-
}).catch((retryErr) => { throw releaseFailure || retryErr; });
|
|
2166
|
-
if (releaseFailure) throw releaseFailure;
|
|
2167
|
-
const contextTokens = claudeContextTokens({
|
|
2168
|
-
cwd: physicalPath(ctx.cwd), sessionId: freshId,
|
|
2169
|
-
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
2170
|
-
});
|
|
2171
|
-
return {
|
|
2172
|
-
text: turnReply(stdout, { credentials: turnCredentials(retryCtx), publicMcp: witnessedMcp }),
|
|
2173
|
-
newSessionId: freshId,
|
|
2174
|
-
contextTokens,
|
|
2175
|
-
stats: claudeRunStats(stdout),
|
|
2176
|
-
};
|
|
2177
|
-
}
|
|
2178
|
-
// F153: a Seatbelt seat with no key signs in only through the keychain.
|
|
2179
|
-
// When that fails, say how to give it a key instead, as bwrap does.
|
|
2180
|
-
// F171: `commonly daemon install` carries no ANTHROPIC_API_KEY (claude
|
|
2181
|
-
// declares no providerKeyEnv), so the key route is a seat run from a
|
|
2182
|
-
// shell that has it; the hint must not send a daemon operator to a
|
|
2183
|
-
// service file that never receives the key.
|
|
2184
|
-
if (publicNativeSandbox && !providerProxy && /not logged in/i.test(String(err?.message))) {
|
|
2185
|
-
err.message = `${err.message}. Sign in with \`claude\` in your own terminal on this Mac, `
|
|
2186
|
-
+ `or run the agent with ANTHROPIC_API_KEY set (\`${CLI} agent run\` from a shell that has it; `
|
|
2187
|
-
+ 'the background daemon does not carry it)';
|
|
2188
|
-
}
|
|
2189
|
-
throw err;
|
|
2190
|
-
} finally {
|
|
2191
|
-
credentialRelease?.stop();
|
|
2192
|
-
await Promise.all(readyFiles.map(({ path }) => rm(path, { force: true }).catch(() => {})));
|
|
2193
|
-
if (ownReadyDir) await rm(ownReadyDir, { recursive: true, force: true }).catch(() => {});
|
|
2194
|
-
try { await mcpConfig?.headerChannel?.close(); } catch { /* ignore */ }
|
|
2195
|
-
if (mcpConfig?.dir) {
|
|
2196
|
-
try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2197
|
-
}
|
|
2198
|
-
try { await providerProxy?.close(); } catch { /* ignore */ }
|
|
2199
|
-
// A public seat's login copy lives for its turns (F96): the last live
|
|
2200
|
-
// turn of the agent to finish removes it.
|
|
2201
|
-
if (publicBwrapSandbox && !workspaceBwrapSandbox && publicClaudeState?.loginPath) {
|
|
2202
|
-
if (loginTurn) await releaseLoginTurn(loginTurn, publicClaudeState.loginPath);
|
|
2203
|
-
else await removeLoginCopy(dirname(publicClaudeState.loginPath));
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
},
|
|
2207
|
-
};
|