@fitlab-ai/agent-infra 0.9.6 → 0.9.8

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 (178) hide show
  1. package/dist/bin/internal-cli.js +10 -0
  2. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +47 -5
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +245 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +398 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +23 -6
  8. package/dist/lib/decide.js +68 -14
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -3
  11. package/dist/lib/init.js +1 -1
  12. package/dist/lib/internal/codex-lifecycle.js +69 -1
  13. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  14. package/dist/lib/internal/release-workflow.js +1 -3
  15. package/dist/lib/internal/task-activity.js +57 -7
  16. package/dist/lib/internal/task-event.js +57 -4
  17. package/dist/lib/internal/task-ledger.js +59 -5
  18. package/dist/lib/internal/task-lifecycle.js +59 -8
  19. package/dist/lib/internal/task-orchestration.js +48 -8
  20. package/dist/lib/internal/task-override.js +131 -0
  21. package/dist/lib/internal/task-review.js +64 -6
  22. package/dist/lib/internal/task-verify.js +65 -3
  23. package/dist/lib/internal/task-warning.js +57 -4
  24. package/dist/lib/platform/context.js +6 -4
  25. package/dist/lib/platform/github-client.js +55 -2
  26. package/dist/lib/platform/pr-summary.js +5 -3
  27. package/dist/lib/process-data/checkpoint.js +319 -0
  28. package/dist/lib/process-data/index.js +128 -60
  29. package/dist/lib/process-data/materialize.js +84 -0
  30. package/dist/lib/process-data/normalize.js +34 -1
  31. package/dist/lib/process-data/reconcile.js +55 -1
  32. package/dist/lib/process-data/sources.js +422 -1
  33. package/dist/lib/process-data/store.js +341 -11
  34. package/dist/lib/runtime/agent-runtime.js +56 -0
  35. package/dist/lib/sandbox/commands/create.js +394 -264
  36. package/dist/lib/sandbox/commands/rm.js +64 -15
  37. package/dist/lib/sandbox/commands/start.js +4 -3
  38. package/dist/lib/sandbox/control/container-identity.js +64 -0
  39. package/dist/lib/sandbox/control/executor.js +23 -9
  40. package/dist/lib/sandbox/control/lifecycle.js +590 -61
  41. package/dist/lib/sandbox/control/protocol.js +7 -0
  42. package/dist/lib/sandbox/control/server.js +202 -26
  43. package/dist/lib/sandbox/control/state.js +51 -25
  44. package/dist/lib/sandbox/control/timing.js +8 -0
  45. package/dist/lib/sandbox/engine.js +14 -7
  46. package/dist/lib/sandbox/mounts.js +7 -0
  47. package/dist/lib/sandbox/recovery.js +135 -50
  48. package/dist/lib/sandbox/workspace-view.js +59 -20
  49. package/dist/lib/server/daemon.js +1 -1
  50. package/dist/lib/server/process-control.js +11 -1
  51. package/dist/lib/server/process-state.js +107 -20
  52. package/dist/lib/task/activity-intent.js +4 -1
  53. package/dist/lib/task/artifact-lifecycle.js +82 -11
  54. package/dist/lib/task/artifact-receipts.js +128 -0
  55. package/dist/lib/task/codex-orchestration.js +81 -6
  56. package/dist/lib/task/commands/validate.js +1 -1
  57. package/dist/lib/task/create.js +10 -2
  58. package/dist/lib/task/decision-intents.js +4 -2
  59. package/dist/lib/task/delegation-receipts.js +120 -17
  60. package/dist/lib/task/events.js +148 -7
  61. package/dist/lib/task/guard-override.js +31 -0
  62. package/dist/lib/task/human-override.js +1240 -0
  63. package/dist/lib/task/ledger-intents.js +6 -3
  64. package/dist/lib/task/ledger.js +13 -1
  65. package/dist/lib/task/lifecycle.js +69 -14
  66. package/dist/lib/task/orchestration.js +293 -18
  67. package/dist/lib/task/review-finalization.js +3 -2
  68. package/dist/lib/task/sections.js +5 -1
  69. package/dist/lib/task/task-execution-lock.js +5 -4
  70. package/dist/lib/task/verification-engine.js +80 -22
  71. package/dist/lib/task/verification.js +13 -1
  72. package/dist/lib/task/workflow-warning-intents.js +4 -2
  73. package/dist/lib/task/write.js +2 -1
  74. package/dist/lifecycle-build-manifest.json +6 -0
  75. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  76. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +62 -4
  77. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  78. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +322 -0
  79. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  80. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +480 -0
  81. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +35 -5
  82. package/lib/decide.ts +59 -14
  83. package/lib/defaults.json +10 -2
  84. package/lib/git/workflow.ts +25 -2
  85. package/lib/init.ts +1 -1
  86. package/lib/internal/codex-lifecycle.ts +68 -1
  87. package/lib/internal/codex-sandbox-controller.ts +109 -0
  88. package/lib/internal/release-workflow.ts +1 -3
  89. package/lib/internal/task-activity.ts +46 -6
  90. package/lib/internal/task-event.ts +53 -4
  91. package/lib/internal/task-ledger.ts +48 -4
  92. package/lib/internal/task-lifecycle.ts +56 -12
  93. package/lib/internal/task-orchestration.ts +43 -8
  94. package/lib/internal/task-override.ts +138 -0
  95. package/lib/internal/task-review.ts +51 -6
  96. package/lib/internal/task-verify.ts +52 -3
  97. package/lib/internal/task-warning.ts +44 -3
  98. package/lib/platform/context.ts +8 -5
  99. package/lib/platform/github-client.ts +61 -3
  100. package/lib/platform/pr-summary.ts +10 -3
  101. package/lib/process-data/checkpoint.ts +335 -0
  102. package/lib/process-data/index.ts +112 -60
  103. package/lib/process-data/materialize.ts +83 -0
  104. package/lib/process-data/normalize.ts +29 -1
  105. package/lib/process-data/reconcile.ts +64 -2
  106. package/lib/process-data/sources.ts +449 -2
  107. package/lib/process-data/store.ts +304 -11
  108. package/lib/process-data/types.ts +124 -2
  109. package/lib/runtime/agent-runtime.ts +76 -0
  110. package/lib/sandbox/commands/create.ts +139 -6
  111. package/lib/sandbox/commands/rm.ts +64 -14
  112. package/lib/sandbox/commands/start.ts +4 -3
  113. package/lib/sandbox/config.ts +2 -1
  114. package/lib/sandbox/control/container-identity.ts +93 -0
  115. package/lib/sandbox/control/executor.ts +24 -12
  116. package/lib/sandbox/control/lifecycle.ts +685 -67
  117. package/lib/sandbox/control/protocol.ts +35 -6
  118. package/lib/sandbox/control/server.ts +184 -24
  119. package/lib/sandbox/control/state.ts +67 -33
  120. package/lib/sandbox/control/timing.ts +12 -0
  121. package/lib/sandbox/engine.ts +20 -9
  122. package/lib/sandbox/mounts.ts +8 -0
  123. package/lib/sandbox/recovery.ts +159 -49
  124. package/lib/sandbox/workspace-view.ts +73 -22
  125. package/lib/server/daemon.ts +1 -1
  126. package/lib/server/process-control.ts +13 -3
  127. package/lib/server/process-state.ts +129 -27
  128. package/lib/task/activity-intent.ts +4 -2
  129. package/lib/task/artifact-lifecycle.ts +73 -10
  130. package/lib/task/artifact-receipts.ts +161 -0
  131. package/lib/task/codex-orchestration.ts +91 -5
  132. package/lib/task/commands/validate.ts +1 -1
  133. package/lib/task/create.ts +11 -2
  134. package/lib/task/decision-intents.ts +4 -2
  135. package/lib/task/delegation-receipts.ts +205 -25
  136. package/lib/task/events.ts +132 -6
  137. package/lib/task/guard-override.ts +50 -0
  138. package/lib/task/human-override.ts +1503 -0
  139. package/lib/task/ledger-intents.ts +6 -3
  140. package/lib/task/ledger.ts +16 -1
  141. package/lib/task/lifecycle.ts +71 -14
  142. package/lib/task/orchestration.ts +443 -22
  143. package/lib/task/review-finalization.ts +6 -2
  144. package/lib/task/sections.ts +6 -2
  145. package/lib/task/task-execution-lock.ts +11 -9
  146. package/lib/task/verification-engine.ts +93 -32
  147. package/lib/task/verification.ts +15 -1
  148. package/lib/task/workflow-warning-intents.ts +4 -2
  149. package/lib/task/write.ts +4 -1
  150. package/package.json +1 -1
  151. package/templates/.agents/README.en.md +7 -0
  152. package/templates/.agents/README.zh-CN.md +5 -0
  153. package/templates/.agents/hooks/lifecycle-delegation.js +33 -4
  154. package/templates/.agents/rules/lifecycle-orchestration.en.md +10 -3
  155. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +10 -3
  156. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  157. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  158. package/templates/.agents/rules/review-handshake.en.md +3 -1
  159. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  160. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  161. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  162. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  163. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  164. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +6 -6
  165. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +6 -6
  166. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -3
  167. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -3
  168. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  169. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  170. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +25 -0
  171. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +25 -0
  172. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  173. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  174. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -1
  175. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -1
  176. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  177. package/templates/.agents/workspace/README.en.md +0 -26
  178. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -18,10 +18,20 @@ const PLATFORM_DEFAULTS = Object.freeze({
18
18
  win32: ENGINES.WSL2
19
19
  });
20
20
 
21
+ export type SandboxEngineInput = string | Record<string, string | null | undefined> | null | undefined;
22
+
21
23
  type EngineConfig = EffectiveSandboxConfig & {
22
- engine?: string | null;
24
+ engine?: SandboxEngineInput;
23
25
  };
24
26
 
27
+ function isPerPlatformEngine(engine: SandboxEngineInput): engine is Record<string, string | null | undefined> {
28
+ return typeof engine === 'object' && engine !== null;
29
+ }
30
+
31
+ function resolveEngineForPlatform(engine: SandboxEngineInput, os: string): string | null | undefined {
32
+ return isPerPlatformEngine(engine) ? engine[os] : engine;
33
+ }
34
+
25
35
  type EngineDependencies = {
26
36
  platformFn?: typeof platform;
27
37
  runFn?: RunFns['run'];
@@ -83,33 +93,34 @@ function ensureBuildKit(engine: string, runOkEngineFn: typeof runOkEngine): void
83
93
  }
84
94
 
85
95
  export function validateSandboxEngine(
86
- engine: string | null | undefined,
96
+ engine: SandboxEngineInput,
87
97
  { platformFn = platform }: Pick<EngineDependencies, 'platformFn'> = {}
88
98
  ): string | null {
89
- if (engine === null || engine === undefined) {
99
+ const os = platformFn();
100
+ const resolved = resolveEngineForPlatform(engine, os);
101
+ if (resolved === null || resolved === undefined) {
90
102
  return null;
91
103
  }
92
104
 
93
- const os = platformFn();
94
- if (!(engine in ADAPTERS)) {
105
+ if (!(resolved in ADAPTERS)) {
95
106
  const known = Object.keys(ADAPTERS).join(', ');
96
107
  throw new Error(
97
- `sandbox: invalid "sandbox.engine" value "${engine}" (unknown sandbox engine). `
108
+ `sandbox: invalid "sandbox.engine" value "${resolved}" (unknown sandbox engine). `
98
109
  + `Valid engines: ${known}.`
99
110
  );
100
111
  }
101
112
 
102
- const adapter = ADAPTERS[engine as keyof typeof ADAPTERS];
113
+ const adapter = ADAPTERS[resolved as keyof typeof ADAPTERS];
103
114
  if (!adapter.supportedPlatforms.includes(os)) {
104
115
  const supported = enginesForPlatform(os);
105
116
  const supportedList = supported.length > 0 ? supported.join(', ') : 'none';
106
117
  throw new Error(
107
- `sandbox: "sandbox.engine" value "${engine}" is not supported on ${os}. `
118
+ `sandbox: "sandbox.engine" value "${resolved}" is not supported on ${os}. `
108
119
  + `Supported engines on ${os}: ${supportedList}.`
109
120
  );
110
121
  }
111
122
 
112
- return engine;
123
+ return resolved;
113
124
  }
114
125
 
115
126
  export function detectEngine(
@@ -26,6 +26,7 @@ export function sandboxCoreBindMounts(
26
26
  workspaceViewRoot: string;
27
27
  controlDir: string;
28
28
  controlStatusDir: string;
29
+ runtimeDir?: string;
29
30
  taskSource?: string;
30
31
  taskId?: string;
31
32
  }
@@ -89,5 +90,12 @@ export function sandboxCoreBindMounts(
89
90
  containerPath: '/run/agent-infra/control',
90
91
  readOnly: false
91
92
  });
93
+ if (taskBound && overrides.runtimeDir) {
94
+ mounts.push({
95
+ hostPaths: [overrides.runtimeDir],
96
+ containerPath: '/run/agent-infra/runtime',
97
+ readOnly: false
98
+ });
99
+ }
92
100
  return mounts;
93
101
  }
@@ -35,7 +35,7 @@ import {
35
35
  type SandboxWorkspaceIdentity
36
36
  } from './workspace-identity.ts';
37
37
  import { inspectWorktree, type WorktreeSnapshot } from './worktree-safety.ts';
38
- import { runEngine, runOkEngine, runVerboseEngine } from './shell.ts';
38
+ import { commandForEngine, runEngine, runOkEngine, runProbe, runVerboseEngine } from './shell.ts';
39
39
  import {
40
40
  declaredTmpfsSeedEntries,
41
41
  resolveTools,
@@ -49,15 +49,19 @@ import {
49
49
  startSandboxContainer,
50
50
  type SandboxRow
51
51
  } from './commands/list-running.ts';
52
- import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.ts';
52
+ import { getProcessIdentityState, getProcessStartTime, removePidFileIfMatches } from '../server/process-state.ts';
53
53
  import {
54
54
  acquireSandboxControlBrokerStartup,
55
- isSandboxControlRootQuiescing
55
+ garbageCollectSandboxControlRoot,
56
+ isSandboxControlRootQuiescing,
57
+ readSandboxControlManifest,
58
+ type BrokerOwner
56
59
  } from './control/lifecycle.ts';
57
60
  import {
58
61
  appendSandboxControlAudit,
59
62
  readSandboxControlStatus
60
63
  } from './control/state.ts';
64
+ import { inspectSandboxControlContainer } from './control/container-identity.ts';
61
65
  import {
62
66
  SANDBOX_CONTROL_FUTURE_SKEW_MS,
63
67
  SANDBOX_CONTROL_STATUS_STALE_MS,
@@ -89,6 +93,9 @@ export type SandboxRecoverySnapshot = {
89
93
  actualBranch: string | null;
90
94
  expectedWorkspace: SandboxWorkspaceIdentity;
91
95
  actualWorkspace: SandboxContainerWorkspaceIdentity;
96
+ workspaceTopology?: 'legacy-parent' | 'per-state' | 'unknown';
97
+ taskView?: { path: string; readable: boolean };
98
+ runtimeStoreOk?: boolean;
92
99
  runtimeCapabilityOk?: boolean;
93
100
  unexpectedCapabilityMounts?: string[];
94
101
  mounts: Array<{
@@ -142,8 +149,27 @@ type RecoveryCommandDeps = {
142
149
  start?: typeof startSandboxContainer;
143
150
  fetchRows?: typeof fetchSandboxRows;
144
151
  ensureControlBroker?: () => void | Promise<void>;
152
+ probe?: typeof runProbe;
145
153
  };
146
154
 
155
+ export function worktreeProbeForEngine(
156
+ engine: string,
157
+ probe: typeof runProbe = runProbe
158
+ ): typeof runProbe {
159
+ return (cmd, args, opts = {}) => {
160
+ const engineArgs = [...args];
161
+ if (engine === 'wsl2' && cmd === 'git') {
162
+ for (let index = 0; index < engineArgs.length - 1; index += 1) {
163
+ if (engineArgs[index] === '-C') {
164
+ engineArgs[index + 1] = toEnginePath(engine, engineArgs[index + 1]!);
165
+ }
166
+ }
167
+ }
168
+ const command = commandForEngine(engine, cmd, engineArgs);
169
+ return probe(command.cmd, command.args, opts);
170
+ };
171
+ }
172
+
147
173
  type EnsureSandboxReadyParams = {
148
174
  config: SandboxConfig;
149
175
  engine: string;
@@ -151,6 +177,7 @@ type EnsureSandboxReadyParams = {
151
177
  workspace?: SandboxWorkspaceIdentity;
152
178
  row: SandboxRow;
153
179
  allowRecreate?: boolean;
180
+ forceRecreate?: boolean;
154
181
  recreate?: (branch: string) => Promise<void>;
155
182
  writeWarning?: (message: string) => void;
156
183
  deps?: RecoveryCommandDeps;
@@ -168,7 +195,7 @@ async function waitForSandboxControlBrokerStatus(params: {
168
195
  statusDir: string;
169
196
  generation: string;
170
197
  pid: number;
171
- startTime?: string;
198
+ startTime?: number;
172
199
  }, timeoutMs = 5_000): Promise<void> {
173
200
  const deadline = Date.now() + timeoutMs;
174
201
  while (Date.now() < deadline) {
@@ -191,7 +218,7 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
191
218
  const directory = path.dirname(fileURLToPath(import.meta.url));
192
219
  const extension = path.extname(fileURLToPath(import.meta.url));
193
220
  const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
194
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as SandboxControlManifest;
221
+ const manifest = readSandboxControlManifest(manifestPath);
195
222
  const root = path.dirname(manifestPath);
196
223
  if (isSandboxControlRootQuiescing(root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
197
224
  const brokerPath = path.join(root, 'broker.json');
@@ -207,6 +234,7 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
207
234
  version?: unknown;
208
235
  pid?: unknown;
209
236
  startTime?: unknown;
237
+ brokerId?: unknown;
210
238
  token?: unknown;
211
239
  generation?: unknown;
212
240
  };
@@ -216,15 +244,24 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
216
244
  } catch {
217
245
  // Malformed owner records are stale and replaced below.
218
246
  }
219
- const owner = broker !== null && typeof broker.pid === 'number' && typeof broker.startTime === 'string'
220
- ? { version: 1 as const, pid: broker.pid, startTime: broker.startTime }
247
+ const owner = broker !== null && broker.version === 3 && typeof broker.pid === 'number'
248
+ && Number.isSafeInteger(broker.pid) && broker.pid > 0 && typeof broker.startTime === 'number'
249
+ && Number.isSafeInteger(broker.startTime) && typeof broker.brokerId === 'string'
250
+ && broker.brokerId.length > 0 && typeof broker.token === 'string'
251
+ && typeof broker.generation === 'string'
252
+ ? broker as BrokerOwner
221
253
  : null;
222
- const live = owner !== null && processIdentityMatches(owner);
254
+ if (broker === null || broker.version !== 3 || owner === null) {
255
+ throw new Error('SANDBOX_CONTROL_BROKER_VERSION_INVALID: rebuild the sandbox container before starting a new broker');
256
+ }
257
+ const ownerState = getProcessIdentityState({ pid: owner.pid, startTime: owner.startTime });
258
+ if (ownerState === 'unknown') throw new Error('SANDBOX_CONTROL_BROKER_OWNER_UNAVAILABLE');
259
+ const live = ownerState === 'alive';
223
260
  if (
224
261
  live
225
262
  && owner !== null
226
263
  && broker !== null
227
- && broker.version === 2
264
+ && broker.version === 3
228
265
  && broker.token === manifest.token
229
266
  && broker.generation === manifest.generation
230
267
  ) {
@@ -237,27 +274,6 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
237
274
  });
238
275
  return;
239
276
  }
240
- if (live && owner) {
241
- const deadline = Date.now() + 5_000;
242
- while (Date.now() < deadline) {
243
- await new Promise((resolve) => setTimeout(resolve, 25));
244
- try {
245
- if (fs.readFileSync(brokerPath, 'utf8') !== brokerSnapshot) {
246
- return startSandboxControlBroker(repoRoot, manifestPath);
247
- }
248
- } catch (error) {
249
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
250
- return startSandboxControlBroker(repoRoot, manifestPath);
251
- }
252
- throw error;
253
- }
254
- if (!processIdentityMatches(owner)) {
255
- removePidFileIfMatches(brokerPath, brokerSnapshot);
256
- return startSandboxControlBroker(repoRoot, manifestPath);
257
- }
258
- }
259
- throw new Error('SANDBOX_CONTROL_BROKER_OWNER_ACTIVE');
260
- }
261
277
  }
262
278
  const startupStartTime = getProcessStartTime(process.pid);
263
279
  if (!startupStartTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
@@ -317,9 +333,16 @@ async function ensureSandboxControlBroker(params: {
317
333
  identity: params.workspace
318
334
  });
319
335
  if (!fs.existsSync(control.manifestPath)) return;
320
- const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as SandboxControlManifest;
321
- if (manifest.version !== 3) {
322
- throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
336
+ const validatedManifest = readSandboxControlManifest(control.manifestPath);
337
+ const containerObservation = await inspectSandboxControlContainer(validatedManifest);
338
+ if (containerObservation.state === 'unknown') {
339
+ throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${containerObservation.reason}`);
340
+ }
341
+ if (containerObservation.state === 'absent') {
342
+ await garbageCollectSandboxControlRoot(control.root, {
343
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(validatedManifest, { timeoutMs })
344
+ });
345
+ throw new Error('SANDBOX_CONTROL_CONTAINER_ABSENT: recreate the sandbox container before broker recovery');
323
346
  }
324
347
  const brokerPath = path.join(control.root, 'broker.json');
325
348
  try {
@@ -327,20 +350,33 @@ async function ensureSandboxControlBroker(params: {
327
350
  version?: unknown;
328
351
  pid?: unknown;
329
352
  startTime?: unknown;
353
+ brokerId?: unknown;
330
354
  token?: unknown;
331
355
  generation?: unknown;
332
356
  };
357
+ const validIdentity = typeof broker.pid === 'number'
358
+ && Number.isSafeInteger(broker.pid)
359
+ && typeof broker.startTime === 'number'
360
+ && Number.isSafeInteger(broker.startTime);
361
+ const ownerState = validIdentity
362
+ ? getProcessIdentityState({ pid: broker.pid as number, startTime: broker.startTime as number })
363
+ : 'dead';
364
+ if (ownerState === 'unknown') throw new Error('SANDBOX_CONTROL_BROKER_OWNER_UNAVAILABLE');
333
365
  if (
334
- broker.version === 2
366
+ broker.version === 3
335
367
  && typeof broker.pid === 'number'
336
- && typeof broker.startTime === 'string'
337
- && broker.token === manifest.token
338
- && broker.generation === manifest.generation
339
- && processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })
368
+ && Number.isSafeInteger(broker.pid)
369
+ && typeof broker.startTime === 'number'
370
+ && Number.isSafeInteger(broker.startTime)
371
+ && typeof broker.brokerId === 'string'
372
+ && broker.brokerId.length > 0
373
+ && broker.token === validatedManifest.token
374
+ && broker.generation === validatedManifest.generation
375
+ && ownerState === 'alive'
340
376
  ) {
341
377
  try {
342
378
  const status = readSandboxControlStatus(control.statusDir);
343
- if (status.generation === manifest.generation && status.broker.pid === broker.pid
379
+ if (status.generation === validatedManifest.generation && status.broker.pid === broker.pid
344
380
  && status.broker.startTime === broker.startTime
345
381
  && Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
346
382
  && status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
@@ -350,13 +386,14 @@ async function ensureSandboxControlBroker(params: {
350
386
  await waitForSandboxControlBrokerStatus({
351
387
  root: control.root,
352
388
  statusDir: control.statusDir,
353
- generation: manifest.generation,
389
+ generation: validatedManifest.generation,
354
390
  pid: broker.pid,
355
391
  startTime: broker.startTime
356
392
  });
357
393
  return;
358
394
  }
359
- } catch {
395
+ } catch (error) {
396
+ if (error instanceof Error && error.message === 'SANDBOX_CONTROL_BROKER_OWNER_UNAVAILABLE') throw error;
360
397
  // A missing, stale, or malformed broker record is replaced below.
361
398
  }
362
399
  await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
@@ -393,6 +430,30 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
393
430
  message: 'Container runtime capability signature does not match the current sandbox plan.'
394
431
  });
395
432
  }
433
+ if (snapshot.workspaceTopology === 'legacy-parent') {
434
+ findings.push({
435
+ repairKind: 'hard-failure',
436
+ code: 'SANDBOX_WORKSPACE_TOPOLOGY_MISMATCH',
437
+ message: 'Sandbox uses the legacy workspace parent-mount topology; recreate the container explicitly to migrate it.',
438
+ path: '/workspace/.agents/workspace'
439
+ });
440
+ }
441
+ if (snapshot.taskView && !snapshot.taskView.readable) {
442
+ findings.push({
443
+ repairKind: 'hard-failure',
444
+ code: 'SANDBOX_TASK_VIEW_UNREADABLE',
445
+ message: `Bound task view is not readable at ${snapshot.taskView.path}.`,
446
+ path: snapshot.taskView.path
447
+ });
448
+ }
449
+ if (snapshot.runtimeStoreOk === false) {
450
+ findings.push({
451
+ repairKind: 'hard-failure',
452
+ code: 'SANDBOX_RUNTIME_STORE_UNAVAILABLE',
453
+ message: 'Task-bound runtime store is not writable and readable by devuser.',
454
+ path: '/run/agent-infra/runtime'
455
+ });
456
+ }
396
457
  for (const mountPath of snapshot.unexpectedCapabilityMounts ?? []) {
397
458
  findings.push({
398
459
  repairKind: 'hard-failure',
@@ -402,6 +463,10 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
402
463
  }
403
464
 
404
465
  for (const mount of snapshot.mounts) {
466
+ if (
467
+ snapshot.workspaceTopology === 'legacy-parent'
468
+ && mount.path.startsWith('/workspace/.agents/workspace')
469
+ ) continue;
405
470
  if (
406
471
  mount.actualType !== mount.expectedType
407
472
  || !mount.sourceMatches
@@ -494,6 +559,20 @@ function inspectContainer(
494
559
  return parsed[0] as DockerInspection;
495
560
  }
496
561
 
562
+ function workspaceTopology(mountsByDestination: ReadonlyMap<string, DockerMount>): SandboxRecoverySnapshot['workspaceTopology'] {
563
+ if (mountsByDestination.has('/workspace/.agents/workspace')) return 'legacy-parent';
564
+ const perState = [
565
+ '/workspace/.agents/workspace/active',
566
+ '/workspace/.agents/workspace/active/.short-ids.json',
567
+ '/workspace/.agents/workspace/completed',
568
+ '/workspace/.agents/workspace/blocked',
569
+ '/workspace/.agents/workspace/archive'
570
+ ];
571
+ return perState.some((destination) => mountsByDestination.has(destination))
572
+ ? 'per-state'
573
+ : 'unknown';
574
+ }
575
+
497
576
  function probe(
498
577
  engine: string,
499
578
  container: string,
@@ -573,6 +652,7 @@ function expectedMounts(params: {
573
652
  controlStatusDir: control.statusDir,
574
653
  ...(params.workspace.mode === 'task-bound'
575
654
  ? {
655
+ runtimeDir: control.runtimeDir,
576
656
  taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
577
657
  taskId: params.workspace.taskId
578
658
  }
@@ -706,6 +786,10 @@ export function collectSandboxRecoverySnapshot(params: {
706
786
  const capabilityPlan = createSandboxCapabilityPlan(params.config);
707
787
  const workspace = params.workspace ?? { mode: 'branch-only' as const };
708
788
  const tools = [...capabilityPlan.tools];
789
+ const topology = workspaceTopology(mountsByDestination);
790
+ const taskViewPath = workspace.mode === 'task-bound'
791
+ ? `/workspace/.agents/workspace/active/${workspace.taskId}/task.md`
792
+ : null;
709
793
  const seeds = declaredTmpfsSeedEntries(tools).map((seed) => {
710
794
  const mounted = mountsByDestination.get(seed.stagingPath)?.Type === 'bind';
711
795
  return {
@@ -736,6 +820,15 @@ export function collectSandboxRecoverySnapshot(params: {
736
820
  runOkFn
737
821
  )
738
822
  }));
823
+ const runtimeStoreOk = workspace.mode === 'task-bound'
824
+ ? probe(
825
+ params.engine,
826
+ params.container,
827
+ 'probe="$1/.agent-infra-runtime-$$"; trap \'rm -f -- "$probe"\' EXIT; printf runtime-probe > "$probe" && test "$(cat -- "$probe")" = runtime-probe',
828
+ ['/run/agent-infra/runtime'],
829
+ runOkFn
830
+ )
831
+ : undefined;
739
832
  const labels = inspection.Config?.Labels ?? {};
740
833
  const branchLabel = labels[sandboxBranchLabel(params.config)];
741
834
  const containerWorkspace = parseSandboxWorkspaceIdentity(labels, {
@@ -797,6 +890,20 @@ export function collectSandboxRecoverySnapshot(params: {
797
890
  actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
798
891
  expectedWorkspace: workspace,
799
892
  actualWorkspace: containerWorkspace,
893
+ workspaceTopology: topology,
894
+ ...(taskViewPath === null ? {} : {
895
+ taskView: {
896
+ path: taskViewPath,
897
+ readable: probe(
898
+ params.engine,
899
+ params.container,
900
+ 'test -r "$1"',
901
+ [taskViewPath],
902
+ runOkFn
903
+ )
904
+ }
905
+ }),
906
+ ...(runtimeStoreOk === undefined ? {} : { runtimeStoreOk }),
800
907
  identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
801
908
  && branchLabel === params.branch
802
909
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
@@ -973,11 +1080,13 @@ function assess(params: {
973
1080
  function canRetryFreshReadiness(findings: SandboxRecoveryFinding[]): boolean {
974
1081
  return findings.length > 0 && findings.every((finding) =>
975
1082
  finding.repairKind === 'hard-failure'
1083
+ && finding.code !== 'SANDBOX_WORKSPACE_TOPOLOGY_MISMATCH'
976
1084
  && finding.path !== undefined
977
1085
  && (
978
1086
  finding.path.startsWith('/workspace/.agents/workspace')
979
1087
  || finding.path === '/home/devuser/.host-shell-config'
980
1088
  || finding.path === '/home/devuser/.bash_aliases'
1089
+ || finding.path === '/run/agent-infra/runtime'
981
1090
  )
982
1091
  );
983
1092
  }
@@ -1048,6 +1157,9 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1048
1157
  workspace: params.workspace ?? { mode: 'branch-only' }
1049
1158
  });
1050
1159
  }
1160
+ if (params.forceRecreate) {
1161
+ throw new Error('Explicit container recreation requested.');
1162
+ }
1051
1163
  if (!params.row.running) {
1052
1164
  startFn(params.engine, params.row.name);
1053
1165
  const initial = assess({
@@ -1192,7 +1304,8 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1192
1304
  `SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`
1193
1305
  );
1194
1306
  }
1195
- const beforeInspection = inspectWorktree(existingWorktrees[0]!);
1307
+ const worktreeProbe = worktreeProbeForEngine(params.engine, deps?.probe);
1308
+ const beforeInspection = inspectWorktree(existingWorktrees[0]!, { probe: worktreeProbe });
1196
1309
  if (beforeInspection.status === 'failed') {
1197
1310
  throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
1198
1311
  }
@@ -1204,17 +1317,14 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1204
1317
  );
1205
1318
  }
1206
1319
 
1207
- const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
1320
+ const warning = params.forceRecreate
1321
+ ? `Explicit sandbox recreation requested. Replacing only the container. ${dataBoundary}`
1322
+ : `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
1208
1323
  warnings.push(warning);
1209
1324
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
1210
- const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
1211
1325
  let replacementResult: SandboxReadyResult | null = null;
1212
1326
  let replacementFailure: unknown = null;
1213
1327
  try {
1214
- if (params.row.running) {
1215
- runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
1216
- }
1217
- runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
1218
1328
  await params.recreate(params.branch);
1219
1329
 
1220
1330
  const rows = (deps?.fetchRows ?? fetchSandboxRows)(
@@ -1245,7 +1355,7 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1245
1355
  replacementFailure = error;
1246
1356
  }
1247
1357
 
1248
- const afterInspection = inspectWorktree(before.worktree);
1358
+ const afterInspection = inspectWorktree(before.worktree, { probe: worktreeProbe });
1249
1359
  if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
1250
1360
  const detail = afterInspection.status === 'failed'
1251
1361
  ? afterInspection.message
@@ -47,8 +47,10 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
47
47
  }
48
48
 
49
49
  export type SandboxControlSetup = Readonly<{
50
+ root: string;
50
51
  channelDir: string;
51
52
  statusDir: string;
53
+ runtimeDir: string;
52
54
  manifestPath: string;
53
55
  token: string;
54
56
  generation: string;
@@ -59,7 +61,7 @@ export function sandboxControlPaths(params: Readonly<{
59
61
  project: string;
60
62
  container: string;
61
63
  identity: SandboxWorkspaceIdentity;
62
- }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; manifestPath: string }> {
64
+ }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; runtimeDir: string; manifestPath: string }> {
63
65
  const identityKey = params.identity.mode === 'task-bound'
64
66
  ? `task-bound:${params.identity.taskId}`
65
67
  : 'branch-only';
@@ -70,6 +72,7 @@ export function sandboxControlPaths(params: Readonly<{
70
72
  channelDir: path.join(root, 'channel'),
71
73
  statusDir: path.join(root, 'public'),
72
74
  processingDir: path.join(root, 'processing'),
75
+ runtimeDir: path.join(root, 'runtime'),
73
76
  manifestPath: path.join(root, 'manifest.json')
74
77
  };
75
78
  }
@@ -84,7 +87,11 @@ function assertSafeDirectory(directory: string, expectedBase: string): void {
84
87
  }
85
88
  }
86
89
 
87
- function assertSafeMountTargetPath(target: string, worktreeRoot: string, canonicalWorktreeRoot: string): void {
90
+ function assertSafeMountTargetPath(
91
+ target: string,
92
+ worktreeRoot: string,
93
+ canonicalWorktreeRoot: string
94
+ ): void {
88
95
  const relative = path.relative(worktreeRoot, target);
89
96
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
90
97
  throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
@@ -106,6 +113,21 @@ function assertSafeMountTargetPath(target: string, worktreeRoot: string, canonic
106
113
  }
107
114
  }
108
115
 
116
+ export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
117
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
118
+ const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
119
+ const source = path.join(activeRoot, taskId);
120
+ const stat = fs.lstatSync(source);
121
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
122
+ throw new Error(`SANDBOX_TASK_SOURCE_INVALID: ${source} must be a real directory`);
123
+ }
124
+ const canonical = fs.realpathSync.native(source);
125
+ if (path.dirname(canonical) !== activeRoot) {
126
+ throw new Error(`SANDBOX_TASK_SOURCE_INVALID: ${source} escapes the active workspace`);
127
+ }
128
+ return canonical;
129
+ }
130
+
109
131
  export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
110
132
  const resolvedWorktreeRoot = path.resolve(worktreeRoot);
111
133
  const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
@@ -118,25 +140,12 @@ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void
118
140
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
119
141
  for (const { hostPath } of statePaths) {
120
142
  fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
143
+ fs.chmodSync(hostPath, 0o700);
121
144
  }
145
+ fs.chmodSync(workspaceRoot, 0o700);
122
146
  fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
123
147
  }
124
148
 
125
- export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
126
- if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
127
- const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
128
- const source = path.join(activeRoot, taskId);
129
- const stat = fs.lstatSync(source);
130
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
131
- throw new Error(`SANDBOX_TASK_SOURCE_INVALID: ${source} must be a real directory`);
132
- }
133
- const canonical = fs.realpathSync.native(source);
134
- if (path.dirname(canonical) !== activeRoot) {
135
- throw new Error(`SANDBOX_TASK_SOURCE_INVALID: ${source} escapes the active workspace`);
136
- }
137
- return canonical;
138
- }
139
-
140
149
  export function materializeSandboxWorkspaceView(params: Readonly<{
141
150
  base: string;
142
151
  project: string;
@@ -180,17 +189,38 @@ export function materializeSandboxControl(params: Readonly<{
180
189
  container: string;
181
190
  branch: string;
182
191
  identity: SandboxWorkspaceIdentity;
192
+ engine?: string;
193
+ replacementLease?: Readonly<{
194
+ root: string;
195
+ assertOwned(): void;
196
+ }>;
183
197
  }>): SandboxControlSetup {
184
- const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
198
+ const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
185
199
  assertSafeDirectory(root, path.resolve(params.base));
200
+ if (fs.existsSync(root)) {
201
+ if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
202
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
203
+ }
204
+ params.replacementLease.assertOwned();
205
+ }
186
206
  const consumedDir = path.join(root, 'consumed');
187
207
  assertSafeDirectory(consumedDir, root);
188
208
  fs.rmSync(consumedDir, { recursive: true, force: true });
189
209
  fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
190
- for (const directory of [statusDir, processingDir]) {
210
+ for (const directory of [statusDir, processingDir, runtimeDir]) {
191
211
  assertSafeDirectory(directory, root);
212
+ if (directory === runtimeDir) fs.rmSync(directory, { recursive: true, force: true });
192
213
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
193
214
  }
215
+ for (const directory of [
216
+ path.join(runtimeDir, 'clients'),
217
+ path.join(runtimeDir, 'clients', 'codex', 'capabilities'),
218
+ path.join(runtimeDir, 'clients', 'codex', 'lifecycle')
219
+ ]) {
220
+ assertSafeDirectory(directory, runtimeDir);
221
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
222
+ fs.chmodSync(directory, 0o700);
223
+ }
194
224
  for (const queue of ['requests', 'responses']) {
195
225
  const directory = path.join(channelDir, queue);
196
226
  assertSafeDirectory(directory, root);
@@ -201,11 +231,13 @@ export function materializeSandboxControl(params: Readonly<{
201
231
  const generation = randomBytes(16).toString('hex');
202
232
  const repoRoot = fs.realpathSync.native(params.repoRoot);
203
233
  const manifest: SandboxControlManifest = {
204
- version: 3,
234
+ version: 5,
235
+ engine: params.engine ?? 'docker',
205
236
  repoRoot,
206
237
  worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
207
238
  project: params.project,
208
239
  container: params.container,
240
+ containerIdentity: { id: '', labels: {} },
209
241
  branch: params.branch,
210
242
  mode: params.identity.mode,
211
243
  taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
@@ -213,8 +245,27 @@ export function materializeSandboxControl(params: Readonly<{
213
245
  generation,
214
246
  channelDir,
215
247
  publicStatusDir: statusDir,
216
- processingDir
248
+ processingDir,
249
+ runtimeDir
217
250
  };
218
251
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
219
- return { channelDir, statusDir, manifestPath, token, generation };
252
+ return { root, channelDir, statusDir, runtimeDir, manifestPath, token, generation };
253
+ }
254
+
255
+ export function finalizeSandboxControlManifest(
256
+ setup: SandboxControlSetup,
257
+ identity: Readonly<{ engine: string; id: string; labels: Readonly<Record<string, string>> }>
258
+ ): void {
259
+ if (!identity.engine || !identity.id) throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
260
+ const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8')) as Record<string, unknown>;
261
+ manifest.version = 5;
262
+ manifest.engine = identity.engine;
263
+ manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
264
+ const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
265
+ try {
266
+ fs.writeFileSync(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
267
+ fs.renameSync(temporary, setup.manifestPath);
268
+ } finally {
269
+ fs.rmSync(temporary, { force: true });
270
+ }
220
271
  }