@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,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { run, runSafe } from './shell.js';
3
3
  import { removeDirRecursive } from '../remove-dir.js';
4
+ import { verifyWorktreePermit } from './worktree-safety.js';
4
5
  export function assertManagedPath(root, target) {
5
6
  const resolvedRoot = path.resolve(root);
6
7
  const resolvedTarget = path.resolve(target);
@@ -14,15 +15,26 @@ export function removeManagedDir(root, dir) {
14
15
  assertManagedPath(root, dir);
15
16
  removeDirRecursive(dir);
16
17
  }
17
- export function removeWorktreeDir(repoRoot, worktreeBase, dir, { runFn = run, runSafeFn = runSafe } = {}) {
18
+ export function removeWorktreeDir(repoRoot, worktreeBase, dir, permit, { runFn = run, runSafeFn = runSafe, allowRegisteredPathFallback = false } = {}) {
19
+ assertManagedPath(worktreeBase, dir);
20
+ if (path.resolve(permit.snapshot.worktree) !== path.resolve(dir)) {
21
+ throw new Error(`Worktree permit target mismatch: ${dir}`);
22
+ }
23
+ verifyWorktreePermit(permit);
18
24
  try {
19
25
  runFn('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
20
26
  }
21
- catch {
27
+ catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ const registeredPathMismatch = /(?:is not|not a valid) working tree|not a working tree/i.test(message);
30
+ if (!allowRegisteredPathFallback || !registeredPathMismatch) {
31
+ throw error;
32
+ }
22
33
  // On WSL2 the worktree is registered under its `/mnt/<drive>/...` path, so
23
34
  // `git worktree remove <windows-path>` cannot match it. Delete the managed
24
35
  // directory directly, then prune the now-dangling worktree metadata so the
25
36
  // branch is no longer reported as checked out.
37
+ verifyWorktreePermit(permit);
26
38
  removeManagedDir(worktreeBase, dir);
27
39
  runSafeFn('git', ['-C', repoRoot, 'worktree', 'prune']);
28
40
  }
@@ -1,6 +1,19 @@
1
1
  import path from 'node:path';
2
2
  import { shareBranchDir, shareCommonDir, shellConfigDirCandidates, worktreeDirCandidates } from './constants.js';
3
+ import { sandboxWorkspaceViewStatePaths } from './workspace-view.js';
3
4
  export function sandboxCoreBindMounts(config, branch, overrides) {
5
+ const taskBound = Boolean(overrides.taskSource && overrides.taskId);
6
+ const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(({ state, hostPath }) => taskBound && state === 'active'
7
+ ? {
8
+ hostPaths: [path.join(hostPath, '.short-ids.json')],
9
+ containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
10
+ readOnly: true
11
+ }
12
+ : {
13
+ hostPaths: [hostPath],
14
+ containerPath: path.posix.join('/workspace/.agents/workspace', state),
15
+ readOnly: true
16
+ });
4
17
  const mounts = [
5
18
  {
6
19
  hostPaths: overrides.worktree
@@ -9,37 +22,35 @@ export function sandboxCoreBindMounts(config, branch, overrides) {
9
22
  containerPath: '/workspace',
10
23
  readOnly: false
11
24
  },
12
- {
13
- hostPaths: [overrides.workspaceViewRoot],
14
- containerPath: '/workspace/.agents/workspace',
15
- readOnly: true
16
- },
17
- {
18
- hostPaths: [shareCommonDir(config)],
19
- containerPath: '/share/common',
20
- readOnly: false
21
- },
22
- {
23
- hostPaths: [shareBranchDir(config, branch)],
24
- containerPath: '/share/branch',
25
- readOnly: false
26
- },
27
- {
28
- hostPaths: overrides.shellConfigHostDir
29
- ? [overrides.shellConfigHostDir]
30
- : shellConfigDirCandidates(config, branch),
31
- containerPath: '/home/devuser/.host-shell-config',
32
- readOnly: true
33
- }
25
+ ...workspaceMounts,
34
26
  ];
35
- if (overrides.taskSource && overrides.taskId) {
36
- mounts.splice(2, 0, {
27
+ if (taskBound) {
28
+ mounts.push({
37
29
  hostPaths: [overrides.taskSource],
38
30
  containerPath: path.posix.join('/workspace/.agents/workspace/active', overrides.taskId),
39
31
  readOnly: false
40
32
  });
41
33
  }
42
34
  mounts.push({
35
+ hostPaths: [shareCommonDir(config)],
36
+ containerPath: '/share/common',
37
+ readOnly: false
38
+ }, {
39
+ hostPaths: [shareBranchDir(config, branch)],
40
+ containerPath: '/share/branch',
41
+ readOnly: false
42
+ }, {
43
+ hostPaths: overrides.shellConfigHostDir
44
+ ? [overrides.shellConfigHostDir]
45
+ : shellConfigDirCandidates(config, branch),
46
+ containerPath: '/home/devuser/.host-shell-config',
47
+ readOnly: true
48
+ });
49
+ mounts.push({
50
+ hostPaths: [overrides.controlStatusDir],
51
+ containerPath: '/run/agent-infra/control-status',
52
+ readOnly: true
53
+ }, {
43
54
  hostPaths: [overrides.controlDir],
44
55
  containerPath: '/run/agent-infra/control',
45
56
  readOnly: false
@@ -2,18 +2,158 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { containerNameCandidates, sandboxBranchLabel, sandboxLabel, sandboxRuntimeCapabilityLabel, sandboxTaskIdLabel, sandboxWorkspaceModeLabel } from './constants.js';
5
+ import { nodeEntryArgs } from './control/executor.js';
6
+ import { containerNameCandidates, sandboxBranchLabel, sandboxLabel, sandboxRuntimeCapabilityLabel, sandboxTaskIdLabel, sandboxWorkspaceModeLabel, worktreeDirCandidates } from './constants.js';
6
7
  import { isAgentClientId } from '../agent-clients/types.js';
7
8
  import { createSandboxCapabilityPlan } from './agent-client-reconciler.js';
8
9
  import { toEnginePath } from './engines/wsl2-paths.js';
9
10
  import { sandboxCoreBindMounts } from './mounts.js';
10
11
  import { assertSandboxTaskSource, sandboxControlPaths, sandboxWorkspaceViewPaths } from './workspace-view.js';
11
12
  import { parseSandboxWorkspaceIdentity, sameSandboxWorkspaceIdentity } from './workspace-identity.js';
13
+ import { inspectWorktree } from './worktree-safety.js';
12
14
  import { runEngine, runOkEngine, runVerboseEngine } from './shell.js';
13
15
  import { declaredTmpfsSeedEntries, resolveTools, toolConfigDirCandidates } from './tools.js';
14
16
  import { fetchSandboxRows, selectSandboxContainer, startSandboxContainer } from './commands/list-running.js';
15
- import { processIdentityMatches } from '../server/process-state.js';
16
- function ensureSandboxControlBroker(params) {
17
+ import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.js';
18
+ import { acquireSandboxControlBrokerStartup, isSandboxControlRootQuiescing } from './control/lifecycle.js';
19
+ import { appendSandboxControlAudit, readSandboxControlStatus } from './control/state.js';
20
+ import { SANDBOX_CONTROL_FUTURE_SKEW_MS, SANDBOX_CONTROL_STATUS_STALE_MS } from './control/protocol.js';
21
+ async function waitForSandboxControlBrokerStatus(params, timeoutMs = 5_000) {
22
+ const deadline = Date.now() + timeoutMs;
23
+ while (Date.now() < deadline) {
24
+ if (isSandboxControlRootQuiescing(params.root))
25
+ throw new Error('SANDBOX_CONTROL_QUIESCING');
26
+ try {
27
+ const status = readSandboxControlStatus(params.statusDir);
28
+ if (status.generation === params.generation && status.broker.pid === params.pid
29
+ && (!params.startTime || status.broker.startTime === params.startTime)
30
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
31
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS)
32
+ return;
33
+ }
34
+ catch {
35
+ // The broker may still be publishing its initial atomic status.
36
+ }
37
+ await new Promise((resolve) => setTimeout(resolve, 25));
38
+ }
39
+ throw new Error('SANDBOX_CONTROL_BROKER_START_TIMEOUT');
40
+ }
41
+ export async function startSandboxControlBroker(repoRoot, manifestPath) {
42
+ const directory = path.dirname(fileURLToPath(import.meta.url));
43
+ const extension = path.extname(fileURLToPath(import.meta.url));
44
+ const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
45
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
46
+ const root = path.dirname(manifestPath);
47
+ if (isSandboxControlRootQuiescing(root))
48
+ throw new Error('SANDBOX_CONTROL_QUIESCING');
49
+ const brokerPath = path.join(root, 'broker.json');
50
+ let brokerSnapshot = null;
51
+ let replacedBrokerRecord = false;
52
+ try {
53
+ brokerSnapshot = fs.readFileSync(brokerPath, 'utf8');
54
+ }
55
+ catch (error) {
56
+ if (error.code !== 'ENOENT')
57
+ throw error;
58
+ }
59
+ if (brokerSnapshot !== null) {
60
+ let broker = null;
61
+ try {
62
+ broker = JSON.parse(brokerSnapshot);
63
+ }
64
+ catch {
65
+ // Malformed owner records are stale and replaced below.
66
+ }
67
+ const owner = broker !== null && typeof broker.pid === 'number' && typeof broker.startTime === 'string'
68
+ ? { version: 1, pid: broker.pid, startTime: broker.startTime }
69
+ : null;
70
+ const live = owner !== null && processIdentityMatches(owner);
71
+ if (live
72
+ && owner !== null
73
+ && broker !== null
74
+ && broker.version === 2
75
+ && broker.token === manifest.token
76
+ && broker.generation === manifest.generation) {
77
+ await waitForSandboxControlBrokerStatus({
78
+ root,
79
+ statusDir: manifest.publicStatusDir,
80
+ generation: manifest.generation,
81
+ pid: owner.pid,
82
+ startTime: owner.startTime
83
+ });
84
+ return;
85
+ }
86
+ if (live && owner) {
87
+ const deadline = Date.now() + 5_000;
88
+ while (Date.now() < deadline) {
89
+ await new Promise((resolve) => setTimeout(resolve, 25));
90
+ try {
91
+ if (fs.readFileSync(brokerPath, 'utf8') !== brokerSnapshot) {
92
+ return startSandboxControlBroker(repoRoot, manifestPath);
93
+ }
94
+ }
95
+ catch (error) {
96
+ if (error.code === 'ENOENT') {
97
+ return startSandboxControlBroker(repoRoot, manifestPath);
98
+ }
99
+ throw error;
100
+ }
101
+ if (!processIdentityMatches(owner)) {
102
+ removePidFileIfMatches(brokerPath, brokerSnapshot);
103
+ return startSandboxControlBroker(repoRoot, manifestPath);
104
+ }
105
+ }
106
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_ACTIVE');
107
+ }
108
+ }
109
+ const startupStartTime = getProcessStartTime(process.pid);
110
+ if (!startupStartTime)
111
+ throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
112
+ const releaseStartup = await acquireSandboxControlBrokerStartup(root, {
113
+ pid: process.pid,
114
+ startTime: startupStartTime
115
+ });
116
+ let child;
117
+ try {
118
+ const currentBroker = fs.existsSync(brokerPath) ? fs.readFileSync(brokerPath, 'utf8') : null;
119
+ if (currentBroker !== brokerSnapshot)
120
+ return startSandboxControlBroker(repoRoot, manifestPath);
121
+ if (brokerSnapshot !== null) {
122
+ replacedBrokerRecord = removePidFileIfMatches(brokerPath, brokerSnapshot);
123
+ if (!replacedBrokerRecord && fs.existsSync(brokerPath)) {
124
+ throw new Error('SANDBOX_CONTROL_BROKER_OWNER_TRANSITION');
125
+ }
126
+ }
127
+ if (replacedBrokerRecord) {
128
+ appendSandboxControlAudit(manifest, 'broker-observed-crash');
129
+ appendSandboxControlAudit(manifest, 'broker-restart');
130
+ }
131
+ child = spawn(process.execPath, nodeEntryArgs(internalCli, ['sandbox-control', 'serve', '--manifest', manifestPath]), { cwd: repoRoot, detached: true, stdio: 'ignore' });
132
+ }
133
+ finally {
134
+ releaseStartup();
135
+ }
136
+ let spawnError = null;
137
+ child.once('error', (error) => { spawnError = error; });
138
+ child.unref();
139
+ if (!child.pid)
140
+ throw new Error('SANDBOX_CONTROL_BROKER_START_FAILED');
141
+ try {
142
+ await waitForSandboxControlBrokerStatus({
143
+ root,
144
+ statusDir: manifest.publicStatusDir,
145
+ generation: manifest.generation,
146
+ pid: child.pid
147
+ });
148
+ }
149
+ catch (error) {
150
+ child.kill('SIGTERM');
151
+ if (spawnError)
152
+ throw spawnError;
153
+ throw error;
154
+ }
155
+ }
156
+ async function ensureSandboxControlBroker(params) {
17
157
  const control = sandboxControlPaths({
18
158
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
19
159
  project: params.config.project,
@@ -23,30 +163,43 @@ function ensureSandboxControlBroker(params) {
23
163
  if (!fs.existsSync(control.manifestPath))
24
164
  return;
25
165
  const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8'));
166
+ if (manifest.version !== 3) {
167
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
168
+ }
26
169
  const brokerPath = path.join(control.root, 'broker.json');
27
170
  try {
28
171
  const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8'));
29
- if (broker.version === 1
172
+ if (broker.version === 2
30
173
  && typeof broker.pid === 'number'
31
174
  && typeof broker.startTime === 'string'
32
175
  && broker.token === manifest.token
33
- && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime }))
176
+ && broker.generation === manifest.generation
177
+ && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })) {
178
+ try {
179
+ const status = readSandboxControlStatus(control.statusDir);
180
+ if (status.generation === manifest.generation && status.broker.pid === broker.pid
181
+ && status.broker.startTime === broker.startTime
182
+ && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
183
+ && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS)
184
+ return;
185
+ }
186
+ catch {
187
+ // A verified live owner may still be publishing its initial status.
188
+ }
189
+ await waitForSandboxControlBrokerStatus({
190
+ root: control.root,
191
+ statusDir: control.statusDir,
192
+ generation: manifest.generation,
193
+ pid: broker.pid,
194
+ startTime: broker.startTime
195
+ });
34
196
  return;
197
+ }
35
198
  }
36
199
  catch {
37
200
  // A missing, stale, or malformed broker record is replaced below.
38
201
  }
39
- fs.rmSync(brokerPath, { force: true });
40
- const directory = path.dirname(fileURLToPath(import.meta.url));
41
- const extension = path.extname(fileURLToPath(import.meta.url));
42
- const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
43
- const child = spawn(process.execPath, [internalCli, 'sandbox-control', 'serve', '--manifest', control.manifestPath], { cwd: params.config.repoRoot, detached: true, stdio: 'ignore' });
44
- child.once('error', () => {
45
- // Readiness remains fail-closed; a later control request reports the
46
- // unavailable broker without turning this detached spawn into an
47
- // unhandled process error.
48
- });
49
- child.unref();
202
+ await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
50
203
  }
51
204
  function findingKey(finding) {
52
205
  return `${finding.repairKind}:${finding.path ?? finding.seed?.targetPath ?? finding.message}`;
@@ -54,9 +207,22 @@ function findingKey(finding) {
54
207
  export function classifySandboxRecovery(snapshot) {
55
208
  const findings = [];
56
209
  if (!snapshot.identityOk) {
210
+ const branchOnly = snapshot.containerIdValid
211
+ && snapshot.actualBranch === snapshot.expectedBranch
212
+ && snapshot.expectedWorkspace.mode === 'task-bound'
213
+ && snapshot.actualWorkspace.mode === 'branch-only';
214
+ const expectedIdentity = snapshot.expectedWorkspace.mode === 'task-bound'
215
+ ? `task-bound:${snapshot.expectedWorkspace.taskId}`
216
+ : snapshot.expectedWorkspace.mode;
217
+ const actualIdentity = snapshot.actualWorkspace.mode === 'task-bound'
218
+ ? `task-bound:${snapshot.actualWorkspace.taskId}`
219
+ : snapshot.actualWorkspace.mode;
57
220
  findings.push({
58
221
  repairKind: 'hard-failure',
59
- message: 'Container identity does not match the requested sandbox branch.'
222
+ code: branchOnly ? 'SANDBOX_CONTROL_BRANCH_ONLY' : 'SANDBOX_WORKSPACE_IDENTITY_CONFLICT',
223
+ message: branchOnly
224
+ ? `Container is branch-only but the requested sandbox is ${expectedIdentity}.`
225
+ : `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
60
226
  });
61
227
  }
62
228
  if (snapshot.runtimeCapabilityOk === false) {
@@ -211,6 +377,7 @@ function expectedMounts(params) {
211
377
  const core = sandboxCoreBindMounts(config, branch, {
212
378
  workspaceViewRoot: view.root,
213
379
  controlDir: control.channelDir,
380
+ controlStatusDir: control.statusDir,
214
381
  ...(params.workspace.mode === 'task-bound'
215
382
  ? {
216
383
  taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
@@ -379,8 +546,12 @@ export function collectSandboxRecoverySnapshot(params) {
379
546
  };
380
547
  });
381
548
  return {
382
- identityOk: typeof inspection.Id === 'string'
383
- && inspection.Id.length > 0
549
+ containerIdValid: typeof inspection.Id === 'string' && inspection.Id.length > 0,
550
+ expectedBranch: params.branch,
551
+ actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
552
+ expectedWorkspace: workspace,
553
+ actualWorkspace: containerWorkspace,
554
+ identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
384
555
  && branchLabel === params.branch
385
556
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
386
557
  runtimeCapabilityOk: enforceCanonicalPlan
@@ -487,7 +658,7 @@ function repairFindings(params) {
487
658
  }
488
659
  }
489
660
  function describeFindings(findings) {
490
- return findings.map((finding) => finding.message).join(' ');
661
+ return findings.map((finding) => finding.code ? `${finding.code}: ${finding.message}` : finding.message).join(' ');
491
662
  }
492
663
  function assess(params) {
493
664
  const snapshot = collectSandboxRecoverySnapshot(params);
@@ -547,12 +718,17 @@ export async function ensureSandboxReady(params) {
547
718
  const startFn = deps?.start ?? startSandboxContainer;
548
719
  const warnings = [];
549
720
  let failure = null;
550
- ensureSandboxControlBroker({
551
- config: params.config,
552
- container: params.row.name,
553
- workspace: params.workspace ?? { mode: 'branch-only' }
554
- });
555
721
  try {
722
+ if (deps?.ensureControlBroker) {
723
+ await deps.ensureControlBroker();
724
+ }
725
+ else {
726
+ await ensureSandboxControlBroker({
727
+ config: params.config,
728
+ container: params.row.name,
729
+ workspace: params.workspace ?? { mode: 'branch-only' }
730
+ });
731
+ }
556
732
  if (!params.row.running) {
557
733
  startFn(params.engine, params.row.name);
558
734
  const initial = assess({
@@ -668,34 +844,75 @@ export async function ensureSandboxReady(params) {
668
844
  failure = error instanceof Error ? error : new Error(String(error));
669
845
  }
670
846
  const dataBoundary = 'The existing container writable layer, ordinary /tmp data, processes, tmux sessions, and RAM state may be lost by replacement; worktree and host-managed sandbox data are preserved.';
847
+ const recoveryTarget = params.workspace?.mode === 'task-bound'
848
+ ? params.workspace.taskId
849
+ : params.branch;
671
850
  if (!params.allowRecreate || !params.recreate) {
672
- throw new Error(`Sandbox recovery failed: ${failure.message} ${dataBoundary} Re-run with --recreate to authorize container-only replacement.`);
851
+ throw new Error([
852
+ `Sandbox recovery failed. Run 'ai sandbox start --recreate ${recoveryTarget}' on the host to authorize container-only replacement.`,
853
+ dataBoundary,
854
+ `Details: ${failure.message}`
855
+ ].join('\n'));
856
+ }
857
+ const existingWorktrees = worktreeDirCandidates(params.config, params.branch).filter((candidate) => fs.existsSync(candidate));
858
+ if (existingWorktrees.length !== 1) {
859
+ const remediation = existingWorktrees.length === 0
860
+ ? `Restore the worktree for '${params.branch}' before recovering the container.`
861
+ : `Found ${existingWorktrees.map((candidate) => JSON.stringify(candidate)).join(', ')}; `
862
+ + 'keep the worktree in use, remove the stale directory, then retry.';
863
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`);
864
+ }
865
+ const beforeInspection = inspectWorktree(existingWorktrees[0]);
866
+ if (beforeInspection.status === 'failed') {
867
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
868
+ }
869
+ const before = beforeInspection.snapshot;
870
+ if (before.branch !== params.branch) {
871
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected branch '${params.branch}', found '${before.branch}'. `
872
+ + `Run 'git -C ${JSON.stringify(before.worktree)} checkout ${params.branch}' to restore the expected branch, then retry.`);
673
873
  }
674
874
  const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
675
875
  warnings.push(warning);
676
876
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
677
877
  const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
678
- if (params.row.running) {
679
- runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
878
+ let replacementResult = null;
879
+ let replacementFailure = null;
880
+ try {
881
+ if (params.row.running) {
882
+ runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
883
+ }
884
+ runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
885
+ await params.recreate(params.branch);
886
+ const rows = (deps?.fetchRows ?? fetchSandboxRows)(params.engine, sandboxLabel(params.config), sandboxBranchLabel(params.config));
887
+ const replacement = selectSandboxContainer([...rows.running, ...rows.nonRunning], containerNameCandidates(params.config, params.branch));
888
+ if (!replacement?.running) {
889
+ throw new Error('Replacement sandbox container was not found in a running state.');
890
+ }
891
+ const final = assess({
892
+ config: params.config,
893
+ engine: params.engine,
894
+ branch: params.branch,
895
+ workspace: params.workspace,
896
+ container: replacement.name,
897
+ deps
898
+ });
899
+ if (final.findings.length > 0) {
900
+ throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
901
+ }
902
+ replacementResult = { container: replacement.name, path: 'recreated', warnings };
680
903
  }
681
- runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
682
- await params.recreate(params.branch);
683
- const rows = (deps?.fetchRows ?? fetchSandboxRows)(params.engine, sandboxLabel(params.config), sandboxBranchLabel(params.config));
684
- const replacement = selectSandboxContainer([...rows.running, ...rows.nonRunning], containerNameCandidates(params.config, params.branch));
685
- if (!replacement?.running) {
686
- throw new Error('Replacement sandbox container was not found in a running state.');
904
+ catch (error) {
905
+ replacementFailure = error;
687
906
  }
688
- const final = assess({
689
- config: params.config,
690
- engine: params.engine,
691
- branch: params.branch,
692
- workspace: params.workspace,
693
- container: replacement.name,
694
- deps
695
- });
696
- if (final.findings.length > 0) {
697
- throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
907
+ const afterInspection = inspectWorktree(before.worktree);
908
+ if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
909
+ const detail = afterInspection.status === 'failed'
910
+ ? afterInspection.message
911
+ : `snapshot changed from ${before.identity} to ${afterInspection.snapshot.identity}`;
912
+ throw new Error(`SANDBOX_RECOVERY_WORKTREE_CHANGED: ${detail}`, replacementFailure === null ? undefined : { cause: replacementFailure });
698
913
  }
699
- return { container: replacement.name, path: 'recreated', warnings };
914
+ if (replacementFailure !== null)
915
+ throw replacementFailure;
916
+ return replacementResult;
700
917
  }
701
918
  //# sourceMappingURL=recovery.js.map
@@ -1,6 +1,18 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
5
+ 'active',
6
+ 'completed',
7
+ 'blocked',
8
+ 'archive'
9
+ ]);
10
+ export function sandboxWorkspaceViewStatePaths(root) {
11
+ return SANDBOX_WORKSPACE_VIEW_STATES.map((state) => ({
12
+ state,
13
+ hostPath: path.join(root, state)
14
+ }));
15
+ }
4
16
  export function sandboxWorkspaceViewPaths(params) {
5
17
  const identityKey = params.identity.mode === 'task-bound'
6
18
  ? `task-bound:${params.identity.taskId}`
@@ -21,7 +33,13 @@ export function sandboxControlPaths(params) {
21
33
  : 'branch-only';
22
34
  const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
23
35
  const root = path.resolve(params.base, params.project, params.container, digest);
24
- return { root, channelDir: path.join(root, 'channel'), manifestPath: path.join(root, 'manifest.json') };
36
+ return {
37
+ root,
38
+ channelDir: path.join(root, 'channel'),
39
+ statusDir: path.join(root, 'public'),
40
+ processingDir: path.join(root, 'processing'),
41
+ manifestPath: path.join(root, 'manifest.json')
42
+ };
25
43
  }
26
44
  function assertSafeDirectory(directory, expectedBase) {
27
45
  const relative = path.relative(expectedBase, directory);
@@ -32,6 +50,44 @@ function assertSafeDirectory(directory, expectedBase) {
32
50
  throw new Error(`Sandbox workspace view must not be a symbolic link: ${directory}`);
33
51
  }
34
52
  }
53
+ function assertSafeMountTargetPath(target, worktreeRoot, canonicalWorktreeRoot) {
54
+ const relative = path.relative(worktreeRoot, target);
55
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
56
+ throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
57
+ }
58
+ let current = worktreeRoot;
59
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
60
+ current = segment ? path.join(current, segment) : current;
61
+ try {
62
+ if (fs.lstatSync(current).isSymbolicLink()) {
63
+ throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
64
+ }
65
+ const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
66
+ if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
67
+ throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
68
+ }
69
+ }
70
+ catch (error) {
71
+ if (error.code !== 'ENOENT')
72
+ throw error;
73
+ }
74
+ }
75
+ }
76
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot) {
77
+ const resolvedWorktreeRoot = path.resolve(worktreeRoot);
78
+ const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
79
+ const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
80
+ const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
81
+ const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
82
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
83
+ assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
84
+ }
85
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
86
+ for (const { hostPath } of statePaths) {
87
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
88
+ }
89
+ fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
90
+ }
35
91
  export function assertSandboxTaskSource(repoRoot, taskId) {
36
92
  if (!/^TASK-\d{8}-\d{6}$/.test(taskId))
37
93
  throw new Error('SANDBOX_TASK_SOURCE_INVALID');
@@ -53,11 +109,10 @@ export function materializeSandboxWorkspaceView(params) {
53
109
  assertSafeDirectory(projectRoot, path.resolve(params.base));
54
110
  assertSafeDirectory(root, projectRoot);
55
111
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
56
- for (const state of ['active', 'completed', 'blocked', 'archive']) {
57
- const stateDir = path.join(root, state);
58
- assertSafeDirectory(stateDir, root);
59
- fs.rmSync(stateDir, { recursive: true, force: true });
60
- fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
112
+ for (const { hostPath } of sandboxWorkspaceViewStatePaths(root)) {
113
+ assertSafeDirectory(hostPath, root);
114
+ fs.rmSync(hostPath, { recursive: true, force: true });
115
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
61
116
  }
62
117
  const active = path.join(root, 'active');
63
118
  const registry = {
@@ -77,8 +132,16 @@ export function materializeSandboxWorkspaceView(params) {
77
132
  return { root, taskMountPath };
78
133
  }
79
134
  export function materializeSandboxControl(params) {
80
- const { root, channelDir, manifestPath } = sandboxControlPaths(params);
135
+ const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
81
136
  assertSafeDirectory(root, path.resolve(params.base));
137
+ const consumedDir = path.join(root, 'consumed');
138
+ assertSafeDirectory(consumedDir, root);
139
+ fs.rmSync(consumedDir, { recursive: true, force: true });
140
+ fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
141
+ for (const directory of [statusDir, processingDir]) {
142
+ assertSafeDirectory(directory, root);
143
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
144
+ }
82
145
  for (const queue of ['requests', 'responses']) {
83
146
  const directory = path.join(channelDir, queue);
84
147
  assertSafeDirectory(directory, root);
@@ -86,18 +149,24 @@ export function materializeSandboxControl(params) {
86
149
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
87
150
  }
88
151
  const token = randomBytes(32).toString('hex');
152
+ const generation = randomBytes(16).toString('hex');
153
+ const repoRoot = fs.realpathSync.native(params.repoRoot);
89
154
  const manifest = {
90
- version: 1,
91
- repoRoot: fs.realpathSync.native(params.repoRoot),
155
+ version: 3,
156
+ repoRoot,
157
+ worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
92
158
  project: params.project,
93
159
  container: params.container,
94
160
  branch: params.branch,
95
161
  mode: params.identity.mode,
96
162
  taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
97
163
  token,
98
- channelDir
164
+ generation,
165
+ channelDir,
166
+ publicStatusDir: statusDir,
167
+ processingDir
99
168
  };
100
169
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
101
- return { channelDir, manifestPath, token };
170
+ return { channelDir, statusDir, manifestPath, token, generation };
102
171
  }
103
172
  //# sourceMappingURL=workspace-view.js.map