@fitlab-ai/agent-infra 0.9.7 → 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 (96) hide show
  1. package/dist/bin/internal-cli.js +5 -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 +2 -1
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +2 -1
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +30 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +2 -1
  7. package/dist/lib/decide.js +68 -14
  8. package/dist/lib/init.js +1 -1
  9. package/dist/lib/internal/release-workflow.js +1 -3
  10. package/dist/lib/internal/task-activity.js +57 -7
  11. package/dist/lib/internal/task-event.js +57 -4
  12. package/dist/lib/internal/task-ledger.js +59 -5
  13. package/dist/lib/internal/task-lifecycle.js +59 -8
  14. package/dist/lib/internal/task-orchestration.js +11 -3
  15. package/dist/lib/internal/task-override.js +131 -0
  16. package/dist/lib/internal/task-review.js +64 -6
  17. package/dist/lib/internal/task-verify.js +65 -3
  18. package/dist/lib/internal/task-warning.js +57 -4
  19. package/dist/lib/platform/pr-summary.js +5 -3
  20. package/dist/lib/runtime/agent-runtime.js +56 -0
  21. package/dist/lib/sandbox/commands/create.js +389 -311
  22. package/dist/lib/sandbox/control/container-identity.js +1 -1
  23. package/dist/lib/sandbox/control/executor.js +10 -2
  24. package/dist/lib/sandbox/control/lifecycle.js +392 -72
  25. package/dist/lib/sandbox/control/state.js +27 -11
  26. package/dist/lib/sandbox/engine.js +14 -7
  27. package/dist/lib/sandbox/mounts.js +20 -5
  28. package/dist/lib/sandbox/recovery.js +78 -10
  29. package/dist/lib/sandbox/workspace-view.js +62 -40
  30. package/dist/lib/server/process-state.js +51 -1
  31. package/dist/lib/task/activity-intent.js +4 -1
  32. package/dist/lib/task/decision-intents.js +4 -2
  33. package/dist/lib/task/delegation-receipts.js +37 -16
  34. package/dist/lib/task/events.js +9 -5
  35. package/dist/lib/task/guard-override.js +31 -0
  36. package/dist/lib/task/human-override.js +1240 -0
  37. package/dist/lib/task/ledger-intents.js +6 -3
  38. package/dist/lib/task/lifecycle.js +69 -14
  39. package/dist/lib/task/orchestration.js +60 -6
  40. package/dist/lib/task/review-finalization.js +3 -2
  41. package/dist/lib/task/verification-engine.js +40 -14
  42. package/dist/lib/task/verification.js +13 -1
  43. package/dist/lib/task/workflow-warning-intents.js +4 -2
  44. package/dist/lib/task/write.js +2 -1
  45. package/dist/lifecycle-build-manifest.json +3 -3
  46. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  47. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +2 -1
  48. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +2 -1
  49. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +29 -2
  50. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +2 -1
  51. package/lib/decide.ts +59 -14
  52. package/lib/init.ts +1 -1
  53. package/lib/internal/release-workflow.ts +1 -3
  54. package/lib/internal/task-activity.ts +46 -6
  55. package/lib/internal/task-event.ts +53 -4
  56. package/lib/internal/task-ledger.ts +48 -4
  57. package/lib/internal/task-lifecycle.ts +56 -12
  58. package/lib/internal/task-orchestration.ts +12 -4
  59. package/lib/internal/task-override.ts +138 -0
  60. package/lib/internal/task-review.ts +51 -6
  61. package/lib/internal/task-verify.ts +52 -3
  62. package/lib/internal/task-warning.ts +44 -3
  63. package/lib/platform/pr-summary.ts +10 -3
  64. package/lib/runtime/agent-runtime.ts +76 -0
  65. package/lib/sandbox/commands/create.ts +88 -4
  66. package/lib/sandbox/config.ts +2 -1
  67. package/lib/sandbox/control/container-identity.ts +3 -3
  68. package/lib/sandbox/control/executor.ts +10 -2
  69. package/lib/sandbox/control/lifecycle.ts +463 -76
  70. package/lib/sandbox/control/protocol.ts +5 -2
  71. package/lib/sandbox/control/state.ts +35 -18
  72. package/lib/sandbox/engine.ts +20 -9
  73. package/lib/sandbox/mounts.ts +23 -5
  74. package/lib/sandbox/recovery.ts +90 -9
  75. package/lib/sandbox/workspace-view.ts +70 -40
  76. package/lib/server/process-state.ts +48 -1
  77. package/lib/task/activity-intent.ts +4 -2
  78. package/lib/task/decision-intents.ts +4 -2
  79. package/lib/task/delegation-receipts.ts +46 -18
  80. package/lib/task/events.ts +10 -5
  81. package/lib/task/guard-override.ts +50 -0
  82. package/lib/task/human-override.ts +1503 -0
  83. package/lib/task/ledger-intents.ts +6 -3
  84. package/lib/task/lifecycle.ts +71 -14
  85. package/lib/task/orchestration.ts +89 -7
  86. package/lib/task/review-finalization.ts +6 -2
  87. package/lib/task/verification-engine.ts +42 -16
  88. package/lib/task/verification.ts +15 -1
  89. package/lib/task/workflow-warning-intents.ts +4 -2
  90. package/lib/task/write.ts +4 -1
  91. package/package.json +1 -1
  92. package/templates/.agents/hooks/lifecycle-delegation.js +8 -3
  93. package/templates/.agents/rules/lifecycle-orchestration.en.md +3 -2
  94. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +3 -2
  95. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -1
  96. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -1
@@ -27,12 +27,15 @@ export type SandboxControlContainerIdentity = Readonly<{
27
27
  id: string;
28
28
  labels: Readonly<Record<string, string>>;
29
29
  }>;
30
- export type SandboxControlManifest = Readonly<{
31
- version: 4; engine: string; repoRoot: string; worktreeRoot: string; project: string; container: string;
30
+ export type SandboxControlManifestBase = Readonly<{
31
+ engine: string; repoRoot: string; worktreeRoot: string; project: string; container: string;
32
32
  containerIdentity: SandboxControlContainerIdentity;
33
33
  branch: string; mode: 'task-bound' | 'branch-only'; taskId: string | null; token: string;
34
34
  generation: string; channelDir: string; publicStatusDir: string; processingDir: string;
35
35
  }>;
36
+ export type SandboxControlLegacyManifest = SandboxControlManifestBase & Readonly<{ version: 4 }>;
37
+ export type SandboxControlManifest = SandboxControlManifestBase & Readonly<{ version: 5; runtimeDir: string }>;
38
+ export type SandboxControlManifestLike = SandboxControlLegacyManifest | SandboxControlManifest;
36
39
  export type SandboxControlBrokerOwner = ProcessIdentity & Readonly<{
37
40
  version: 3;
38
41
  brokerId: string;
@@ -3,12 +3,12 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { buildProcessTreeStopCommand } from '../../server/process-control.ts';
6
- import { processIdentityMatches } from '../../server/process-state.ts';
7
- import type { ProcessIdentity } from '../../server/process-state.ts';
6
+ import { getProcessIdentityState } from '../../server/process-state.ts';
7
+ import type { ProcessIdentity, ProcessIdentityProbe } from '../../server/process-state.ts';
8
8
  import type {
9
9
  SandboxControlExecution,
10
10
  SandboxControlLease,
11
- SandboxControlManifest,
11
+ SandboxControlManifestLike,
12
12
  SandboxControlStatus
13
13
  } from './protocol.ts';
14
14
 
@@ -36,12 +36,12 @@ export function readJsonFile(filePath: string): unknown {
36
36
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
37
37
  }
38
38
 
39
- export function statusPath(manifest: SandboxControlManifest): string {
39
+ export function statusPath(manifest: SandboxControlManifestLike): string {
40
40
  return path.join(manifest.publicStatusDir, 'status.json');
41
41
  }
42
42
 
43
43
  export function writeSandboxControlStatus(
44
- manifest: SandboxControlManifest,
44
+ manifest: SandboxControlManifestLike,
45
45
  broker: ProcessIdentity & { brokerId: string },
46
46
  state: SandboxControlStatus['state'],
47
47
  reasonCode: string | null,
@@ -72,7 +72,7 @@ export function readSandboxControlStatus(directory: string): SandboxControlStatu
72
72
  return parseSandboxControlStatus(readJsonFile(path.join(directory, 'status.json')));
73
73
  }
74
74
 
75
- export function readActiveLease(manifest: SandboxControlManifest, now = Date.now()): SandboxControlLease | null {
75
+ export function readActiveLease(manifest: SandboxControlManifestLike, now = Date.now()): SandboxControlLease | null {
76
76
  const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
77
77
  if (!fs.existsSync(filePath)) return null;
78
78
  const lease = readJsonFile(filePath) as Partial<SandboxControlLease> | null;
@@ -90,7 +90,11 @@ export function readActiveLease(manifest: SandboxControlManifest, now = Date.now
90
90
  return lease as SandboxControlLease;
91
91
  }
92
92
 
93
- export function cleanupStaleSandboxControlLease(manifest: SandboxControlManifest, now = Date.now()): boolean {
93
+ export function cleanupStaleSandboxControlLease(
94
+ manifest: SandboxControlManifestLike,
95
+ now = Date.now(),
96
+ options: Readonly<{ identityProbe?: ProcessIdentityProbe }> = {}
97
+ ): boolean {
94
98
  const filePath = path.join(path.dirname(manifest.publicStatusDir), 'lease.json');
95
99
  if (!fs.existsSync(filePath)) return false;
96
100
  const raw = fs.readFileSync(filePath, 'utf8');
@@ -104,10 +108,12 @@ export function cleanupStaleSandboxControlLease(manifest: SandboxControlManifest
104
108
  || typeof lease.branch !== 'string' || typeof lease.reason !== 'string') {
105
109
  throw new Error('SANDBOX_CONTROL_LEASE_INVALID');
106
110
  }
107
- const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
108
- || lease.branch !== manifest.branch || lease.expiresAt! <= now || !processIdentityMatches({
111
+ const ownerState = (options.identityProbe ?? getProcessIdentityState)({
109
112
  pid: lease.owner.pid!, startTime: lease.owner.startTime!
110
113
  });
114
+ if (ownerState === 'unknown') throw new Error('SANDBOX_CONTROL_LEASE_OWNER_UNAVAILABLE');
115
+ const stale = lease.generation !== manifest.generation || lease.taskId !== manifest.taskId
116
+ || lease.branch !== manifest.branch || lease.expiresAt! <= now || ownerState === 'dead';
111
117
  if (!stale) return false;
112
118
  if (fs.readFileSync(filePath, 'utf8') !== raw) throw new Error('SANDBOX_CONTROL_LEASE_OWNERSHIP_LOST');
113
119
  fs.unlinkSync(filePath);
@@ -115,7 +121,7 @@ export function cleanupStaleSandboxControlLease(manifest: SandboxControlManifest
115
121
  }
116
122
 
117
123
  export function appendSandboxControlAudit(
118
- manifest: SandboxControlManifest,
124
+ manifest: SandboxControlManifestLike,
119
125
  event: string,
120
126
  fields: Record<string, string | number | boolean | null> = {},
121
127
  now = Date.now()
@@ -130,7 +136,7 @@ export function appendSandboxControlAudit(
130
136
  });
131
137
  }
132
138
 
133
- export function executionPath(manifest: SandboxControlManifest, requestId: string): string {
139
+ export function executionPath(manifest: SandboxControlManifestLike, requestId: string): string {
134
140
  return path.join(manifest.processingDir, requestId, 'execution.json');
135
141
  }
136
142
 
@@ -153,31 +159,42 @@ export function terminateSandboxControlExecution(
153
159
  deadlineAt?: number;
154
160
  forceAt?: number;
155
161
  allowForce?: boolean;
162
+ identityProbe?: ProcessIdentityProbe;
156
163
  } = {}
157
164
  ): boolean {
158
165
  const platform = options.platform ?? process.platform;
159
166
  const processGroupId = execution.child.processGroupId;
160
- const processGroupAlive = (groupId: number): boolean => {
167
+ const processGroupState = (groupId: number): 'alive' | 'dead' | 'unknown' => {
161
168
  try {
162
169
  const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
163
170
  return rows.split('\n').some((row) => {
164
171
  const match = row.trim().match(/^(\d+)\s+(\S+)/);
165
172
  return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
166
- });
173
+ }) ? 'alive' : 'dead';
167
174
  } catch {
168
175
  try {
169
176
  process.kill(-groupId, 0);
170
- return true;
177
+ return 'alive';
171
178
  } catch (error) {
172
- return (error as NodeJS.ErrnoException).code === 'EPERM';
179
+ const code = (error as NodeJS.ErrnoException).code;
180
+ if (code === 'ESRCH') return 'dead';
181
+ if (code === 'EPERM') return 'alive';
182
+ return 'unknown';
173
183
  }
174
184
  }
175
185
  };
176
- const treeAlive = (): boolean => {
186
+ const treeState = (): 'alive' | 'dead' | 'unknown' => {
177
187
  if (platform === 'win32' || !processGroupId) {
178
- return processIdentityMatches({ pid: execution.child.pid, startTime: execution.child.startTime });
188
+ return (options.identityProbe ?? getProcessIdentityState)({
189
+ pid: execution.child.pid, startTime: execution.child.startTime
190
+ });
179
191
  }
180
- return processGroupAlive(processGroupId);
192
+ return processGroupState(processGroupId);
193
+ };
194
+ const treeAlive = (): boolean => {
195
+ const state = treeState();
196
+ if (state === 'unknown') throw new Error('SANDBOX_CONTROL_EXECUTION_OWNER_UNAVAILABLE');
197
+ return state === 'alive';
181
198
  };
182
199
  if (!treeAlive()) return true;
183
200
  const command = buildProcessTreeStopCommand(processGroupId ?? execution.child.pid, platform);
@@ -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(
@@ -6,6 +6,7 @@ import {
6
6
  worktreeDirCandidates
7
7
  } from './constants.ts';
8
8
  import type { SandboxConfig } from './config.ts';
9
+ import { sandboxWorkspaceViewStatePaths } from './workspace-view.ts';
9
10
 
10
11
  export type SandboxBindMountDeclaration = {
11
12
  hostPaths: string[];
@@ -25,11 +26,25 @@ export function sandboxCoreBindMounts(
25
26
  workspaceViewRoot: string;
26
27
  controlDir: string;
27
28
  controlStatusDir: string;
29
+ runtimeDir?: string;
28
30
  taskSource?: string;
29
31
  taskId?: string;
30
32
  }
31
33
  ): SandboxBindMountDeclaration[] {
32
34
  const taskBound = Boolean(overrides.taskSource && overrides.taskId);
35
+ const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(
36
+ ({ state, hostPath }) => taskBound && state === 'active'
37
+ ? {
38
+ hostPaths: [path.join(hostPath, '.short-ids.json')],
39
+ containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
40
+ readOnly: true
41
+ }
42
+ : {
43
+ hostPaths: [hostPath],
44
+ containerPath: path.posix.join('/workspace/.agents/workspace', state),
45
+ readOnly: true
46
+ }
47
+ );
33
48
  const mounts: SandboxBindMountDeclaration[] = [
34
49
  {
35
50
  hostPaths: overrides.worktree
@@ -38,11 +53,7 @@ export function sandboxCoreBindMounts(
38
53
  containerPath: '/workspace',
39
54
  readOnly: false
40
55
  },
41
- {
42
- hostPaths: [overrides.workspaceViewRoot],
43
- containerPath: '/workspace/.agents/workspace',
44
- readOnly: true
45
- },
56
+ ...workspaceMounts,
46
57
  ];
47
58
  if (taskBound) {
48
59
  mounts.push({
@@ -79,5 +90,12 @@ export function sandboxCoreBindMounts(
79
90
  containerPath: '/run/agent-infra/control',
80
91
  readOnly: false
81
92
  });
93
+ if (taskBound && overrides.runtimeDir) {
94
+ mounts.push({
95
+ hostPaths: [overrides.runtimeDir],
96
+ containerPath: '/run/agent-infra/runtime',
97
+ readOnly: false
98
+ });
99
+ }
82
100
  return mounts;
83
101
  }
@@ -49,7 +49,7 @@ 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
55
  garbageCollectSandboxControlRoot,
@@ -93,6 +93,9 @@ export type SandboxRecoverySnapshot = {
93
93
  actualBranch: string | null;
94
94
  expectedWorkspace: SandboxWorkspaceIdentity;
95
95
  actualWorkspace: SandboxContainerWorkspaceIdentity;
96
+ workspaceTopology?: 'legacy-parent' | 'per-state' | 'unknown';
97
+ taskView?: { path: string; readable: boolean };
98
+ runtimeStoreOk?: boolean;
96
99
  runtimeCapabilityOk?: boolean;
97
100
  unexpectedCapabilityMounts?: string[];
98
101
  mounts: Array<{
@@ -251,7 +254,9 @@ export async function startSandboxControlBroker(repoRoot: string, manifestPath:
251
254
  if (broker === null || broker.version !== 3 || owner === null) {
252
255
  throw new Error('SANDBOX_CONTROL_BROKER_VERSION_INVALID: rebuild the sandbox container before starting a new broker');
253
256
  }
254
- const live = owner !== null && processIdentityMatches({ pid: owner.pid, startTime: owner.startTime });
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';
255
260
  if (
256
261
  live
257
262
  && owner !== null
@@ -349,6 +354,14 @@ async function ensureSandboxControlBroker(params: {
349
354
  token?: unknown;
350
355
  generation?: unknown;
351
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');
352
365
  if (
353
366
  broker.version === 3
354
367
  && typeof broker.pid === 'number'
@@ -359,7 +372,7 @@ async function ensureSandboxControlBroker(params: {
359
372
  && broker.brokerId.length > 0
360
373
  && broker.token === validatedManifest.token
361
374
  && broker.generation === validatedManifest.generation
362
- && processIdentityMatches({ pid: broker.pid, startTime: broker.startTime })
375
+ && ownerState === 'alive'
363
376
  ) {
364
377
  try {
365
378
  const status = readSandboxControlStatus(control.statusDir);
@@ -379,7 +392,8 @@ async function ensureSandboxControlBroker(params: {
379
392
  });
380
393
  return;
381
394
  }
382
- } catch {
395
+ } catch (error) {
396
+ if (error instanceof Error && error.message === 'SANDBOX_CONTROL_BROKER_OWNER_UNAVAILABLE') throw error;
383
397
  // A missing, stale, or malformed broker record is replaced below.
384
398
  }
385
399
  await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
@@ -416,6 +430,30 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
416
430
  message: 'Container runtime capability signature does not match the current sandbox plan.'
417
431
  });
418
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
+ }
419
457
  for (const mountPath of snapshot.unexpectedCapabilityMounts ?? []) {
420
458
  findings.push({
421
459
  repairKind: 'hard-failure',
@@ -425,6 +463,10 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
425
463
  }
426
464
 
427
465
  for (const mount of snapshot.mounts) {
466
+ if (
467
+ snapshot.workspaceTopology === 'legacy-parent'
468
+ && mount.path.startsWith('/workspace/.agents/workspace')
469
+ ) continue;
428
470
  if (
429
471
  mount.actualType !== mount.expectedType
430
472
  || !mount.sourceMatches
@@ -517,6 +559,20 @@ function inspectContainer(
517
559
  return parsed[0] as DockerInspection;
518
560
  }
519
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
+
520
576
  function probe(
521
577
  engine: string,
522
578
  container: string,
@@ -596,6 +652,7 @@ function expectedMounts(params: {
596
652
  controlStatusDir: control.statusDir,
597
653
  ...(params.workspace.mode === 'task-bound'
598
654
  ? {
655
+ runtimeDir: control.runtimeDir,
599
656
  taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
600
657
  taskId: params.workspace.taskId
601
658
  }
@@ -729,6 +786,10 @@ export function collectSandboxRecoverySnapshot(params: {
729
786
  const capabilityPlan = createSandboxCapabilityPlan(params.config);
730
787
  const workspace = params.workspace ?? { mode: 'branch-only' as const };
731
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;
732
793
  const seeds = declaredTmpfsSeedEntries(tools).map((seed) => {
733
794
  const mounted = mountsByDestination.get(seed.stagingPath)?.Type === 'bind';
734
795
  return {
@@ -759,6 +820,15 @@ export function collectSandboxRecoverySnapshot(params: {
759
820
  runOkFn
760
821
  )
761
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;
762
832
  const labels = inspection.Config?.Labels ?? {};
763
833
  const branchLabel = labels[sandboxBranchLabel(params.config)];
764
834
  const containerWorkspace = parseSandboxWorkspaceIdentity(labels, {
@@ -820,6 +890,20 @@ export function collectSandboxRecoverySnapshot(params: {
820
890
  actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
821
891
  expectedWorkspace: workspace,
822
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 }),
823
907
  identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
824
908
  && branchLabel === params.branch
825
909
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
@@ -996,11 +1080,13 @@ function assess(params: {
996
1080
  function canRetryFreshReadiness(findings: SandboxRecoveryFinding[]): boolean {
997
1081
  return findings.length > 0 && findings.every((finding) =>
998
1082
  finding.repairKind === 'hard-failure'
1083
+ && finding.code !== 'SANDBOX_WORKSPACE_TOPOLOGY_MISMATCH'
999
1084
  && finding.path !== undefined
1000
1085
  && (
1001
1086
  finding.path.startsWith('/workspace/.agents/workspace')
1002
1087
  || finding.path === '/home/devuser/.host-shell-config'
1003
1088
  || finding.path === '/home/devuser/.bash_aliases'
1089
+ || finding.path === '/run/agent-infra/runtime'
1004
1090
  )
1005
1091
  );
1006
1092
  }
@@ -1236,14 +1322,9 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
1236
1322
  : `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
1237
1323
  warnings.push(warning);
1238
1324
  (params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
1239
- const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
1240
1325
  let replacementResult: SandboxReadyResult | null = null;
1241
1326
  let replacementFailure: unknown = null;
1242
1327
  try {
1243
- if (params.row.running) {
1244
- runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
1245
- }
1246
- runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
1247
1328
  await params.recreate(params.branch);
1248
1329
 
1249
1330
  const rows = (deps?.fetchRows ?? fetchSandboxRows)(
@@ -50,6 +50,7 @@ export type SandboxControlSetup = Readonly<{
50
50
  root: string;
51
51
  channelDir: string;
52
52
  statusDir: string;
53
+ runtimeDir: string;
53
54
  manifestPath: string;
54
55
  token: string;
55
56
  generation: string;
@@ -60,7 +61,7 @@ export function sandboxControlPaths(params: Readonly<{
60
61
  project: string;
61
62
  container: string;
62
63
  identity: SandboxWorkspaceIdentity;
63
- }>): 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 }> {
64
65
  const identityKey = params.identity.mode === 'task-bound'
65
66
  ? `task-bound:${params.identity.taskId}`
66
67
  : 'branch-only';
@@ -71,6 +72,7 @@ export function sandboxControlPaths(params: Readonly<{
71
72
  channelDir: path.join(root, 'channel'),
72
73
  statusDir: path.join(root, 'public'),
73
74
  processingDir: path.join(root, 'processing'),
75
+ runtimeDir: path.join(root, 'runtime'),
74
76
  manifestPath: path.join(root, 'manifest.json')
75
77
  };
76
78
  }
@@ -85,6 +87,32 @@ function assertSafeDirectory(directory: string, expectedBase: string): void {
85
87
  }
86
88
  }
87
89
 
90
+ function assertSafeMountTargetPath(
91
+ target: string,
92
+ worktreeRoot: string,
93
+ canonicalWorktreeRoot: string
94
+ ): void {
95
+ const relative = path.relative(worktreeRoot, target);
96
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
97
+ throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
98
+ }
99
+ let current = worktreeRoot;
100
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
101
+ current = segment ? path.join(current, segment) : current;
102
+ try {
103
+ if (fs.lstatSync(current).isSymbolicLink()) {
104
+ throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
105
+ }
106
+ const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
107
+ if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
108
+ throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
109
+ }
110
+ } catch (error) {
111
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
112
+ }
113
+ }
114
+ }
115
+
88
116
  export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
89
117
  if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
90
118
  const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
@@ -100,41 +128,22 @@ export function assertSandboxTaskSource(repoRoot: string, taskId: string): strin
100
128
  return canonical;
101
129
  }
102
130
 
103
- export function prepareSandboxWorkspaceMountTarget(worktreeRoot: string): string {
104
- const worktree = path.resolve(worktreeRoot);
105
- const canonicalWorktree = fs.realpathSync.native(worktree);
106
- const agentsRoot = path.join(worktree, '.agents');
107
- if (fs.existsSync(agentsRoot)) {
108
- const agentsStat = fs.lstatSync(agentsRoot);
109
- if (!agentsStat.isDirectory() || agentsStat.isSymbolicLink()) {
110
- throw new Error(`Sandbox workspace mount ancestor must be a real directory: ${agentsRoot}`);
111
- }
112
- } else {
113
- fs.mkdirSync(agentsRoot, { mode: 0o700 });
114
- }
115
- const canonicalAgents = fs.realpathSync.native(agentsRoot);
116
- if (path.dirname(canonicalAgents) !== canonicalWorktree) {
117
- throw new Error(`Sandbox workspace mount ancestor escapes the worktree: ${agentsRoot}`);
131
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
132
+ const resolvedWorktreeRoot = path.resolve(worktreeRoot);
133
+ const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
134
+ const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
135
+ const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
136
+ const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
137
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
138
+ assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
118
139
  }
119
-
120
- const target = path.join(agentsRoot, 'workspace');
121
- if (fs.existsSync(target)) {
122
- const stat = fs.lstatSync(target);
123
- if (stat.isSymbolicLink()) {
124
- throw new Error(`Sandbox workspace mount target must not be a symbolic link: ${target}`);
125
- }
126
- if (!stat.isDirectory()) {
127
- throw new Error(`Sandbox workspace mount target must be a directory: ${target}`);
128
- }
129
- if (path.dirname(fs.realpathSync.native(target)) !== canonicalAgents) {
130
- throw new Error(`Sandbox workspace mount target escapes the worktree: ${target}`);
131
- }
132
- } else {
133
- fs.mkdirSync(target, { mode: 0o700 });
140
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
141
+ for (const { hostPath } of statePaths) {
142
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
143
+ fs.chmodSync(hostPath, 0o700);
134
144
  }
135
- fs.chmodSync(target, 0o700);
136
- fs.accessSync(target, fs.constants.W_OK);
137
- return target;
145
+ fs.chmodSync(workspaceRoot, 0o700);
146
+ fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
138
147
  }
139
148
 
140
149
  export function materializeSandboxWorkspaceView(params: Readonly<{
@@ -181,16 +190,36 @@ export function materializeSandboxControl(params: Readonly<{
181
190
  branch: string;
182
191
  identity: SandboxWorkspaceIdentity;
183
192
  engine?: string;
193
+ replacementLease?: Readonly<{
194
+ root: string;
195
+ assertOwned(): void;
196
+ }>;
184
197
  }>): SandboxControlSetup {
185
- const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
198
+ const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
186
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
+ }
187
206
  const consumedDir = path.join(root, 'consumed');
188
207
  assertSafeDirectory(consumedDir, root);
189
208
  fs.rmSync(consumedDir, { recursive: true, force: true });
190
209
  fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
191
- for (const directory of [statusDir, processingDir]) {
210
+ for (const directory of [statusDir, processingDir, runtimeDir]) {
192
211
  assertSafeDirectory(directory, root);
212
+ if (directory === runtimeDir) fs.rmSync(directory, { recursive: true, force: true });
213
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
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);
193
221
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
222
+ fs.chmodSync(directory, 0o700);
194
223
  }
195
224
  for (const queue of ['requests', 'responses']) {
196
225
  const directory = path.join(channelDir, queue);
@@ -202,7 +231,7 @@ export function materializeSandboxControl(params: Readonly<{
202
231
  const generation = randomBytes(16).toString('hex');
203
232
  const repoRoot = fs.realpathSync.native(params.repoRoot);
204
233
  const manifest: SandboxControlManifest = {
205
- version: 4,
234
+ version: 5,
206
235
  engine: params.engine ?? 'docker',
207
236
  repoRoot,
208
237
  worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
@@ -216,10 +245,11 @@ export function materializeSandboxControl(params: Readonly<{
216
245
  generation,
217
246
  channelDir,
218
247
  publicStatusDir: statusDir,
219
- processingDir
248
+ processingDir,
249
+ runtimeDir
220
250
  };
221
251
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
222
- return { root, channelDir, statusDir, manifestPath, token, generation };
252
+ return { root, channelDir, statusDir, runtimeDir, manifestPath, token, generation };
223
253
  }
224
254
 
225
255
  export function finalizeSandboxControlManifest(
@@ -228,7 +258,7 @@ export function finalizeSandboxControlManifest(
228
258
  ): void {
229
259
  if (!identity.engine || !identity.id) throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
230
260
  const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8')) as Record<string, unknown>;
231
- manifest.version = 4;
261
+ manifest.version = 5;
232
262
  manifest.engine = identity.engine;
233
263
  manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
234
264
  const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;