@fitlab-ai/agent-infra 0.9.5 → 0.9.6

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 (106) hide show
  1. package/dist/bin/internal-cli.js +3 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +126 -16
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +48 -4
  5. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  6. package/dist/lib/git/workflow.js +0 -1
  7. package/dist/lib/internal/codex-lifecycle.js +100 -2
  8. package/dist/lib/internal/release-workflow.js +108 -11
  9. package/dist/lib/internal/sandbox-control.js +43 -6
  10. package/dist/lib/internal/task-create.js +12 -4
  11. package/dist/lib/internal/task-orchestration.js +5 -4
  12. package/dist/lib/platform/issue-comments.js +2 -1
  13. package/dist/lib/release/workflow.js +29 -21
  14. package/dist/lib/sandbox/commands/create.js +15 -16
  15. package/dist/lib/sandbox/commands/prune.js +40 -16
  16. package/dist/lib/sandbox/commands/rm.js +318 -89
  17. package/dist/lib/sandbox/control/client.js +138 -22
  18. package/dist/lib/sandbox/control/executor.js +136 -0
  19. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  20. package/dist/lib/sandbox/control/protocol.js +62 -26
  21. package/dist/lib/sandbox/control/server.js +222 -102
  22. package/dist/lib/sandbox/control/state.js +181 -0
  23. package/dist/lib/sandbox/index.js +2 -2
  24. package/dist/lib/sandbox/managed-fs.js +14 -2
  25. package/dist/lib/sandbox/mounts.js +35 -24
  26. package/dist/lib/sandbox/recovery.js +262 -45
  27. package/dist/lib/sandbox/workspace-view.js +73 -10
  28. package/dist/lib/sandbox/worktree-safety.js +196 -0
  29. package/dist/lib/server/process-control.js +6 -0
  30. package/dist/lib/task/artifact-lifecycle.js +5 -4
  31. package/dist/lib/task/codex-orchestration.js +161 -0
  32. package/dist/lib/task/commands/status.js +3 -0
  33. package/dist/lib/task/commands/validate.js +289 -0
  34. package/dist/lib/task/commit-finalization.js +36 -9
  35. package/dist/lib/task/delegation-receipts.js +25 -0
  36. package/dist/lib/task/events.js +10 -4
  37. package/dist/lib/task/finalization-artifacts.js +2 -1
  38. package/dist/lib/task/implementation-inputs.js +1 -7
  39. package/dist/lib/task/index.js +9 -0
  40. package/dist/lib/task/orchestration.js +133 -4
  41. package/dist/lib/task/verification-engine.js +38 -6
  42. package/dist/lib/task/verification.js +1 -0
  43. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +142 -15
  44. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  45. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +51 -3
  46. package/lib/agent-clients/adapters/codex.ts +1 -1
  47. package/lib/git/workflow.ts +0 -1
  48. package/lib/internal/codex-lifecycle.ts +107 -3
  49. package/lib/internal/release-workflow.ts +107 -11
  50. package/lib/internal/sandbox-control.ts +38 -6
  51. package/lib/internal/task-create.ts +16 -4
  52. package/lib/internal/task-orchestration.ts +4 -4
  53. package/lib/platform/issue-comments.ts +2 -1
  54. package/lib/release/workflow.ts +69 -20
  55. package/lib/sandbox/commands/create.ts +17 -21
  56. package/lib/sandbox/commands/prune.ts +53 -15
  57. package/lib/sandbox/commands/rm.ts +368 -103
  58. package/lib/sandbox/control/client.ts +152 -28
  59. package/lib/sandbox/control/executor.ts +163 -0
  60. package/lib/sandbox/control/lifecycle.ts +342 -0
  61. package/lib/sandbox/control/protocol.ts +96 -69
  62. package/lib/sandbox/control/server.ts +239 -110
  63. package/lib/sandbox/control/state.ts +196 -0
  64. package/lib/sandbox/index.ts +2 -2
  65. package/lib/sandbox/managed-fs.ts +17 -2
  66. package/lib/sandbox/mounts.ts +31 -13
  67. package/lib/sandbox/recovery.ts +301 -60
  68. package/lib/sandbox/workspace-view.ts +81 -11
  69. package/lib/sandbox/worktree-safety.ts +234 -0
  70. package/lib/server/process-control.ts +11 -0
  71. package/lib/task/artifact-lifecycle.ts +5 -4
  72. package/lib/task/codex-orchestration.ts +233 -0
  73. package/lib/task/commands/status.ts +2 -0
  74. package/lib/task/commands/validate.ts +282 -0
  75. package/lib/task/commit-finalization.ts +52 -9
  76. package/lib/task/delegation-receipts.ts +52 -3
  77. package/lib/task/events.ts +9 -4
  78. package/lib/task/finalization-artifacts.ts +2 -1
  79. package/lib/task/implementation-inputs.ts +1 -5
  80. package/lib/task/index.ts +8 -0
  81. package/lib/task/orchestration.ts +199 -4
  82. package/lib/task/verification-engine.ts +42 -8
  83. package/lib/task/verification.ts +2 -1
  84. package/package.json +1 -1
  85. package/templates/.agents/hooks/lifecycle-delegation.js +48 -12
  86. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  87. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  88. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  89. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  90. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  91. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  92. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  93. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  94. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  95. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  96. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  97. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  98. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  99. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  100. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  101. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  102. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  103. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  104. package/templates/.codex/hooks.json +8 -8
  105. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  106. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -1,34 +1,39 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { getProcessStartTime } from '../../server/process-state.ts';
5
- import { createTask } from '../../task/create-service.ts';
6
4
  import { assertGitWorktreeBinding } from '../../git/worktree-identity.ts';
7
5
  import {
8
- bindSandboxControlTask,
9
- validateSandboxControlRequest,
6
+ controlError,
7
+ SANDBOX_CONTROL_STATUS_INTERVAL_MS,
10
8
  type SandboxControlManifest,
9
+ type SandboxControlRequest,
11
10
  type SandboxControlResponse
12
11
  } from './protocol.ts';
12
+ import { prepareSandboxControlExecution, type PreparedSandboxControlExecution, type SandboxControlExecutionResult } from './executor.ts';
13
+ import {
14
+ appendSandboxControlAudit,
15
+ atomicWriteJson,
16
+ cleanupStaleSandboxControlLease,
17
+ executionPath,
18
+ readActiveLease,
19
+ readExecution,
20
+ terminateSandboxControlExecution,
21
+ writeSandboxControlStatus
22
+ } from './state.ts';
23
+ import { validateSandboxControlRequest } from './protocol.ts';
24
+ import {
25
+ acquireSandboxControlBrokerStartup,
26
+ isSandboxControlRootQuiescing,
27
+ readSandboxControlManifest
28
+ } from './lifecycle.ts';
13
29
 
14
- function readManifest(manifestPath: string): SandboxControlManifest {
15
- const stat = fs.lstatSync(manifestPath);
16
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
17
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as SandboxControlManifest;
18
- if (manifest.version !== 2) {
19
- throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 2; recreate the sandbox');
20
- }
21
- if (
22
- typeof manifest.repoRoot !== 'string'
23
- || typeof manifest.worktreeRoot !== 'string'
24
- || typeof manifest.channelDir !== 'string'
25
- || typeof manifest.token !== 'string'
26
- ) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
27
- if (path.resolve(manifest.channelDir) !== path.join(path.dirname(path.resolve(manifestPath)), 'channel')) {
28
- throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
29
- }
30
- return manifest;
31
- }
30
+ type ActiveExecution = {
31
+ request: SandboxControlRequest;
32
+ prepared: PreparedSandboxControlExecution;
33
+ result: SandboxControlExecutionResult | null;
34
+ failure: unknown;
35
+ settled: boolean;
36
+ };
32
37
 
33
38
  function assertRealDirectory(directory: string, parent?: string): void {
34
39
  const stat = fs.lstatSync(directory);
@@ -38,118 +43,242 @@ function assertRealDirectory(directory: string, parent?: string): void {
38
43
  }
39
44
  }
40
45
 
41
- function writeResponse(directory: string, response: SandboxControlResponse): void {
42
- const temporary = path.join(directory, `.${response.id}.tmp`);
43
- fs.writeFileSync(temporary, `${JSON.stringify(response)}\n`, { mode: 0o600, flag: 'wx' });
44
- fs.renameSync(temporary, path.join(directory, `${response.id}.json`));
46
+ function responsePath(manifest: SandboxControlManifest, id: string): string {
47
+ return path.join(manifest.channelDir, 'responses', `${id}.json`);
45
48
  }
46
49
 
47
- export function sandboxControlSafeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
48
- return Object.fromEntries(
49
- Object.entries(env).filter(
50
- ([key]) => !key.toUpperCase().startsWith('AGENT_INFRA_CONTROL_')
51
- )
52
- );
50
+ function writeResponse(manifest: SandboxControlManifest, response: SandboxControlResponse): void {
51
+ atomicWriteJson(responsePath(manifest, response.id), response);
52
+ }
53
+
54
+ function rejected(id: string, error: unknown): SandboxControlResponse {
55
+ const detail = controlError(error);
56
+ return { version: 2, id, phase: 'rejected', exitCode: null, stdout: '', stderr: `${detail.message}\n`, error: detail };
57
+ }
58
+
59
+ function unknown(id: string): SandboxControlResponse {
60
+ const error = {
61
+ code: 'SANDBOX_CONTROL_RESULT_UNKNOWN',
62
+ message: 'SANDBOX_CONTROL_RESULT_UNKNOWN: accepted execution ended without a provable result; inspect domain state before retrying',
63
+ retryable: false
64
+ };
65
+ return { version: 2, id, phase: 'rejected', exitCode: null, stdout: '', stderr: `${error.message}\n`, error };
66
+ }
67
+
68
+ function notExecuted(id: string): SandboxControlResponse {
69
+ const error = {
70
+ code: 'SANDBOX_CONTROL_NOT_EXECUTED',
71
+ message: 'SANDBOX_CONTROL_NOT_EXECUTED: request was claimed but never accepted; retry with a new request id',
72
+ retryable: true
73
+ };
74
+ return { version: 2, id, phase: 'rejected', exitCode: null, stdout: '', stderr: `${error.message}\n`, error };
53
75
  }
54
76
 
55
77
  function consumeRequest(directory: string, id: string): void {
56
78
  try {
57
79
  fs.writeFileSync(path.join(directory, id), '', { flag: 'wx', mode: 0o600 });
58
80
  } catch (error) {
59
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
60
- throw new Error('SANDBOX_CONTROL_REQUEST_REPLAYED');
61
- }
81
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('SANDBOX_CONTROL_REQUEST_REPLAYED');
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ function claimRequest(manifest: SandboxControlManifest, requestPath: string, id: string): string {
87
+ const directory = path.join(manifest.processingDir, id);
88
+ fs.mkdirSync(directory, { mode: 0o700 });
89
+ const claimed = path.join(directory, 'request.json');
90
+ try {
91
+ fs.renameSync(requestPath, claimed);
92
+ return claimed;
93
+ } catch (error) {
94
+ fs.rmSync(directory, { recursive: true, force: true });
62
95
  throw error;
63
96
  }
64
97
  }
65
98
 
66
- export function serveSandboxControl(manifestPath: string, signal: AbortSignal = new AbortController().signal): void {
67
- const manifest = readManifest(manifestPath);
68
- assertGitWorktreeBinding(manifest.repoRoot, manifest.worktreeRoot, manifest.branch);
69
- const brokerPath = path.join(path.dirname(manifestPath), 'broker.json');
99
+ function bindingReason(manifest: SandboxControlManifest): string | null {
100
+ try {
101
+ assertGitWorktreeBinding(manifest.repoRoot, manifest.worktreeRoot, manifest.branch);
102
+ return null;
103
+ } catch {
104
+ return 'SANDBOX_WORKTREE_BINDING_LOST';
105
+ }
106
+ }
107
+
108
+ function recoverProcessing(manifest: SandboxControlManifest): void {
109
+ for (const entry of fs.readdirSync(manifest.processingDir, { withFileTypes: true })) {
110
+ if (!entry.isDirectory() || !/^[a-f0-9-]{16,64}$/.test(entry.name)) continue;
111
+ const descriptor = executionPath(manifest, entry.name);
112
+ const existingResponse = responsePath(manifest, entry.name);
113
+ if (fs.existsSync(descriptor)) {
114
+ const execution = readExecution(descriptor);
115
+ if (!terminateSandboxControlExecution(execution)) {
116
+ throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${entry.name}`);
117
+ }
118
+ appendSandboxControlAudit(manifest, 'orphan-tree-terminated', { requestId: entry.name });
119
+ let terminal = false;
120
+ if (fs.existsSync(existingResponse)) {
121
+ try {
122
+ const response = JSON.parse(fs.readFileSync(existingResponse, 'utf8')) as SandboxControlResponse;
123
+ terminal = response.version === 2 && response.id === entry.name
124
+ && (response.phase === 'completed' || response.phase === 'rejected');
125
+ } catch {
126
+ terminal = false;
127
+ }
128
+ }
129
+ if (!terminal) writeResponse(manifest, unknown(entry.name));
130
+ } else {
131
+ writeResponse(manifest, notExecuted(entry.name));
132
+ }
133
+ fs.rmSync(path.join(manifest.processingDir, entry.name), { recursive: true, force: true });
134
+ }
135
+ }
136
+
137
+ function delay(ms: number): Promise<void> {
138
+ return new Promise((resolve) => setTimeout(resolve, ms));
139
+ }
140
+
141
+ export function sandboxControlSafeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
142
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !key.toUpperCase().startsWith('AGENT_INFRA_CONTROL_')));
143
+ }
144
+
145
+ export async function serveSandboxControl(
146
+ manifestPath: string,
147
+ signal: AbortSignal = new AbortController().signal
148
+ ): Promise<void> {
149
+ const manifest = readSandboxControlManifest(manifestPath);
150
+ const root = path.dirname(manifestPath);
70
151
  const startTime = getProcessStartTime(process.pid);
71
152
  if (!startTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
72
- fs.writeFileSync(brokerPath, `${JSON.stringify({
73
- version: 1,
74
- pid: process.pid,
75
- startTime,
76
- token: manifest.token
77
- })}\n`, { mode: 0o600, flag: 'wx' });
153
+ const releaseStartup = await acquireSandboxControlBrokerStartup(root, { pid: process.pid, startTime });
78
154
  const requestsDir = path.join(manifest.channelDir, 'requests');
79
155
  const responsesDir = path.join(manifest.channelDir, 'responses');
80
- const consumedDir = path.join(path.dirname(manifestPath), 'consumed');
81
- assertRealDirectory(manifest.channelDir);
82
- fs.mkdirSync(requestsDir, { recursive: true, mode: 0o700 });
83
- fs.mkdirSync(responsesDir, { recursive: true, mode: 0o700 });
84
- fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
85
- assertRealDirectory(requestsDir, manifest.channelDir);
86
- assertRealDirectory(responsesDir, manifest.channelDir);
87
- assertRealDirectory(consumedDir, path.dirname(manifestPath));
88
- while (!signal.aborted) {
89
- const current = readManifest(manifestPath);
90
- if (current.token !== manifest.token) return;
91
- assertGitWorktreeBinding(current.repoRoot, current.worktreeRoot, current.branch);
156
+ const consumedDir = path.join(root, 'consumed');
157
+ const broker = { pid: process.pid, startTime };
158
+ const brokerPath = path.join(root, 'broker.json');
159
+ const brokerRecord = `${JSON.stringify({ version: 2, ...broker, token: manifest.token, generation: manifest.generation })}\n`;
160
+ try {
161
+ for (const directory of [manifest.channelDir, requestsDir, responsesDir, consumedDir, manifest.publicStatusDir, manifest.processingDir]) {
162
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
163
+ }
164
+ assertRealDirectory(manifest.channelDir);
92
165
  assertRealDirectory(requestsDir, manifest.channelDir);
93
166
  assertRealDirectory(responsesDir, manifest.channelDir);
94
- for (const name of fs.readdirSync(requestsDir).sort()) {
95
- if (!/^[a-f0-9-]{16,64}\.json$/.test(name)) continue;
96
- const requestPath = path.join(requestsDir, name);
97
- let id = name.slice(0, -5);
98
- let response: SandboxControlResponse;
167
+ assertRealDirectory(consumedDir, root);
168
+ assertRealDirectory(manifest.publicStatusDir, root);
169
+ assertRealDirectory(manifest.processingDir, root);
170
+ fs.writeFileSync(brokerPath, brokerRecord, { mode: 0o600, flag: 'wx' });
171
+ if (isSandboxControlRootQuiescing(root)) {
172
+ if (fs.readFileSync(brokerPath, 'utf8') === brokerRecord) fs.unlinkSync(brokerPath);
173
+ return;
174
+ }
175
+ } finally {
176
+ releaseStartup();
177
+ }
178
+ let active: ActiveExecution | null = null;
179
+ let lastState = '';
180
+ let lastStatusAt = 0;
181
+ try {
182
+ writeSandboxControlStatus(manifest, broker, 'starting', null, null);
183
+ appendSandboxControlAudit(manifest, 'broker-start', { pid: broker.pid });
184
+ recoverProcessing(manifest);
185
+ while (!signal.aborted) {
186
+ let current: SandboxControlManifest;
99
187
  try {
100
- const stat = fs.lstatSync(requestPath);
101
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_REQUEST_INVALID');
102
- const request = validateSandboxControlRequest(
103
- JSON.parse(fs.readFileSync(requestPath, 'utf8')),
104
- manifest
105
- );
106
- id = request.id;
107
- consumeRequest(consumedDir, id);
108
- if (request.family === 'task-create') {
109
- const result = createTask(request.candidate, { repoRoot: manifest.repoRoot });
110
- response = {
111
- version: 1, id,
112
- exitCode: result.status === 'blocked' ? 2 : result.status === 'failed' ? 1 : 0,
113
- stdout: `${JSON.stringify(result)}\n`, stderr: ''
114
- };
188
+ current = readSandboxControlManifest(manifestPath);
189
+ } catch {
190
+ break;
191
+ }
192
+ if (current.token !== manifest.token || current.generation !== manifest.generation) break;
193
+
194
+ if (active?.settled) {
195
+ if (active.result) {
196
+ writeResponse(manifest, {
197
+ version: 2, id: active.request.id, phase: 'completed', exitCode: active.result.exitCode,
198
+ stdout: active.result.stdout, stderr: active.result.stderr, error: null
199
+ });
115
200
  } else {
116
- const boundArgs = bindSandboxControlTask(request, manifest.taskId!);
117
- if (request.family === 'task-orchestration') {
118
- boundArgs.push('--git-worktree-root', manifest.worktreeRoot);
119
- }
120
- const result = spawnSync(
121
- process.execPath,
122
- ['--experimental-strip-types', '--no-warnings', process.argv[1]!, request.family, ...boundArgs],
123
- {
124
- cwd: manifest.repoRoot,
125
- encoding: 'utf8',
126
- env: sandboxControlSafeEnv()
127
- }
128
- );
129
- response = {
130
- version: 1,
131
- id,
132
- exitCode: result.status ?? 1,
133
- stdout: result.stdout ?? '',
134
- stderr: result.stderr ?? ''
135
- };
201
+ writeResponse(manifest, unknown(active.request.id));
136
202
  }
137
- } catch (error) {
138
- response = {
139
- version: 1,
140
- id,
141
- exitCode: 1,
142
- stdout: '',
143
- stderr: `${error instanceof Error ? error.message : String(error)}\n`
144
- };
203
+ fs.rmSync(path.join(manifest.processingDir, active.request.id), { recursive: true, force: true });
204
+ active = null;
145
205
  }
206
+
207
+ let reasonCode: string | null = null;
146
208
  try {
147
- fs.unlinkSync(requestPath);
148
- writeResponse(responsesDir, response);
149
- } catch (error) {
150
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
209
+ if (cleanupStaleSandboxControlLease(manifest)) {
210
+ appendSandboxControlAudit(manifest, 'lease-stale-cleanup');
211
+ }
212
+ if (readActiveLease(manifest)) reasonCode = 'SANDBOX_CONTROL_HANDOFF_ACTIVE';
213
+ } catch {
214
+ reasonCode = 'SANDBOX_CONTROL_HANDOFF_ACTIVE';
151
215
  }
216
+ reasonCode ??= bindingReason(manifest);
217
+ const state = reasonCode ? 'parked' : active ? 'busy' : 'healthy';
218
+ const stateKey = `${state}:${reasonCode ?? ''}:${active?.request.id ?? ''}`;
219
+ const now = Date.now();
220
+ if (stateKey !== lastState || now - lastStatusAt >= SANDBOX_CONTROL_STATUS_INTERVAL_MS) {
221
+ writeSandboxControlStatus(manifest, broker, state, reasonCode, active?.request.id ?? null, now);
222
+ lastStatusAt = now;
223
+ }
224
+ if (stateKey !== lastState) {
225
+ appendSandboxControlAudit(manifest, 'broker-state', { state, reasonCode, requestId: active?.request.id ?? null });
226
+ lastState = stateKey;
227
+ }
228
+
229
+ for (const name of fs.readdirSync(requestsDir).sort()) {
230
+ if (!/^[a-f0-9-]{16,64}\.json$/.test(name)) continue;
231
+ const id = name.slice(0, -5);
232
+ const source = path.join(requestsDir, name);
233
+ let claimed: string | null = null;
234
+ try {
235
+ claimed = claimRequest(manifest, source, id);
236
+ consumeRequest(consumedDir, id);
237
+ if (reasonCode) throw new Error(reasonCode);
238
+ if (active) throw new Error('SANDBOX_CONTROL_BUSY');
239
+ const request = validateSandboxControlRequest(JSON.parse(fs.readFileSync(claimed, 'utf8')), manifest);
240
+ if (bindingReason(manifest)) throw new Error('SANDBOX_WORKTREE_BINDING_LOST');
241
+ if (readActiveLease(manifest)) throw new Error('SANDBOX_CONTROL_HANDOFF_ACTIVE');
242
+ const prepared = await prepareSandboxControlExecution({
243
+ manifest, manifestPath, request, requestPath: claimed, internalCliPath: process.argv[1]!
244
+ });
245
+ const execution: ActiveExecution = { request, prepared, result: null, failure: null, settled: false };
246
+ active = execution;
247
+ prepared.completion.then(
248
+ (result) => { execution.result = result; execution.settled = true; },
249
+ (error) => { execution.failure = error; execution.settled = true; }
250
+ );
251
+ writeResponse(manifest, {
252
+ version: 2, id, phase: 'accepted', exitCode: null, stdout: '', stderr: '', error: null
253
+ });
254
+ try {
255
+ prepared.start();
256
+ } catch (error) {
257
+ prepared.terminate();
258
+ writeResponse(manifest, unknown(id));
259
+ fs.rmSync(path.join(manifest.processingDir, id), { recursive: true, force: true });
260
+ active = null;
261
+ claimed = null;
262
+ appendSandboxControlAudit(manifest, 'executor-gate-failed', { requestId: id });
263
+ continue;
264
+ }
265
+ } catch (error) {
266
+ writeResponse(manifest, rejected(id, error));
267
+ if (claimed) fs.rmSync(path.dirname(claimed), { recursive: true, force: true });
268
+ }
269
+ }
270
+ await delay(25);
271
+ }
272
+ } finally {
273
+ if (active) {
274
+ active.prepared.terminate();
275
+ writeResponse(manifest, unknown(active.request.id));
276
+ }
277
+ appendSandboxControlAudit(manifest, 'broker-stop', { pid: broker.pid });
278
+ try {
279
+ if (fs.readFileSync(brokerPath, 'utf8') === brokerRecord) fs.unlinkSync(brokerPath);
280
+ } catch {
281
+ // A newer owner or recovery path owns the record.
152
282
  }
153
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
154
283
  }
155
284
  }
@@ -0,0 +1,196 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { buildProcessTreeStopCommand } from '../../server/process-control.ts';
6
+ import { processIdentityMatches } from '../../server/process-state.ts';
7
+ import type {
8
+ SandboxControlExecution,
9
+ SandboxControlLease,
10
+ SandboxControlManifest,
11
+ SandboxControlStatus
12
+ } from './protocol.ts';
13
+
14
+ export const SANDBOX_CONTROL_AUDIT_MAX_BYTES = 1024 * 1024;
15
+ export const SANDBOX_CONTROL_EXECUTION_STOP_MS = 2_000;
16
+
17
+ function assertRegularFile(filePath: string): void {
18
+ const stat = fs.lstatSync(filePath);
19
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_STATE_INVALID');
20
+ }
21
+
22
+ export function atomicWriteJson(filePath: string, value: unknown, mode = 0o600, createParent = true): void {
23
+ if (createParent) fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
24
+ const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
25
+ try {
26
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode, flag: 'wx' });
27
+ fs.renameSync(temporary, filePath);
28
+ } finally {
29
+ fs.rmSync(temporary, { force: true });
30
+ }
31
+ }
32
+
33
+ export function readJsonFile(filePath: string): unknown {
34
+ assertRegularFile(filePath);
35
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
36
+ }
37
+
38
+ export function statusPath(manifest: SandboxControlManifest): string {
39
+ return path.join(manifest.publicStatusDir, 'status.json');
40
+ }
41
+
42
+ export function writeSandboxControlStatus(
43
+ manifest: SandboxControlManifest,
44
+ broker: { pid: number; startTime: string },
45
+ state: SandboxControlStatus['state'],
46
+ reasonCode: string | null,
47
+ activeRequestId: string | null,
48
+ now = Date.now()
49
+ ): SandboxControlStatus {
50
+ const status: SandboxControlStatus = {
51
+ version: 1, generation: manifest.generation, broker, state, reasonCode, activeRequestId, updatedAt: now
52
+ };
53
+ atomicWriteJson(statusPath(manifest), status, 0o600, false);
54
+ return status;
55
+ }
56
+
57
+ export function parseSandboxControlStatus(value: unknown): SandboxControlStatus {
58
+ const status = value as Partial<SandboxControlStatus> | null;
59
+ if (!status || status.version !== 1 || typeof status.generation !== 'string'
60
+ || !status.broker || !Number.isSafeInteger(status.broker.pid) || typeof status.broker.startTime !== 'string'
61
+ || !['starting', 'healthy', 'busy', 'parked'].includes(status.state ?? '')
62
+ || (status.reasonCode !== null && typeof status.reasonCode !== 'string')
63
+ || (status.activeRequestId !== null && typeof status.activeRequestId !== 'string')
64
+ || !Number.isSafeInteger(status.updatedAt)) throw new Error('SANDBOX_CONTROL_STATUS_INVALID');
65
+ return status as SandboxControlStatus;
66
+ }
67
+
68
+ export function readSandboxControlStatus(directory: string): SandboxControlStatus {
69
+ return parseSandboxControlStatus(readJsonFile(path.join(directory, 'status.json')));
70
+ }
71
+
72
+ export function readActiveLease(manifest: SandboxControlManifest, now = Date.now()): SandboxControlLease | null {
73
+ const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
74
+ if (!fs.existsSync(filePath)) return null;
75
+ const lease = readJsonFile(filePath) as Partial<SandboxControlLease> | null;
76
+ if (!lease || lease.version !== 1
77
+ || typeof lease.nonce !== 'string' || !lease.owner || !Number.isSafeInteger(lease.owner.pid)
78
+ || typeof lease.owner.startTime !== 'string' || !Number.isSafeInteger(lease.issuedAt)
79
+ || !Number.isSafeInteger(lease.expiresAt) || typeof lease.generation !== 'string'
80
+ || (lease.taskId !== null && typeof lease.taskId !== 'string')
81
+ || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
82
+ throw new Error('SANDBOX_CONTROL_LEASE_INVALID');
83
+ }
84
+ if (lease.generation !== manifest.generation || lease.expiresAt! <= now
85
+ || lease.taskId !== manifest.taskId || lease.branch !== manifest.branch) return null;
86
+ return lease as SandboxControlLease;
87
+ }
88
+
89
+ export function cleanupStaleSandboxControlLease(manifest: SandboxControlManifest, now = Date.now()): boolean {
90
+ const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
91
+ if (!fs.existsSync(filePath)) return false;
92
+ const raw = fs.readFileSync(filePath, 'utf8');
93
+ const lease = JSON.parse(raw) as Partial<SandboxControlLease> | null;
94
+ if (!lease || lease.version !== 1
95
+ || typeof lease.nonce !== 'string' || !lease.owner || !Number.isSafeInteger(lease.owner.pid)
96
+ || typeof lease.owner.startTime !== 'string' || !Number.isSafeInteger(lease.issuedAt)
97
+ || !Number.isSafeInteger(lease.expiresAt) || typeof lease.generation !== 'string'
98
+ || (lease.taskId !== null && typeof lease.taskId !== 'string')
99
+ || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
100
+ throw new Error('SANDBOX_CONTROL_LEASE_INVALID');
101
+ }
102
+ const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
103
+ || lease.branch !== manifest.branch || lease.expiresAt! <= now || !processIdentityMatches({
104
+ version: 1, pid: lease.owner.pid!, startTime: lease.owner.startTime!
105
+ });
106
+ if (!stale) return false;
107
+ if (fs.readFileSync(filePath, 'utf8') !== raw) throw new Error('SANDBOX_CONTROL_LEASE_OWNERSHIP_LOST');
108
+ fs.unlinkSync(filePath);
109
+ return true;
110
+ }
111
+
112
+ export function appendSandboxControlAudit(
113
+ manifest: SandboxControlManifest,
114
+ event: string,
115
+ fields: Record<string, string | number | boolean | null> = {},
116
+ now = Date.now()
117
+ ): void {
118
+ const auditPath = path.join(path.dirname(manifest.publicStatusDir), 'audit.ndjson');
119
+ if (fs.existsSync(auditPath) && fs.statSync(auditPath).size >= SANDBOX_CONTROL_AUDIT_MAX_BYTES) {
120
+ fs.rmSync(`${auditPath}.1`, { force: true });
121
+ fs.renameSync(auditPath, `${auditPath}.1`);
122
+ }
123
+ fs.appendFileSync(auditPath, `${JSON.stringify({ version: 1, event, generation: manifest.generation, at: now, ...fields })}\n`, {
124
+ encoding: 'utf8', mode: 0o600
125
+ });
126
+ }
127
+
128
+ export function executionPath(manifest: SandboxControlManifest, requestId: string): string {
129
+ return path.join(manifest.processingDir, requestId, 'execution.json');
130
+ }
131
+
132
+ export function readExecution(filePath: string): SandboxControlExecution {
133
+ const execution = readJsonFile(filePath) as Partial<SandboxControlExecution> | null;
134
+ if (!execution || execution.version !== 1 || typeof execution.generation !== 'string'
135
+ || typeof execution.requestId !== 'string' || typeof execution.nonce !== 'string'
136
+ || !execution.child || !Number.isSafeInteger(execution.child.pid) || typeof execution.child.startTime !== 'string'
137
+ || !['prepared', 'running', 'terminating'].includes(execution.phase ?? '')
138
+ || !Number.isSafeInteger(execution.updatedAt)) throw new Error('SANDBOX_CONTROL_EXECUTION_INVALID');
139
+ return execution as SandboxControlExecution;
140
+ }
141
+
142
+ export function terminateSandboxControlExecution(
143
+ execution: SandboxControlExecution,
144
+ options: { platform?: NodeJS.Platform; timeoutMs?: number } = {}
145
+ ): boolean {
146
+ const platform = options.platform ?? process.platform;
147
+ const processGroupId = execution.child.processGroupId;
148
+ const processGroupAlive = (groupId: number): boolean => {
149
+ try {
150
+ const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
151
+ return rows.split('\n').some((row) => {
152
+ const match = row.trim().match(/^(\d+)\s+(\S+)/);
153
+ return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
154
+ });
155
+ } catch {
156
+ try {
157
+ process.kill(-groupId, 0);
158
+ return true;
159
+ } catch (error) {
160
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
161
+ }
162
+ }
163
+ };
164
+ const treeAlive = (): boolean => {
165
+ if (platform === 'win32' || !processGroupId) {
166
+ return processIdentityMatches({ version: 1, pid: execution.child.pid, startTime: execution.child.startTime });
167
+ }
168
+ return processGroupAlive(processGroupId);
169
+ };
170
+ if (!treeAlive()) return true;
171
+ const command = buildProcessTreeStopCommand(processGroupId ?? execution.child.pid, platform);
172
+ try {
173
+ if (command.kind === 'exec') execFileSync(command.command, command.args, { stdio: 'ignore' });
174
+ else process.kill(command.pid, command.signal);
175
+ } catch {
176
+ // The identity check below distinguishes an already-exited tree from a failed termination.
177
+ }
178
+ const deadline = Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS);
179
+ while (Date.now() < deadline) {
180
+ if (!treeAlive()) return true;
181
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
182
+ }
183
+ if (platform !== 'win32') {
184
+ try {
185
+ process.kill(processGroupId ? -processGroupId : execution.child.pid, 'SIGKILL');
186
+ } catch {
187
+ // The final identity check handles an already-exited tree.
188
+ }
189
+ const killDeadline = Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS);
190
+ while (Date.now() < killDeadline) {
191
+ if (!treeAlive()) return true;
192
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
193
+ }
194
+ }
195
+ return !treeAlive();
196
+ }
@@ -15,9 +15,9 @@ Commands:
15
15
  rebuild [--quiet] [--refresh]
16
16
  Rebuild the sandbox image (--refresh pulls base + tools)
17
17
  refresh Sync host Claude Code credentials to all sandbox copies
18
- rm <branch> | --all | --purge
18
+ rm <branch> | --unbound | --purge
19
19
  Remove one sandbox, all sandboxes not bound to an
20
- active task (--all), or tear down everything (--purge)
20
+ active task (--unbound), or tear down everything (--purge)
21
21
  start [--recreate] <branch | TASK-id | N>
22
22
  Verify or recover an existing sandbox container;
23
23
  optionally replace only the container on failure
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { run, runSafe } from './shell.ts';
3
3
  import { removeDirRecursive } from '../remove-dir.ts';
4
+ import { verifyWorktreePermit } from './worktree-safety.ts';
5
+ import type { WorktreeRemovalPermit } from './worktree-safety.ts';
4
6
 
5
7
  export function assertManagedPath(root: string, target: string): void {
6
8
  const resolvedRoot = path.resolve(root);
@@ -22,18 +24,31 @@ export function removeWorktreeDir(
22
24
  repoRoot: string,
23
25
  worktreeBase: string,
24
26
  dir: string,
25
- { runFn = run, runSafeFn = runSafe }: {
27
+ permit: WorktreeRemovalPermit,
28
+ { runFn = run, runSafeFn = runSafe, allowRegisteredPathFallback = false }: {
26
29
  runFn?: typeof run;
27
30
  runSafeFn?: typeof runSafe;
31
+ allowRegisteredPathFallback?: boolean;
28
32
  } = {}
29
33
  ): void {
34
+ assertManagedPath(worktreeBase, dir);
35
+ if (path.resolve(permit.snapshot.worktree) !== path.resolve(dir)) {
36
+ throw new Error(`Worktree permit target mismatch: ${dir}`);
37
+ }
38
+ verifyWorktreePermit(permit);
30
39
  try {
31
40
  runFn('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
32
- } catch {
41
+ } catch (error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ const registeredPathMismatch = /(?:is not|not a valid) working tree|not a working tree/i.test(message);
44
+ if (!allowRegisteredPathFallback || !registeredPathMismatch) {
45
+ throw error;
46
+ }
33
47
  // On WSL2 the worktree is registered under its `/mnt/<drive>/...` path, so
34
48
  // `git worktree remove <windows-path>` cannot match it. Delete the managed
35
49
  // directory directly, then prune the now-dangling worktree metadata so the
36
50
  // branch is no longer reported as checked out.
51
+ verifyWorktreePermit(permit);
37
52
  removeManagedDir(worktreeBase, dir);
38
53
  runSafeFn('git', ['-C', repoRoot, 'worktree', 'prune']);
39
54
  }