@fitlab-ai/agent-infra 0.9.4 → 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 (130) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +13 -3
  4. package/dist/lib/agent-clients/adapter.js +2 -2
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +492 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +175 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +255 -0
  8. package/dist/lib/agent-clients/adapters/codex.js +3 -3
  9. package/dist/lib/git/workflow.js +0 -1
  10. package/dist/lib/git/worktree-identity.js +64 -0
  11. package/dist/lib/internal/codex-lifecycle.js +300 -0
  12. package/dist/lib/internal/release-workflow.js +108 -11
  13. package/dist/lib/internal/sandbox-control.js +43 -6
  14. package/dist/lib/internal/task-create.js +56 -0
  15. package/dist/lib/internal/task-orchestration.js +71 -31
  16. package/dist/lib/platform/issue-comments.js +2 -1
  17. package/dist/lib/release/workflow.js +29 -21
  18. package/dist/lib/sandbox/commands/create.js +16 -16
  19. package/dist/lib/sandbox/commands/prune.js +40 -16
  20. package/dist/lib/sandbox/commands/rm.js +318 -89
  21. package/dist/lib/sandbox/control/client.js +152 -22
  22. package/dist/lib/sandbox/control/executor.js +136 -0
  23. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  24. package/dist/lib/sandbox/control/protocol.js +73 -18
  25. package/dist/lib/sandbox/control/server.js +235 -80
  26. package/dist/lib/sandbox/control/state.js +181 -0
  27. package/dist/lib/sandbox/index.js +2 -2
  28. package/dist/lib/sandbox/managed-fs.js +14 -2
  29. package/dist/lib/sandbox/mounts.js +35 -24
  30. package/dist/lib/sandbox/recovery.js +262 -45
  31. package/dist/lib/sandbox/workspace-view.js +80 -11
  32. package/dist/lib/sandbox/worktree-safety.js +196 -0
  33. package/dist/lib/server/process-control.js +6 -0
  34. package/dist/lib/task/activity-log.js +32 -5
  35. package/dist/lib/task/artifact-lifecycle.js +5 -4
  36. package/dist/lib/task/codex-orchestration.js +161 -0
  37. package/dist/lib/task/commands/status.js +3 -0
  38. package/dist/lib/task/commands/validate.js +289 -0
  39. package/dist/lib/task/commit-finalization.js +50 -12
  40. package/dist/lib/task/create-service.js +137 -0
  41. package/dist/lib/task/create.js +333 -0
  42. package/dist/lib/task/delegation-receipts.js +25 -0
  43. package/dist/lib/task/events.js +10 -4
  44. package/dist/lib/task/finalization-artifacts.js +2 -1
  45. package/dist/lib/task/implementation-inputs.js +1 -7
  46. package/dist/lib/task/index.js +9 -0
  47. package/dist/lib/task/orchestration.js +283 -18
  48. package/dist/lib/task/review-fingerprint.js +4 -0
  49. package/dist/lib/task/verification-engine.js +38 -6
  50. package/dist/lib/task/verification.js +1 -0
  51. package/dist/lib/task/workspace-snapshot.js +78 -3
  52. package/lib/agent-clients/adapter.ts +4 -4
  53. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +569 -0
  54. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +323 -0
  55. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +298 -0
  56. package/lib/agent-clients/adapters/codex.ts +3 -3
  57. package/lib/git/workflow.ts +0 -1
  58. package/lib/git/worktree-identity.ts +86 -0
  59. package/lib/internal/codex-lifecycle.ts +308 -0
  60. package/lib/internal/release-workflow.ts +107 -11
  61. package/lib/internal/sandbox-control.ts +38 -6
  62. package/lib/internal/task-create.ts +61 -0
  63. package/lib/internal/task-orchestration.ts +57 -31
  64. package/lib/platform/issue-comments.ts +2 -1
  65. package/lib/release/workflow.ts +69 -20
  66. package/lib/sandbox/commands/create.ts +18 -21
  67. package/lib/sandbox/commands/prune.ts +53 -15
  68. package/lib/sandbox/commands/rm.ts +368 -103
  69. package/lib/sandbox/control/client.ts +173 -25
  70. package/lib/sandbox/control/executor.ts +163 -0
  71. package/lib/sandbox/control/lifecycle.ts +342 -0
  72. package/lib/sandbox/control/protocol.ts +110 -48
  73. package/lib/sandbox/control/server.ts +252 -85
  74. package/lib/sandbox/control/state.ts +196 -0
  75. package/lib/sandbox/index.ts +2 -2
  76. package/lib/sandbox/managed-fs.ts +17 -2
  77. package/lib/sandbox/mounts.ts +31 -13
  78. package/lib/sandbox/recovery.ts +301 -60
  79. package/lib/sandbox/workspace-view.ts +89 -12
  80. package/lib/sandbox/worktree-safety.ts +234 -0
  81. package/lib/server/process-control.ts +11 -0
  82. package/lib/task/activity-log.ts +46 -7
  83. package/lib/task/artifact-lifecycle.ts +5 -4
  84. package/lib/task/codex-orchestration.ts +233 -0
  85. package/lib/task/commands/status.ts +2 -0
  86. package/lib/task/commands/validate.ts +282 -0
  87. package/lib/task/commit-finalization.ts +75 -12
  88. package/lib/task/create-service.ts +171 -0
  89. package/lib/task/create.ts +372 -0
  90. package/lib/task/delegation-receipts.ts +52 -3
  91. package/lib/task/events.ts +9 -4
  92. package/lib/task/finalization-artifacts.ts +2 -1
  93. package/lib/task/implementation-inputs.ts +1 -5
  94. package/lib/task/index.ts +8 -0
  95. package/lib/task/orchestration.ts +381 -22
  96. package/lib/task/review-fingerprint.ts +9 -0
  97. package/lib/task/verification-engine.ts +42 -8
  98. package/lib/task/verification.ts +2 -1
  99. package/lib/task/workspace-snapshot.ts +98 -4
  100. package/package.json +7 -7
  101. package/templates/.agents/hooks/lifecycle-delegation.js +88 -8
  102. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  103. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  104. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  105. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  106. package/templates/.agents/rules/task-management.en.md +1 -0
  107. package/templates/.agents/rules/task-management.zh-CN.md +1 -0
  108. package/templates/.agents/skills/commit/SKILL.en.md +3 -9
  109. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
  110. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
  111. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
  112. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  113. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  114. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  115. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  116. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  117. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  118. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  119. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  120. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  121. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  122. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  123. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  124. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  125. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  126. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  127. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  128. package/templates/.codex/hooks.json +50 -0
  129. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  130. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -1,35 +1,90 @@
1
+ import { validateTaskCreateCandidate } from '../../task/create.js';
1
2
  export const SANDBOX_CONTROL_MAX_BYTES = 64 * 1024;
2
- export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration'];
3
+ export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
4
+ export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
5
+ export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
6
+ export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
7
+ export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-create'];
8
+ export class SandboxControlProtocolError extends Error {
9
+ code;
10
+ retryable;
11
+ constructor(code, message, retryable = false) {
12
+ super(`${code}: ${message}`);
13
+ this.name = 'SandboxControlProtocolError';
14
+ this.code = code;
15
+ this.retryable = retryable;
16
+ }
17
+ }
18
+ function fail(code, message, retryable = false) {
19
+ throw new SandboxControlProtocolError(code, message, retryable);
20
+ }
3
21
  export function isSandboxControlFamily(value) {
4
22
  return SANDBOX_CONTROL_FAMILIES.includes(value);
5
23
  }
6
- export function validateSandboxControlRequest(value, manifest) {
24
+ export function validateSandboxControlRequest(value, manifest, options = {}) {
7
25
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
8
- throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: request must be an object');
26
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request must be an object');
9
27
  }
10
28
  const request = value;
11
- if (request.version !== 1
12
- || typeof request.id !== 'string'
13
- || !/^[a-f0-9-]{16,64}$/.test(request.id)
14
- || request.token !== manifest.token
15
- || typeof request.family !== 'string'
16
- || !isSandboxControlFamily(request.family)
17
- || !Array.isArray(request.args)
18
- || !request.args.every((arg) => typeof arg === 'string')) {
19
- throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: request schema or authorization is invalid');
29
+ if (request.version !== 2 || typeof request.id !== 'string'
30
+ || !/^[a-f0-9-]{16,64}$/.test(request.id) || request.token !== manifest.token
31
+ || typeof request.family !== 'string' || !isSandboxControlFamily(request.family)
32
+ || !Number.isSafeInteger(request.issuedAt) || !Number.isSafeInteger(request.expiresAt))
33
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
34
+ if (request.generation !== manifest.generation) {
35
+ fail('SANDBOX_CONTROL_GENERATION_INVALID', 'request generation does not match the broker');
20
36
  }
21
- if (manifest.mode !== 'task-bound' || !manifest.taskId) {
22
- throw new Error('SANDBOX_CONTROL_BRANCH_ONLY: branch-only sandboxes cannot coordinate tasks');
37
+ const issuedAt = request.issuedAt;
38
+ const expiresAt = request.expiresAt;
39
+ const now = options.now ?? Date.now();
40
+ if (expiresAt <= issuedAt || expiresAt - issuedAt > SANDBOX_CONTROL_ADMISSION_WINDOW_MS || issuedAt > now + SANDBOX_CONTROL_FUTURE_SKEW_MS) {
41
+ fail('SANDBOX_CONTROL_REQUEST_DEADLINE_INVALID', 'request admission deadline is invalid');
23
42
  }
43
+ if (now > expiresAt)
44
+ fail('SANDBOX_CONTROL_REQUEST_EXPIRED', 'request admission deadline has passed', true);
24
45
  if (Buffer.byteLength(JSON.stringify(request), 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
25
- throw new Error('SANDBOX_CONTROL_REQUEST_TOO_LARGE: request exceeds the control limit');
46
+ fail('SANDBOX_CONTROL_REQUEST_TOO_LARGE', 'request exceeds the control limit');
47
+ }
48
+ if (request.family === 'task-create') {
49
+ const expected = ['candidate', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
50
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')) {
51
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
52
+ }
53
+ return { ...request, candidate: validateTaskCreateCandidate(request.candidate) };
54
+ }
55
+ const expected = ['args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
56
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
57
+ || !Array.isArray(request.args) || !request.args.every((arg) => typeof arg === 'string')) {
58
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
59
+ }
60
+ if (manifest.mode !== 'task-bound' || !manifest.taskId) {
61
+ fail('SANDBOX_CONTROL_BRANCH_ONLY', "branch-only sandboxes cannot coordinate tasks; return to the host and run 'ai sandbox start --recreate <task-ref-or-correct-branch>'");
62
+ }
63
+ if (request.family === 'task-orchestration'
64
+ && request.args.some((arg) => arg === '--git-worktree-root' || arg.startsWith('--git-worktree-root='))) {
65
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'worktree binding is reserved for the control broker');
26
66
  }
27
67
  return request;
28
68
  }
29
69
  export function bindSandboxControlTask(request, taskId) {
30
- if (request.args.length === 0) {
31
- throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: command arguments are required');
32
- }
70
+ if (request.family === 'task-create')
71
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-create requests do not bind a current task');
72
+ if (request.args.length === 0)
73
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'command arguments are required');
33
74
  return [taskId, ...request.args.slice(1)];
34
75
  }
76
+ export function controlError(error) {
77
+ if (error instanceof SandboxControlProtocolError) {
78
+ return { code: error.code, message: error.message, retryable: error.retryable };
79
+ }
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ const code = /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'SANDBOX_CONTROL_INTERNAL_ERROR';
82
+ const retryable = new Set([
83
+ 'SANDBOX_CONTROL_BUSY',
84
+ 'SANDBOX_CONTROL_HANDOFF_ACTIVE',
85
+ 'SANDBOX_CONTROL_REQUEST_EXPIRED',
86
+ 'SANDBOX_WORKTREE_BINDING_LOST'
87
+ ]).has(code);
88
+ return { code, message, retryable };
89
+ }
35
90
  //# sourceMappingURL=protocol.js.map
@@ -1,23 +1,12 @@
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.js';
5
- import { bindSandboxControlTask, validateSandboxControlRequest } from './protocol.js';
6
- function readManifest(manifestPath) {
7
- const stat = fs.lstatSync(manifestPath);
8
- if (!stat.isFile() || stat.isSymbolicLink())
9
- throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
10
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
11
- if (manifest.version !== 1
12
- || typeof manifest.repoRoot !== 'string'
13
- || typeof manifest.channelDir !== 'string'
14
- || typeof manifest.token !== 'string')
15
- throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
16
- if (path.resolve(manifest.channelDir) !== path.join(path.dirname(path.resolve(manifestPath)), 'channel')) {
17
- throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
18
- }
19
- return manifest;
20
- }
4
+ import { assertGitWorktreeBinding } from '../../git/worktree-identity.js';
5
+ import { controlError, SANDBOX_CONTROL_STATUS_INTERVAL_MS } from './protocol.js';
6
+ import { prepareSandboxControlExecution } from './executor.js';
7
+ import { appendSandboxControlAudit, atomicWriteJson, cleanupStaleSandboxControlLease, executionPath, readActiveLease, readExecution, terminateSandboxControlExecution, writeSandboxControlStatus } from './state.js';
8
+ import { validateSandboxControlRequest } from './protocol.js';
9
+ import { acquireSandboxControlBrokerStartup, isSandboxControlRootQuiescing, readSandboxControlManifest } from './lifecycle.js';
21
10
  function assertRealDirectory(directory, parent) {
22
11
  const stat = fs.lstatSync(directory);
23
12
  if (!stat.isDirectory() || stat.isSymbolicLink())
@@ -26,84 +15,250 @@ function assertRealDirectory(directory, parent) {
26
15
  throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
27
16
  }
28
17
  }
29
- function writeResponse(directory, response) {
30
- const temporary = path.join(directory, `.${response.id}.tmp`);
31
- fs.writeFileSync(temporary, `${JSON.stringify(response)}\n`, { mode: 0o600, flag: 'wx' });
32
- fs.renameSync(temporary, path.join(directory, `${response.id}.json`));
18
+ function responsePath(manifest, id) {
19
+ return path.join(manifest.channelDir, 'responses', `${id}.json`);
20
+ }
21
+ function writeResponse(manifest, response) {
22
+ atomicWriteJson(responsePath(manifest, response.id), response);
23
+ }
24
+ function rejected(id, error) {
25
+ const detail = controlError(error);
26
+ return { version: 2, id, phase: 'rejected', exitCode: null, stdout: '', stderr: `${detail.message}\n`, error: detail };
27
+ }
28
+ function unknown(id) {
29
+ const error = {
30
+ code: 'SANDBOX_CONTROL_RESULT_UNKNOWN',
31
+ message: 'SANDBOX_CONTROL_RESULT_UNKNOWN: accepted execution ended without a provable result; inspect domain state before retrying',
32
+ retryable: false
33
+ };
34
+ return { version: 2, id, phase: 'rejected', exitCode: null, stdout: '', stderr: `${error.message}\n`, error };
33
35
  }
34
- export function serveSandboxControl(manifestPath, signal = new AbortController().signal) {
35
- const manifest = readManifest(manifestPath);
36
- const brokerPath = path.join(path.dirname(manifestPath), 'broker.json');
36
+ function notExecuted(id) {
37
+ const error = {
38
+ code: 'SANDBOX_CONTROL_NOT_EXECUTED',
39
+ message: 'SANDBOX_CONTROL_NOT_EXECUTED: request was claimed but never accepted; retry with a new request id',
40
+ retryable: true
41
+ };
42
+ return { version: 2, id, phase: 'rejected', exitCode: null, stdout: '', stderr: `${error.message}\n`, error };
43
+ }
44
+ function consumeRequest(directory, id) {
45
+ try {
46
+ fs.writeFileSync(path.join(directory, id), '', { flag: 'wx', mode: 0o600 });
47
+ }
48
+ catch (error) {
49
+ if (error.code === 'EEXIST')
50
+ throw new Error('SANDBOX_CONTROL_REQUEST_REPLAYED');
51
+ throw error;
52
+ }
53
+ }
54
+ function claimRequest(manifest, requestPath, id) {
55
+ const directory = path.join(manifest.processingDir, id);
56
+ fs.mkdirSync(directory, { mode: 0o700 });
57
+ const claimed = path.join(directory, 'request.json');
58
+ try {
59
+ fs.renameSync(requestPath, claimed);
60
+ return claimed;
61
+ }
62
+ catch (error) {
63
+ fs.rmSync(directory, { recursive: true, force: true });
64
+ throw error;
65
+ }
66
+ }
67
+ function bindingReason(manifest) {
68
+ try {
69
+ assertGitWorktreeBinding(manifest.repoRoot, manifest.worktreeRoot, manifest.branch);
70
+ return null;
71
+ }
72
+ catch {
73
+ return 'SANDBOX_WORKTREE_BINDING_LOST';
74
+ }
75
+ }
76
+ function recoverProcessing(manifest) {
77
+ for (const entry of fs.readdirSync(manifest.processingDir, { withFileTypes: true })) {
78
+ if (!entry.isDirectory() || !/^[a-f0-9-]{16,64}$/.test(entry.name))
79
+ continue;
80
+ const descriptor = executionPath(manifest, entry.name);
81
+ const existingResponse = responsePath(manifest, entry.name);
82
+ if (fs.existsSync(descriptor)) {
83
+ const execution = readExecution(descriptor);
84
+ if (!terminateSandboxControlExecution(execution)) {
85
+ throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${entry.name}`);
86
+ }
87
+ appendSandboxControlAudit(manifest, 'orphan-tree-terminated', { requestId: entry.name });
88
+ let terminal = false;
89
+ if (fs.existsSync(existingResponse)) {
90
+ try {
91
+ const response = JSON.parse(fs.readFileSync(existingResponse, 'utf8'));
92
+ terminal = response.version === 2 && response.id === entry.name
93
+ && (response.phase === 'completed' || response.phase === 'rejected');
94
+ }
95
+ catch {
96
+ terminal = false;
97
+ }
98
+ }
99
+ if (!terminal)
100
+ writeResponse(manifest, unknown(entry.name));
101
+ }
102
+ else {
103
+ writeResponse(manifest, notExecuted(entry.name));
104
+ }
105
+ fs.rmSync(path.join(manifest.processingDir, entry.name), { recursive: true, force: true });
106
+ }
107
+ }
108
+ function delay(ms) {
109
+ return new Promise((resolve) => setTimeout(resolve, ms));
110
+ }
111
+ export function sandboxControlSafeEnv(env = process.env) {
112
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !key.toUpperCase().startsWith('AGENT_INFRA_CONTROL_')));
113
+ }
114
+ export async function serveSandboxControl(manifestPath, signal = new AbortController().signal) {
115
+ const manifest = readSandboxControlManifest(manifestPath);
116
+ const root = path.dirname(manifestPath);
37
117
  const startTime = getProcessStartTime(process.pid);
38
118
  if (!startTime)
39
119
  throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
40
- fs.writeFileSync(brokerPath, `${JSON.stringify({
41
- version: 1,
42
- pid: process.pid,
43
- startTime,
44
- token: manifest.token
45
- })}\n`, { mode: 0o600, flag: 'wx' });
120
+ const releaseStartup = await acquireSandboxControlBrokerStartup(root, { pid: process.pid, startTime });
46
121
  const requestsDir = path.join(manifest.channelDir, 'requests');
47
122
  const responsesDir = path.join(manifest.channelDir, 'responses');
48
- assertRealDirectory(manifest.channelDir);
49
- fs.mkdirSync(requestsDir, { recursive: true, mode: 0o700 });
50
- fs.mkdirSync(responsesDir, { recursive: true, mode: 0o700 });
51
- assertRealDirectory(requestsDir, manifest.channelDir);
52
- assertRealDirectory(responsesDir, manifest.channelDir);
53
- const consumed = new Set();
54
- while (!signal.aborted) {
55
- const current = readManifest(manifestPath);
56
- if (current.token !== manifest.token)
57
- return;
123
+ const consumedDir = path.join(root, 'consumed');
124
+ const broker = { pid: process.pid, startTime };
125
+ const brokerPath = path.join(root, 'broker.json');
126
+ const brokerRecord = `${JSON.stringify({ version: 2, ...broker, token: manifest.token, generation: manifest.generation })}\n`;
127
+ try {
128
+ for (const directory of [manifest.channelDir, requestsDir, responsesDir, consumedDir, manifest.publicStatusDir, manifest.processingDir]) {
129
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
130
+ }
131
+ assertRealDirectory(manifest.channelDir);
58
132
  assertRealDirectory(requestsDir, manifest.channelDir);
59
133
  assertRealDirectory(responsesDir, manifest.channelDir);
60
- for (const name of fs.readdirSync(requestsDir).sort()) {
61
- if (!/^[a-f0-9-]{16,64}\.json$/.test(name))
62
- continue;
63
- const requestPath = path.join(requestsDir, name);
64
- let id = name.slice(0, -5);
65
- let response;
134
+ assertRealDirectory(consumedDir, root);
135
+ assertRealDirectory(manifest.publicStatusDir, root);
136
+ assertRealDirectory(manifest.processingDir, root);
137
+ fs.writeFileSync(brokerPath, brokerRecord, { mode: 0o600, flag: 'wx' });
138
+ if (isSandboxControlRootQuiescing(root)) {
139
+ if (fs.readFileSync(brokerPath, 'utf8') === brokerRecord)
140
+ fs.unlinkSync(brokerPath);
141
+ return;
142
+ }
143
+ }
144
+ finally {
145
+ releaseStartup();
146
+ }
147
+ let active = null;
148
+ let lastState = '';
149
+ let lastStatusAt = 0;
150
+ try {
151
+ writeSandboxControlStatus(manifest, broker, 'starting', null, null);
152
+ appendSandboxControlAudit(manifest, 'broker-start', { pid: broker.pid });
153
+ recoverProcessing(manifest);
154
+ while (!signal.aborted) {
155
+ let current;
66
156
  try {
67
- const stat = fs.lstatSync(requestPath);
68
- if (!stat.isFile() || stat.isSymbolicLink())
69
- throw new Error('SANDBOX_CONTROL_REQUEST_INVALID');
70
- const request = validateSandboxControlRequest(JSON.parse(fs.readFileSync(requestPath, 'utf8')), manifest);
71
- id = request.id;
72
- if (consumed.has(id))
73
- throw new Error('SANDBOX_CONTROL_REQUEST_REPLAYED');
74
- consumed.add(id);
75
- const result = spawnSync(process.execPath, ['--experimental-strip-types', '--no-warnings', process.argv[1], request.family, ...bindSandboxControlTask(request, manifest.taskId)], {
76
- cwd: manifest.repoRoot,
77
- encoding: 'utf8',
78
- env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('AGENT_INFRA_CONTROL_')))
79
- });
80
- response = {
81
- version: 1,
82
- id,
83
- exitCode: result.status ?? 1,
84
- stdout: result.stdout ?? '',
85
- stderr: result.stderr ?? ''
86
- };
157
+ current = readSandboxControlManifest(manifestPath);
158
+ }
159
+ catch {
160
+ break;
87
161
  }
88
- catch (error) {
89
- response = {
90
- version: 1,
91
- id,
92
- exitCode: 1,
93
- stdout: '',
94
- stderr: `${error instanceof Error ? error.message : String(error)}\n`
95
- };
162
+ if (current.token !== manifest.token || current.generation !== manifest.generation)
163
+ break;
164
+ if (active?.settled) {
165
+ if (active.result) {
166
+ writeResponse(manifest, {
167
+ version: 2, id: active.request.id, phase: 'completed', exitCode: active.result.exitCode,
168
+ stdout: active.result.stdout, stderr: active.result.stderr, error: null
169
+ });
170
+ }
171
+ else {
172
+ writeResponse(manifest, unknown(active.request.id));
173
+ }
174
+ fs.rmSync(path.join(manifest.processingDir, active.request.id), { recursive: true, force: true });
175
+ active = null;
96
176
  }
177
+ let reasonCode = null;
97
178
  try {
98
- fs.unlinkSync(requestPath);
99
- writeResponse(responsesDir, response);
179
+ if (cleanupStaleSandboxControlLease(manifest)) {
180
+ appendSandboxControlAudit(manifest, 'lease-stale-cleanup');
181
+ }
182
+ if (readActiveLease(manifest))
183
+ reasonCode = 'SANDBOX_CONTROL_HANDOFF_ACTIVE';
184
+ }
185
+ catch {
186
+ reasonCode = 'SANDBOX_CONTROL_HANDOFF_ACTIVE';
187
+ }
188
+ reasonCode ??= bindingReason(manifest);
189
+ const state = reasonCode ? 'parked' : active ? 'busy' : 'healthy';
190
+ const stateKey = `${state}:${reasonCode ?? ''}:${active?.request.id ?? ''}`;
191
+ const now = Date.now();
192
+ if (stateKey !== lastState || now - lastStatusAt >= SANDBOX_CONTROL_STATUS_INTERVAL_MS) {
193
+ writeSandboxControlStatus(manifest, broker, state, reasonCode, active?.request.id ?? null, now);
194
+ lastStatusAt = now;
195
+ }
196
+ if (stateKey !== lastState) {
197
+ appendSandboxControlAudit(manifest, 'broker-state', { state, reasonCode, requestId: active?.request.id ?? null });
198
+ lastState = stateKey;
100
199
  }
101
- catch (error) {
102
- if (error.code !== 'ENOENT')
103
- throw error;
200
+ for (const name of fs.readdirSync(requestsDir).sort()) {
201
+ if (!/^[a-f0-9-]{16,64}\.json$/.test(name))
202
+ continue;
203
+ const id = name.slice(0, -5);
204
+ const source = path.join(requestsDir, name);
205
+ let claimed = null;
206
+ try {
207
+ claimed = claimRequest(manifest, source, id);
208
+ consumeRequest(consumedDir, id);
209
+ if (reasonCode)
210
+ throw new Error(reasonCode);
211
+ if (active)
212
+ throw new Error('SANDBOX_CONTROL_BUSY');
213
+ const request = validateSandboxControlRequest(JSON.parse(fs.readFileSync(claimed, 'utf8')), manifest);
214
+ if (bindingReason(manifest))
215
+ throw new Error('SANDBOX_WORKTREE_BINDING_LOST');
216
+ if (readActiveLease(manifest))
217
+ throw new Error('SANDBOX_CONTROL_HANDOFF_ACTIVE');
218
+ const prepared = await prepareSandboxControlExecution({
219
+ manifest, manifestPath, request, requestPath: claimed, internalCliPath: process.argv[1]
220
+ });
221
+ const execution = { request, prepared, result: null, failure: null, settled: false };
222
+ active = execution;
223
+ prepared.completion.then((result) => { execution.result = result; execution.settled = true; }, (error) => { execution.failure = error; execution.settled = true; });
224
+ writeResponse(manifest, {
225
+ version: 2, id, phase: 'accepted', exitCode: null, stdout: '', stderr: '', error: null
226
+ });
227
+ try {
228
+ prepared.start();
229
+ }
230
+ catch (error) {
231
+ prepared.terminate();
232
+ writeResponse(manifest, unknown(id));
233
+ fs.rmSync(path.join(manifest.processingDir, id), { recursive: true, force: true });
234
+ active = null;
235
+ claimed = null;
236
+ appendSandboxControlAudit(manifest, 'executor-gate-failed', { requestId: id });
237
+ continue;
238
+ }
239
+ }
240
+ catch (error) {
241
+ writeResponse(manifest, rejected(id, error));
242
+ if (claimed)
243
+ fs.rmSync(path.dirname(claimed), { recursive: true, force: true });
244
+ }
104
245
  }
246
+ await delay(25);
247
+ }
248
+ }
249
+ finally {
250
+ if (active) {
251
+ active.prepared.terminate();
252
+ writeResponse(manifest, unknown(active.request.id));
253
+ }
254
+ appendSandboxControlAudit(manifest, 'broker-stop', { pid: broker.pid });
255
+ try {
256
+ if (fs.readFileSync(brokerPath, 'utf8') === brokerRecord)
257
+ fs.unlinkSync(brokerPath);
258
+ }
259
+ catch {
260
+ // A newer owner or recovery path owns the record.
105
261
  }
106
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
107
262
  }
108
263
  }
109
264
  //# sourceMappingURL=server.js.map
@@ -0,0 +1,181 @@
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.js';
6
+ import { processIdentityMatches } from '../../server/process-state.js';
7
+ export const SANDBOX_CONTROL_AUDIT_MAX_BYTES = 1024 * 1024;
8
+ export const SANDBOX_CONTROL_EXECUTION_STOP_MS = 2_000;
9
+ function assertRegularFile(filePath) {
10
+ const stat = fs.lstatSync(filePath);
11
+ if (!stat.isFile() || stat.isSymbolicLink())
12
+ throw new Error('SANDBOX_CONTROL_STATE_INVALID');
13
+ }
14
+ export function atomicWriteJson(filePath, value, mode = 0o600, createParent = true) {
15
+ if (createParent)
16
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
17
+ const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
18
+ try {
19
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode, flag: 'wx' });
20
+ fs.renameSync(temporary, filePath);
21
+ }
22
+ finally {
23
+ fs.rmSync(temporary, { force: true });
24
+ }
25
+ }
26
+ export function readJsonFile(filePath) {
27
+ assertRegularFile(filePath);
28
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
29
+ }
30
+ export function statusPath(manifest) {
31
+ return path.join(manifest.publicStatusDir, 'status.json');
32
+ }
33
+ export function writeSandboxControlStatus(manifest, broker, state, reasonCode, activeRequestId, now = Date.now()) {
34
+ const status = {
35
+ version: 1, generation: manifest.generation, broker, state, reasonCode, activeRequestId, updatedAt: now
36
+ };
37
+ atomicWriteJson(statusPath(manifest), status, 0o600, false);
38
+ return status;
39
+ }
40
+ export function parseSandboxControlStatus(value) {
41
+ const status = value;
42
+ if (!status || status.version !== 1 || typeof status.generation !== 'string'
43
+ || !status.broker || !Number.isSafeInteger(status.broker.pid) || typeof status.broker.startTime !== 'string'
44
+ || !['starting', 'healthy', 'busy', 'parked'].includes(status.state ?? '')
45
+ || (status.reasonCode !== null && typeof status.reasonCode !== 'string')
46
+ || (status.activeRequestId !== null && typeof status.activeRequestId !== 'string')
47
+ || !Number.isSafeInteger(status.updatedAt))
48
+ throw new Error('SANDBOX_CONTROL_STATUS_INVALID');
49
+ return status;
50
+ }
51
+ export function readSandboxControlStatus(directory) {
52
+ return parseSandboxControlStatus(readJsonFile(path.join(directory, 'status.json')));
53
+ }
54
+ export function readActiveLease(manifest, now = Date.now()) {
55
+ const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
56
+ if (!fs.existsSync(filePath))
57
+ return null;
58
+ const lease = readJsonFile(filePath);
59
+ if (!lease || lease.version !== 1
60
+ || typeof lease.nonce !== 'string' || !lease.owner || !Number.isSafeInteger(lease.owner.pid)
61
+ || typeof lease.owner.startTime !== 'string' || !Number.isSafeInteger(lease.issuedAt)
62
+ || !Number.isSafeInteger(lease.expiresAt) || typeof lease.generation !== 'string'
63
+ || (lease.taskId !== null && typeof lease.taskId !== 'string')
64
+ || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
65
+ throw new Error('SANDBOX_CONTROL_LEASE_INVALID');
66
+ }
67
+ if (lease.generation !== manifest.generation || lease.expiresAt <= now
68
+ || lease.taskId !== manifest.taskId || lease.branch !== manifest.branch)
69
+ return null;
70
+ return lease;
71
+ }
72
+ export function cleanupStaleSandboxControlLease(manifest, now = Date.now()) {
73
+ const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
74
+ if (!fs.existsSync(filePath))
75
+ return false;
76
+ const raw = fs.readFileSync(filePath, 'utf8');
77
+ const lease = JSON.parse(raw);
78
+ if (!lease || lease.version !== 1
79
+ || typeof lease.nonce !== 'string' || !lease.owner || !Number.isSafeInteger(lease.owner.pid)
80
+ || typeof lease.owner.startTime !== 'string' || !Number.isSafeInteger(lease.issuedAt)
81
+ || !Number.isSafeInteger(lease.expiresAt) || typeof lease.generation !== 'string'
82
+ || (lease.taskId !== null && typeof lease.taskId !== 'string')
83
+ || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
84
+ throw new Error('SANDBOX_CONTROL_LEASE_INVALID');
85
+ }
86
+ const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
87
+ || lease.branch !== manifest.branch || lease.expiresAt <= now || !processIdentityMatches({
88
+ version: 1, pid: lease.owner.pid, startTime: lease.owner.startTime
89
+ });
90
+ if (!stale)
91
+ return false;
92
+ if (fs.readFileSync(filePath, 'utf8') !== raw)
93
+ throw new Error('SANDBOX_CONTROL_LEASE_OWNERSHIP_LOST');
94
+ fs.unlinkSync(filePath);
95
+ return true;
96
+ }
97
+ export function appendSandboxControlAudit(manifest, event, fields = {}, now = Date.now()) {
98
+ const auditPath = path.join(path.dirname(manifest.publicStatusDir), 'audit.ndjson');
99
+ if (fs.existsSync(auditPath) && fs.statSync(auditPath).size >= SANDBOX_CONTROL_AUDIT_MAX_BYTES) {
100
+ fs.rmSync(`${auditPath}.1`, { force: true });
101
+ fs.renameSync(auditPath, `${auditPath}.1`);
102
+ }
103
+ fs.appendFileSync(auditPath, `${JSON.stringify({ version: 1, event, generation: manifest.generation, at: now, ...fields })}\n`, {
104
+ encoding: 'utf8', mode: 0o600
105
+ });
106
+ }
107
+ export function executionPath(manifest, requestId) {
108
+ return path.join(manifest.processingDir, requestId, 'execution.json');
109
+ }
110
+ export function readExecution(filePath) {
111
+ const execution = readJsonFile(filePath);
112
+ if (!execution || execution.version !== 1 || typeof execution.generation !== 'string'
113
+ || typeof execution.requestId !== 'string' || typeof execution.nonce !== 'string'
114
+ || !execution.child || !Number.isSafeInteger(execution.child.pid) || typeof execution.child.startTime !== 'string'
115
+ || !['prepared', 'running', 'terminating'].includes(execution.phase ?? '')
116
+ || !Number.isSafeInteger(execution.updatedAt))
117
+ throw new Error('SANDBOX_CONTROL_EXECUTION_INVALID');
118
+ return execution;
119
+ }
120
+ export function terminateSandboxControlExecution(execution, options = {}) {
121
+ const platform = options.platform ?? process.platform;
122
+ const processGroupId = execution.child.processGroupId;
123
+ const processGroupAlive = (groupId) => {
124
+ try {
125
+ const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
126
+ return rows.split('\n').some((row) => {
127
+ const match = row.trim().match(/^(\d+)\s+(\S+)/);
128
+ return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
129
+ });
130
+ }
131
+ catch {
132
+ try {
133
+ process.kill(-groupId, 0);
134
+ return true;
135
+ }
136
+ catch (error) {
137
+ return error.code === 'EPERM';
138
+ }
139
+ }
140
+ };
141
+ const treeAlive = () => {
142
+ if (platform === 'win32' || !processGroupId) {
143
+ return processIdentityMatches({ version: 1, pid: execution.child.pid, startTime: execution.child.startTime });
144
+ }
145
+ return processGroupAlive(processGroupId);
146
+ };
147
+ if (!treeAlive())
148
+ return true;
149
+ const command = buildProcessTreeStopCommand(processGroupId ?? execution.child.pid, platform);
150
+ try {
151
+ if (command.kind === 'exec')
152
+ execFileSync(command.command, command.args, { stdio: 'ignore' });
153
+ else
154
+ process.kill(command.pid, command.signal);
155
+ }
156
+ catch {
157
+ // The identity check below distinguishes an already-exited tree from a failed termination.
158
+ }
159
+ const deadline = Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS);
160
+ while (Date.now() < deadline) {
161
+ if (!treeAlive())
162
+ return true;
163
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
164
+ }
165
+ if (platform !== 'win32') {
166
+ try {
167
+ process.kill(processGroupId ? -processGroupId : execution.child.pid, 'SIGKILL');
168
+ }
169
+ catch {
170
+ // The final identity check handles an already-exited tree.
171
+ }
172
+ const killDeadline = Date.now() + (options.timeoutMs ?? SANDBOX_CONTROL_EXECUTION_STOP_MS);
173
+ while (Date.now() < killDeadline) {
174
+ if (!treeAlive())
175
+ return true;
176
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
177
+ }
178
+ }
179
+ return !treeAlive();
180
+ }
181
+ //# sourceMappingURL=state.js.map
@@ -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