@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.
Files changed (167) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +104 -31
  3. package/bin/space.js +113 -0
  4. package/lib/agents.js +181 -0
  5. package/lib/boardTools.js +89 -0
  6. package/lib/credentials.js +95 -0
  7. package/lib/daemon.js +294 -0
  8. package/lib/harness.js +166 -0
  9. package/lib/http.js +101 -0
  10. package/lib/login.js +68 -0
  11. package/lib/mcp.js +39 -0
  12. package/lib/runtime.js +253 -0
  13. package/lib/store.js +96 -0
  14. package/lib/tools.js +93 -0
  15. package/lib/workspaceSandbox.js +77 -0
  16. package/lib/workspaceTools.js +160 -0
  17. package/lib/workspaceWorker.js +17 -0
  18. package/package.json +27 -46
  19. package/NOTICE +0 -55
  20. package/assets/webhook-sdk/python/bot.py +0 -63
  21. package/assets/webhook-sdk/python/commonly.py +0 -232
  22. package/node_modules/@commonlyai/seats/README.md +0 -25
  23. package/node_modules/@commonlyai/seats/package.json +0 -22
  24. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  25. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  26. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  27. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  28. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  29. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  30. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  31. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  32. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  33. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  34. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  35. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  36. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  37. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  38. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  39. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  40. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  41. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  42. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  43. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  44. package/skills/commonly/SKILL.md +0 -233
  45. package/src/commands/agent.js +0 -6334
  46. package/src/commands/daemon.js +0 -933
  47. package/src/commands/dev.js +0 -153
  48. package/src/commands/doctor.js +0 -141
  49. package/src/commands/inbox.js +0 -330
  50. package/src/commands/login.js +0 -194
  51. package/src/commands/mcp.js +0 -56
  52. package/src/commands/pod.js +0 -120
  53. package/src/commands/task.js +0 -279
  54. package/src/commands/verify.js +0 -48
  55. package/src/index.js +0 -99
  56. package/src/lib/adapter-login.js +0 -76
  57. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  58. package/src/lib/adapters/claude.js +0 -2207
  59. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  60. package/src/lib/adapters/codex.js +0 -1898
  61. package/src/lib/adapters/dsh.js +0 -513
  62. package/src/lib/adapters/goose.js +0 -510
  63. package/src/lib/adapters/hermes.js +0 -18
  64. package/src/lib/adapters/index.js +0 -68
  65. package/src/lib/adapters/opencode.js +0 -1681
  66. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  67. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  68. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  69. package/src/lib/adapters/pi.js +0 -1025
  70. package/src/lib/adapters/qwen.js +0 -523
  71. package/src/lib/adapters/seat-model.js +0 -8
  72. package/src/lib/adapters/stub.js +0 -1
  73. package/src/lib/adapters/tested-version.js +0 -106
  74. package/src/lib/api.js +0 -205
  75. package/src/lib/claim-outcome.js +0 -128
  76. package/src/lib/claimed-work-gate.js +0 -73
  77. package/src/lib/cli-hint.js +0 -33
  78. package/src/lib/commit-attribution.js +0 -391
  79. package/src/lib/config.js +0 -155
  80. package/src/lib/content-refusal.js +0 -94
  81. package/src/lib/credential-file.js +0 -208
  82. package/src/lib/daemon-agent-states.js +0 -95
  83. package/src/lib/daemon-hint.js +0 -18
  84. package/src/lib/daemon-logs.js +0 -43
  85. package/src/lib/daemon-service-windows.js +0 -263
  86. package/src/lib/daemon-service.js +0 -486
  87. package/src/lib/daemon-state.js +0 -84
  88. package/src/lib/daemon-store.js +0 -119
  89. package/src/lib/daemon-supervisor.js +0 -629
  90. package/src/lib/daemon-watch.js +0 -115
  91. package/src/lib/declared-mcp-guard.js +0 -284
  92. package/src/lib/default-environment.js +0 -207
  93. package/src/lib/device-login.js +0 -126
  94. package/src/lib/digest-frame.js +0 -151
  95. package/src/lib/doctor.js +0 -291
  96. package/src/lib/egress-proxy.js +0 -160
  97. package/src/lib/enforcement.js +0 -946
  98. package/src/lib/environment.js +0 -681
  99. package/src/lib/first-output-watchdog.js +0 -191
  100. package/src/lib/git-readonly-server.js +0 -138
  101. package/src/lib/git-readonly.js +0 -463
  102. package/src/lib/git-remote-commonly.mjs +0 -100
  103. package/src/lib/grant-broker-guard.js +0 -345
  104. package/src/lib/hooks-config.js +0 -238
  105. package/src/lib/host-gh-broker.js +0 -152
  106. package/src/lib/host-gh-shim.mjs +0 -98
  107. package/src/lib/inbox.js +0 -483
  108. package/src/lib/instance-proxy.js +0 -76
  109. package/src/lib/machine-host.js +0 -29
  110. package/src/lib/mcp-credential-delivery.js +0 -121
  111. package/src/lib/mcp-first-run.js +0 -50
  112. package/src/lib/mcp-home.js +0 -644
  113. package/src/lib/mcp-ready.js +0 -68
  114. package/src/lib/mcp-server-version.js +0 -123
  115. package/src/lib/mcp-token.js +0 -36
  116. package/src/lib/mcp-warm-child.mjs +0 -412
  117. package/src/lib/memory-bridge.js +0 -191
  118. package/src/lib/memory-import.js +0 -156
  119. package/src/lib/missing-harness.js +0 -31
  120. package/src/lib/model-unavailable.js +0 -154
  121. package/src/lib/native-runtime-inventory.js +0 -40
  122. package/src/lib/outsider-grant-mcp.js +0 -82
  123. package/src/lib/own-key-launch.js +0 -42
  124. package/src/lib/pod-catch-up.js +0 -55
  125. package/src/lib/pod-focus.js +0 -224
  126. package/src/lib/pod-tail.js +0 -68
  127. package/src/lib/poll-liveness.js +0 -103
  128. package/src/lib/poll-retry.js +0 -81
  129. package/src/lib/poller.js +0 -200
  130. package/src/lib/provider-proxy.js +0 -42
  131. package/src/lib/public-seat-mcp-flag.js +0 -36
  132. package/src/lib/public-tool-error.js +0 -60
  133. package/src/lib/public-withheld-tools.js +0 -48
  134. package/src/lib/reply-receipt.js +0 -55
  135. package/src/lib/resolve-on-path.js +0 -22
  136. package/src/lib/runtime-inventory.js +0 -56
  137. package/src/lib/runtime-observation.js +0 -37
  138. package/src/lib/sandbox/bwrap.js +0 -275
  139. package/src/lib/sandbox/codex-perimeter.js +0 -16
  140. package/src/lib/sandbox/host-check.js +0 -268
  141. package/src/lib/sandbox/mode.js +0 -49
  142. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  143. package/src/lib/sandbox/seatbelt.js +0 -684
  144. package/src/lib/sandbox/turn-dir.js +0 -56
  145. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  146. package/src/lib/sandbox/workspace-git-config.js +0 -243
  147. package/src/lib/sandbox/workspace-pin.js +0 -158
  148. package/src/lib/seat-runtime-controls.js +0 -61
  149. package/src/lib/seat-stop.js +0 -97
  150. package/src/lib/server-skills.js +0 -32
  151. package/src/lib/session-recycle.js +0 -219
  152. package/src/lib/session-store.js +0 -215
  153. package/src/lib/skill-prompt.js +0 -74
  154. package/src/lib/skills-import.js +0 -113
  155. package/src/lib/spawn-credential.js +0 -402
  156. package/src/lib/spawn-retry.js +0 -311
  157. package/src/lib/stable-install.js +0 -176
  158. package/src/lib/supervised-turn.js +0 -352
  159. package/src/lib/task.js +0 -235
  160. package/src/lib/turn-failure-notice.js +0 -177
  161. package/src/lib/unconfined-seat-guard.js +0 -94
  162. package/src/lib/update-check.js +0 -192
  163. package/src/lib/upstream-refusal.js +0 -1
  164. package/src/lib/webhook-server.js +0 -109
  165. package/src/lib/windows-command.js +0 -393
  166. package/src/lib/workspace-git.js +0 -87
  167. 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
- };