@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
@@ -1,12 +1,18 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { buildProcessTreeStopCommand } from '../../server/process-control.ts';
5
- import { processIdentityMatches } from '../../server/process-state.ts';
6
- import type { ProcessIdentity } from '../../server/process-state.ts';
6
+ import {
7
+ getProcessIdentityState,
8
+ getProcessStartTime
9
+ } from '../../server/process-state.ts';
10
+ import type { ProcessIdentity, ProcessIdentityProbe, ProcessIdentityState } from '../../server/process-state.ts';
7
11
  import type {
8
12
  SandboxControlExecution,
13
+ SandboxControlManifestLike,
9
14
  SandboxControlManifest,
15
+ SandboxControlLegacyManifest,
10
16
  SandboxControlStatus,
11
17
  SandboxControlTimingPolicy
12
18
  } from './protocol.ts';
@@ -24,6 +30,8 @@ export type BrokerOwner = ProcessIdentity & Readonly<{ version: 3; brokerId: str
24
30
  const DEFAULT_QUIESCE_TIMEOUT_MS = DEFAULT_SANDBOX_CONTROL_TIMING.quiesceDeadlineMs;
25
31
  const QUIESCING_FILE = 'quiescing.json';
26
32
  const BROKER_STARTING_FILE = 'broker-starting.json';
33
+ const REPLACEMENT_FILE = 'replacement.json';
34
+ const REPLACEMENT_STATE_SUFFIX = '.replacement-state.json';
27
35
 
28
36
  async function awaitWithDeadline<T>(
29
37
  operation: () => Promise<T>,
@@ -90,13 +98,17 @@ function readStartupOwner(filePath: string): { raw: string; owner: OwnerIdentity
90
98
  }
91
99
  }
92
100
 
93
- async function waitForStartupTransition(root: string, timeoutMs: number): Promise<void> {
101
+ async function waitForStartupTransition(
102
+ root: string,
103
+ timeoutMs: number,
104
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
105
+ ): Promise<void> {
94
106
  const transitionPath = path.join(root, BROKER_STARTING_FILE);
95
107
  const deadline = Date.now() + timeoutMs;
96
108
  while (Date.now() < deadline) {
97
109
  const transition = readStartupOwner(transitionPath);
98
110
  if (!fs.existsSync(transitionPath)) return;
99
- if (transition && !ownerLive(transition.owner)) {
111
+ if (transition && !ownerLive(transition.owner, identityProbe)) {
100
112
  try {
101
113
  if (fs.readFileSync(transitionPath, 'utf8') === transition.raw) fs.unlinkSync(transitionPath);
102
114
  } catch {
@@ -112,7 +124,8 @@ async function waitForStartupTransition(root: string, timeoutMs: number): Promis
112
124
  export async function acquireSandboxControlBrokerStartup(
113
125
  root: string,
114
126
  owner: OwnerIdentity,
115
- timeoutMs = 5_000
127
+ timeoutMs = 5_000,
128
+ options: Readonly<{ identityProbe?: ProcessIdentityProbe }> = {}
116
129
  ): Promise<() => void> {
117
130
  const resolvedRoot = path.resolve(root);
118
131
  const transitionPath = path.join(resolvedRoot, BROKER_STARTING_FILE);
@@ -140,7 +153,7 @@ export async function acquireSandboxControlBrokerStartup(
140
153
  } catch (error) {
141
154
  if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
142
155
  const transition = readStartupOwner(transitionPath);
143
- if (transition && !ownerLive(transition.owner)) {
156
+ if (transition && !ownerLive(transition.owner, options.identityProbe)) {
144
157
  try {
145
158
  if (fs.readFileSync(transitionPath, 'utf8') === transition.raw) fs.unlinkSync(transitionPath);
146
159
  } catch {
@@ -154,37 +167,381 @@ export async function acquireSandboxControlBrokerStartup(
154
167
  throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
155
168
  }
156
169
 
157
- export function readSandboxControlManifest(manifestPath: string): SandboxControlManifest {
170
+ type ReplacementRecord = Readonly<{
171
+ version: 1;
172
+ transitionId: string;
173
+ pid: number;
174
+ startTime: number;
175
+ }>;
176
+
177
+ type ReplacementStateRecord = Readonly<{
178
+ version: 1;
179
+ phase: 'prepared' | 'committed';
180
+ root: string;
181
+ snapshotRoot: string;
182
+ transitionId: string;
183
+ }>;
184
+
185
+ export type SandboxControlReplacementCutover = Readonly<{
186
+ root: string;
187
+ snapshotRoot: string;
188
+ transitionId: string;
189
+ }>;
190
+
191
+ export type SandboxControlCutoverSnapshot = Readonly<{
192
+ root: string;
193
+ manifestRaw: string | null;
194
+ brokerRaw: string | null;
195
+ statusRaw: string | null;
196
+ processing: ReadonlyArray<Readonly<{ name: string; directory: boolean; executionRaw: string | null }>>;
197
+ }>;
198
+
199
+ export type SandboxControlReplacementLease = Readonly<{
200
+ root: string;
201
+ transitionId: string;
202
+ owner: ProcessIdentity;
203
+ assertOwned(): void;
204
+ clearQuiescing(): void;
205
+ release(): void;
206
+ }>;
207
+
208
+ function replacementPath(root: string): string {
209
+ return path.join(root, REPLACEMENT_FILE);
210
+ }
211
+
212
+ function replacementStatePath(root: string): string {
213
+ const resolvedRoot = path.resolve(root);
214
+ return path.join(path.dirname(resolvedRoot), `.${path.basename(resolvedRoot)}${REPLACEMENT_STATE_SUFFIX}`);
215
+ }
216
+
217
+ function replacementSnapshotPath(root: string, transitionId: string): string {
218
+ const resolvedRoot = path.resolve(root);
219
+ return path.join(path.dirname(resolvedRoot), `.${path.basename(resolvedRoot)}.replacement-${transitionId}.snapshot`);
220
+ }
221
+
222
+ function writeAtomicText(filePath: string, content: string): void {
223
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
224
+ try {
225
+ fs.writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
226
+ fs.renameSync(temporary, filePath);
227
+ } finally {
228
+ fs.rmSync(temporary, { force: true });
229
+ }
230
+ }
231
+
232
+ function readReplacementState(root: string): ReplacementStateRecord | null {
233
+ const filePath = replacementStatePath(root);
234
+ if (!fs.existsSync(filePath)) return null;
235
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_INVALID');
236
+ try {
237
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial<ReplacementStateRecord>;
238
+ const resolvedRoot = path.resolve(root);
239
+ if (value.version !== 1 || (value.phase !== 'prepared' && value.phase !== 'committed')
240
+ || value.root !== resolvedRoot || typeof value.transitionId !== 'string' || value.transitionId.length === 0
241
+ || value.snapshotRoot !== replacementSnapshotPath(resolvedRoot, value.transitionId)) {
242
+ throw new Error('invalid replacement state');
243
+ }
244
+ return value as ReplacementStateRecord;
245
+ } catch {
246
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_INVALID');
247
+ }
248
+ }
249
+
250
+ function writeReplacementState(state: ReplacementStateRecord): void {
251
+ writeAtomicText(replacementStatePath(state.root), `${JSON.stringify(state)}\n`);
252
+ }
253
+
254
+ function removeReplacementState(root: string): void {
255
+ fs.rmSync(replacementStatePath(root), { force: true });
256
+ }
257
+
258
+ function readReplacementRecord(filePath: string): { raw: string; record: ReplacementRecord } | null {
259
+ if (!fs.existsSync(filePath)) return null;
260
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_INVALID');
261
+ const raw = fs.readFileSync(filePath, 'utf8');
262
+ try {
263
+ const value = JSON.parse(raw) as Partial<ReplacementRecord>;
264
+ if (value.version !== 1 || typeof value.transitionId !== 'string' || value.transitionId.length === 0
265
+ || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0
266
+ || !Number.isSafeInteger(value.startTime)) throw new Error('invalid replacement');
267
+ return { raw, record: value as ReplacementRecord };
268
+ } catch {
269
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_INVALID');
270
+ }
271
+ }
272
+
273
+ export function acquireSandboxControlReplacement(
274
+ root: string,
275
+ options: Readonly<{
276
+ owner?: ProcessIdentity;
277
+ probeOwner?: (identity: ProcessIdentity) => ProcessIdentityState;
278
+ }> = {}
279
+ ): SandboxControlReplacementLease {
280
+ const resolvedRoot = path.resolve(root);
281
+ if (fs.existsSync(resolvedRoot)) {
282
+ const stat = fs.lstatSync(resolvedRoot);
283
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
284
+ } else {
285
+ fs.mkdirSync(resolvedRoot, { recursive: true, mode: 0o700 });
286
+ }
287
+ const startTime = options.owner?.startTime ?? getProcessStartTime(process.pid);
288
+ if (startTime === null || startTime === undefined) throw new Error('SANDBOX_CONTROL_REPLACEMENT_OWNER_UNAVAILABLE');
289
+ const owner = options.owner ?? { pid: process.pid, startTime };
290
+ const transitionId = randomUUID();
291
+ const record = `${JSON.stringify({ version: 1, transitionId, ...owner })}\n`;
292
+ const filePath = replacementPath(resolvedRoot);
293
+ const existing = readReplacementRecord(filePath);
294
+ if (existing) {
295
+ const ownerState = (options.probeOwner ?? getProcessIdentityState)(existing.record);
296
+ if (ownerState === 'alive') {
297
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_BUSY');
298
+ }
299
+ if (ownerState === 'unknown') {
300
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_OWNER_UNAVAILABLE');
301
+ }
302
+ if (fs.readFileSync(filePath, 'utf8') !== existing.raw) {
303
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
304
+ }
305
+ fs.unlinkSync(filePath);
306
+ }
307
+ try {
308
+ fs.writeFileSync(filePath, record, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
309
+ } catch (error) {
310
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
311
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_BUSY');
312
+ }
313
+ throw error;
314
+ }
315
+ let released = false;
316
+ const verifyOwnership = (): void => {
317
+ if (released) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RELEASED');
318
+ const current = readReplacementRecord(filePath);
319
+ if (!current || current.raw !== record || current.record.transitionId !== transitionId) {
320
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
321
+ }
322
+ };
323
+ return {
324
+ root: resolvedRoot,
325
+ transitionId,
326
+ owner,
327
+ assertOwned: verifyOwnership,
328
+ clearQuiescing(): void {
329
+ verifyOwnership();
330
+ const markerPath = path.join(resolvedRoot, QUIESCING_FILE);
331
+ if (fs.existsSync(markerPath)) {
332
+ if (!regularFile(markerPath)) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
333
+ fs.unlinkSync(markerPath);
334
+ }
335
+ },
336
+ release(): void {
337
+ if (released) return;
338
+ verifyOwnership();
339
+ fs.unlinkSync(filePath);
340
+ released = true;
341
+ }
342
+ };
343
+ }
344
+
345
+ function readReplacementRaw(root: string): string {
346
+ const current = readReplacementRecord(replacementPath(root));
347
+ if (!current) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
348
+ return current.raw;
349
+ }
350
+
351
+ export function beginSandboxControlReplacement(
352
+ root: string,
353
+ lease: Readonly<{ root: string; transitionId: string; assertOwned(): void }>
354
+ ): SandboxControlReplacementCutover {
355
+ const resolvedRoot = path.resolve(root);
356
+ if (path.resolve(lease.root) !== resolvedRoot) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
357
+ lease.assertOwned();
358
+ if (!fs.existsSync(resolvedRoot)) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
359
+ const rootStat = fs.lstatSync(resolvedRoot);
360
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
361
+ if (readReplacementState(resolvedRoot)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_REQUIRED');
362
+ const snapshotRoot = replacementSnapshotPath(resolvedRoot, lease.transitionId);
363
+ if (fs.existsSync(snapshotRoot)) throw new Error('SANDBOX_CONTROL_REPLACEMENT_RECOVERY_INVALID');
364
+ const replacementRaw = readReplacementRaw(resolvedRoot);
365
+ const state: ReplacementStateRecord = {
366
+ version: 1,
367
+ phase: 'prepared',
368
+ root: resolvedRoot,
369
+ snapshotRoot,
370
+ transitionId: lease.transitionId
371
+ };
372
+ writeReplacementState(state);
373
+ let moved = false;
374
+ try {
375
+ fs.renameSync(resolvedRoot, snapshotRoot);
376
+ moved = true;
377
+ fs.mkdirSync(resolvedRoot, { recursive: true, mode: 0o700 });
378
+ fs.writeFileSync(replacementPath(resolvedRoot), replacementRaw, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
379
+ markSandboxControlRootQuiescing(resolvedRoot);
380
+ return { root: resolvedRoot, snapshotRoot, transitionId: lease.transitionId };
381
+ } catch (error) {
382
+ if (moved) {
383
+ fs.rmSync(resolvedRoot, { recursive: true, force: true });
384
+ fs.renameSync(snapshotRoot, resolvedRoot);
385
+ }
386
+ removeReplacementState(resolvedRoot);
387
+ throw error;
388
+ }
389
+ }
390
+
391
+ function isRecoverableCutoverManifestError(error: unknown): boolean {
392
+ return error instanceof Error
393
+ && (error.message.startsWith('SANDBOX_CONTROL_MANIFEST_')
394
+ || error.message === 'SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
395
+ }
396
+
397
+ export async function recoverSandboxControlReplacement(
398
+ root: string,
399
+ lease: Readonly<{ root: string; assertOwned(): void }>
400
+ ): Promise<'none' | 'restored' | 'committed'> {
401
+ const resolvedRoot = path.resolve(root);
402
+ if (path.resolve(lease.root) !== resolvedRoot) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
403
+ const state = readReplacementState(resolvedRoot);
404
+ if (!state) return 'none';
405
+ lease.assertOwned();
406
+ if (state.phase === 'committed') {
407
+ fs.rmSync(state.snapshotRoot, { recursive: true, force: true });
408
+ removeReplacementState(resolvedRoot);
409
+ return 'committed';
410
+ }
411
+ if (!fs.existsSync(state.snapshotRoot)) {
412
+ removeReplacementState(resolvedRoot);
413
+ return 'none';
414
+ }
415
+ if (fs.existsSync(resolvedRoot) && fs.existsSync(path.join(resolvedRoot, 'manifest.json'))) {
416
+ try {
417
+ await quiesceSandboxControlRoot(resolvedRoot);
418
+ } catch (error) {
419
+ if (!isRecoverableCutoverManifestError(error)) throw error;
420
+ }
421
+ }
422
+ lease.assertOwned();
423
+ const replacementRaw = readReplacementRaw(resolvedRoot);
424
+ fs.rmSync(resolvedRoot, { recursive: true, force: true });
425
+ fs.renameSync(state.snapshotRoot, resolvedRoot);
426
+ writeAtomicText(replacementPath(resolvedRoot), replacementRaw);
427
+ removeReplacementState(resolvedRoot);
428
+ return 'restored';
429
+ }
430
+
431
+ export function commitSandboxControlReplacement(
432
+ root: string,
433
+ lease: Readonly<{ root: string; assertOwned(): void }>
434
+ ): void {
435
+ const resolvedRoot = path.resolve(root);
436
+ if (path.resolve(lease.root) !== resolvedRoot) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
437
+ const state = readReplacementState(resolvedRoot);
438
+ if (!state) return;
439
+ lease.assertOwned();
440
+ writeReplacementState({ ...state, phase: 'committed' });
441
+ fs.rmSync(state.snapshotRoot, { recursive: true, force: true });
442
+ removeReplacementState(resolvedRoot);
443
+ }
444
+
445
+ function readOptionalRaw(filePath: string): string | null {
446
+ if (!fs.existsSync(filePath)) return null;
447
+ if (!regularFile(filePath)) throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
448
+ return fs.readFileSync(filePath, 'utf8');
449
+ }
450
+
451
+ function readProcessingSnapshot(root: string): SandboxControlCutoverSnapshot['processing'] {
452
+ const processingDir = path.join(root, 'processing');
453
+ if (!fs.existsSync(processingDir)) return [];
454
+ const stat = fs.lstatSync(processingDir);
455
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
456
+ return fs.readdirSync(processingDir, { withFileTypes: true })
457
+ .map((entry) => ({
458
+ name: entry.name,
459
+ directory: entry.isDirectory() && !entry.isSymbolicLink(),
460
+ executionRaw: entry.isDirectory() && !entry.isSymbolicLink()
461
+ ? readOptionalRaw(path.join(processingDir, entry.name, 'execution.json'))
462
+ : null
463
+ }))
464
+ .sort((left, right) => left.name.localeCompare(right.name));
465
+ }
466
+
467
+ export function captureSandboxControlCutoverSnapshot(root: string): SandboxControlCutoverSnapshot {
468
+ const resolvedRoot = path.resolve(root);
469
+ const manifestPath = path.join(resolvedRoot, 'manifest.json');
470
+ if (fs.existsSync(manifestPath)) readSandboxControlManifestForTransition(manifestPath);
471
+ return {
472
+ root: resolvedRoot,
473
+ manifestRaw: readOptionalRaw(manifestPath),
474
+ brokerRaw: readOptionalRaw(path.join(resolvedRoot, 'broker.json')),
475
+ statusRaw: readOptionalRaw(path.join(resolvedRoot, 'public', 'status.json')),
476
+ processing: readProcessingSnapshot(resolvedRoot)
477
+ };
478
+ }
479
+
480
+ export function assertSandboxControlCutoverSnapshot(snapshot: SandboxControlCutoverSnapshot): void {
481
+ const current = captureSandboxControlCutoverSnapshot(snapshot.root);
482
+ if (JSON.stringify(current) !== JSON.stringify(snapshot)) {
483
+ throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
484
+ }
485
+ }
486
+
487
+ function readSandboxControlManifestValue(
488
+ manifestPath: string,
489
+ allowLegacy: boolean
490
+ ): SandboxControlManifestLike {
158
491
  if (!regularFile(manifestPath)) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
159
- let manifest: Partial<SandboxControlManifest>;
492
+ let manifest: Record<string, unknown>;
160
493
  try {
161
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Partial<SandboxControlManifest>;
494
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
495
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
496
+ throw new Error('invalid manifest');
497
+ }
498
+ manifest = parsed as Record<string, unknown>;
162
499
  } catch {
163
500
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
164
501
  }
165
- if (manifest.version !== 4) {
166
- throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 4; container-only recreation is required');
167
- }
168
- if (typeof manifest.engine !== 'string' || manifest.engine.length === 0
169
- || typeof manifest.repoRoot !== 'string' || typeof manifest.worktreeRoot !== 'string'
170
- || typeof manifest.project !== 'string' || typeof manifest.container !== 'string'
171
- || !manifest.containerIdentity || typeof manifest.containerIdentity.id !== 'string'
172
- || manifest.containerIdentity.id.length === 0 || !manifest.containerIdentity.labels
173
- || typeof manifest.containerIdentity.labels !== 'object'
174
- || Array.isArray(manifest.containerIdentity.labels)
175
- || Object.values(manifest.containerIdentity.labels).some((value) => typeof value !== 'string')
176
- || typeof manifest.branch !== 'string' || !['task-bound', 'branch-only'].includes(manifest.mode ?? '')
177
- || (manifest.taskId !== null && typeof manifest.taskId !== 'string')
178
- || typeof manifest.channelDir !== 'string' || typeof manifest.publicStatusDir !== 'string'
179
- || typeof manifest.processingDir !== 'string' || typeof manifest.token !== 'string'
180
- || typeof manifest.generation !== 'string') throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
502
+ const candidate = manifest as Partial<SandboxControlManifest> & Partial<SandboxControlLegacyManifest>;
503
+ if (candidate.version !== 4 && candidate.version !== 5) {
504
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 5; container-only recreation is required');
505
+ }
506
+ if (typeof candidate.containerIdentity !== 'object' || candidate.containerIdentity === null
507
+ || Array.isArray(candidate.containerIdentity)) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
508
+ const containerIdentity = candidate.containerIdentity as Record<string, unknown>;
509
+ if (typeof candidate.engine !== 'string' || candidate.engine.length === 0
510
+ || typeof candidate.repoRoot !== 'string' || typeof candidate.worktreeRoot !== 'string'
511
+ || typeof candidate.project !== 'string' || typeof candidate.container !== 'string'
512
+ || typeof containerIdentity.id !== 'string' || containerIdentity.id.length === 0
513
+ || typeof containerIdentity.labels !== 'object' || containerIdentity.labels === null
514
+ || Array.isArray(containerIdentity.labels)
515
+ || Object.values(containerIdentity.labels).some((value) => typeof value !== 'string')
516
+ || typeof candidate.branch !== 'string' || !['task-bound', 'branch-only'].includes(candidate.mode ?? '')
517
+ || (candidate.taskId !== null && typeof candidate.taskId !== 'string')
518
+ || typeof candidate.channelDir !== 'string' || typeof candidate.publicStatusDir !== 'string'
519
+ || typeof candidate.processingDir !== 'string' || typeof candidate.token !== 'string'
520
+ || typeof candidate.generation !== 'string') throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
181
521
  const root = path.dirname(path.resolve(manifestPath));
182
- if (path.resolve(manifest.channelDir) !== path.join(root, 'channel')
183
- || path.resolve(manifest.publicStatusDir) !== path.join(root, 'public')
184
- || path.resolve(manifest.processingDir) !== path.join(root, 'processing')) {
522
+ if (path.resolve(candidate.channelDir) !== path.join(root, 'channel')
523
+ || path.resolve(candidate.publicStatusDir) !== path.join(root, 'public')
524
+ || path.resolve(candidate.processingDir) !== path.join(root, 'processing')) {
185
525
  throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
186
526
  }
187
- return manifest as SandboxControlManifest;
527
+ if (candidate.version === 4) {
528
+ if (!allowLegacy) {
529
+ throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 5; container-only recreation is required');
530
+ }
531
+ return candidate as unknown as SandboxControlLegacyManifest;
532
+ }
533
+ if (typeof candidate.runtimeDir !== 'string' || path.resolve(candidate.runtimeDir) !== path.join(root, 'runtime')) {
534
+ throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
535
+ }
536
+ return candidate as unknown as SandboxControlManifest;
537
+ }
538
+
539
+ export function readSandboxControlManifest(manifestPath: string): SandboxControlManifest {
540
+ return readSandboxControlManifestValue(manifestPath, false) as SandboxControlManifest;
541
+ }
542
+
543
+ export function readSandboxControlManifestForTransition(manifestPath: string): SandboxControlManifestLike {
544
+ return readSandboxControlManifestValue(manifestPath, true);
188
545
  }
189
546
 
190
547
  function readBrokerOwner(filePath: string): BrokerOwner | null {
@@ -218,11 +575,16 @@ function sameOwner(left: OwnerIdentity, right: OwnerIdentity): boolean {
218
575
  && (!left.brokerId || !right.brokerId || left.brokerId === right.brokerId);
219
576
  }
220
577
 
221
- function ownerLive(owner: OwnerIdentity): boolean {
222
- return processIdentityMatches({ pid: owner.pid, startTime: owner.startTime });
578
+ function ownerLive(
579
+ owner: OwnerIdentity,
580
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
581
+ ): boolean {
582
+ const state = identityProbe({ pid: owner.pid, startTime: owner.startTime });
583
+ if (state === 'unknown') throw new Error('SANDBOX_CONTROL_OWNER_UNAVAILABLE');
584
+ return state === 'alive';
223
585
  }
224
586
 
225
- function readExecutions(manifest: SandboxControlManifest): SandboxControlExecution[] {
587
+ function readExecutions(manifest: SandboxControlManifestLike): SandboxControlExecution[] {
226
588
  if (!fs.existsSync(manifest.processingDir)) return [];
227
589
  const stat = fs.lstatSync(manifest.processingDir);
228
590
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
@@ -249,37 +611,50 @@ function readExecutions(manifest: SandboxControlManifest): SandboxControlExecuti
249
611
  return executions;
250
612
  }
251
613
 
252
- function processGroupAlive(groupId: number): boolean {
614
+ function processGroupState(groupId: number): ProcessIdentityState {
253
615
  try {
254
616
  const rows = execFileSync('ps', ['-axo', 'pgid=,stat='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
255
617
  return rows.split('\n').some((row) => {
256
618
  const match = row.trim().match(/^(\d+)\s+(\S+)/);
257
619
  return Number(match?.[1]) === groupId && !match?.[2]?.startsWith('Z');
258
- });
620
+ }) ? 'alive' : 'dead';
259
621
  } catch {
260
622
  try {
261
623
  process.kill(-groupId, 0);
262
- return true;
624
+ return 'alive';
263
625
  } catch (error) {
264
- return (error as NodeJS.ErrnoException).code === 'EPERM';
626
+ const code = (error as NodeJS.ErrnoException).code;
627
+ if (code === 'ESRCH') return 'dead';
628
+ if (code === 'EPERM') return 'alive';
629
+ return 'unknown';
265
630
  }
266
631
  }
267
632
  }
268
633
 
269
- function executionAlive(execution: SandboxControlExecution, platform: NodeJS.Platform): boolean {
270
- if (platform !== 'win32' && execution.child.processGroupId) {
271
- return processGroupAlive(execution.child.processGroupId);
272
- }
273
- return processIdentityMatches({ pid: execution.child.pid, startTime: execution.child.startTime });
634
+ function executionAlive(
635
+ execution: SandboxControlExecution,
636
+ platform: NodeJS.Platform,
637
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
638
+ ): boolean {
639
+ const state = platform !== 'win32' && execution.child.processGroupId
640
+ ? processGroupState(execution.child.processGroupId)
641
+ : identityProbe({ pid: execution.child.pid, startTime: execution.child.startTime });
642
+ if (state === 'unknown') throw new Error('SANDBOX_CONTROL_EXECUTION_OWNER_UNAVAILABLE');
643
+ return state === 'alive';
274
644
  }
275
645
 
276
- async function waitForExit(owner: OwnerIdentity, timeoutMs: number, deadlineAt?: number): Promise<boolean> {
646
+ async function waitForExit(
647
+ owner: OwnerIdentity,
648
+ timeoutMs: number,
649
+ deadlineAt?: number,
650
+ identityProbe: ProcessIdentityProbe = getProcessIdentityState
651
+ ): Promise<boolean> {
277
652
  const deadline = deadlineAt ?? (Date.now() + timeoutMs);
278
653
  while (Date.now() < deadline) {
279
- if (!ownerLive(owner)) return true;
654
+ if (!ownerLive(owner, identityProbe)) return true;
280
655
  await new Promise((resolve) => setTimeout(resolve, 25));
281
656
  }
282
- return !ownerLive(owner);
657
+ return !ownerLive(owner, identityProbe);
283
658
  }
284
659
 
285
660
  function signalOwner(owner: OwnerIdentity, platform: NodeJS.Platform, force: boolean): void {
@@ -303,7 +678,12 @@ function mergeExecutions(...groups: readonly SandboxControlExecution[][]): Sandb
303
678
 
304
679
  export async function quiesceSandboxControlRoot(
305
680
  root: string,
306
- options: { platform?: NodeJS.Platform; timeoutMs?: number; timing?: SandboxControlTimingPolicy } = {}
681
+ options: {
682
+ platform?: NodeJS.Platform;
683
+ timeoutMs?: number;
684
+ timing?: SandboxControlTimingPolicy;
685
+ identityProbe?: ProcessIdentityProbe;
686
+ } = {}
307
687
  ): Promise<'missing' | 'stale' | 'stopped'> {
308
688
  const resolvedRoot = path.resolve(root);
309
689
  if (!fs.existsSync(resolvedRoot)) return 'missing';
@@ -317,18 +697,19 @@ export async function quiesceSandboxControlRoot(
317
697
  }
318
698
 
319
699
  const timeoutMs = options.timeoutMs ?? options.timing?.quiesceDeadlineMs ?? DEFAULT_QUIESCE_TIMEOUT_MS;
700
+ const identityProbe = options.identityProbe ?? getProcessIdentityState;
320
701
  const deadlineAt = Date.now() + timeoutMs;
321
702
  const forceAt = forceDeadline(deadlineAt, timeoutMs);
322
703
  markSandboxControlRootQuiescing(resolvedRoot);
323
- await waitForStartupTransition(resolvedRoot, Math.max(0, forceAt - Date.now()));
704
+ await waitForStartupTransition(resolvedRoot, Math.max(0, forceAt - Date.now()), identityProbe);
324
705
 
325
706
  const manifestPath = path.join(resolvedRoot, 'manifest.json');
326
- const manifest = fs.existsSync(manifestPath) ? readSandboxControlManifest(manifestPath) : null;
707
+ const manifest = fs.existsSync(manifestPath) ? readSandboxControlManifestForTransition(manifestPath) : null;
327
708
  const broker = readBrokerOwner(path.join(resolvedRoot, 'broker.json'));
328
709
  const status = readStatusOwner(path.join(resolvedRoot, 'public', 'status.json'));
329
710
  const statusOwner = status ? status.broker : null;
330
- const brokerLive = broker ? ownerLive(broker) : false;
331
- const statusLive = statusOwner ? ownerLive(statusOwner) : false;
711
+ const brokerLive = broker ? ownerLive(broker, identityProbe) : false;
712
+ const statusLive = statusOwner ? ownerLive(statusOwner, identityProbe) : false;
332
713
 
333
714
  if (manifest && broker && (broker.token !== manifest.token || broker.generation !== manifest.generation)) {
334
715
  throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
@@ -347,23 +728,23 @@ export async function quiesceSandboxControlRoot(
347
728
  if (!owner) {
348
729
  if (manifest && !broker && !status) throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
349
730
  for (const execution of executions) {
350
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false });
731
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
351
732
  }
352
733
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
353
734
  while (Date.now() < softDeadlineAt) {
354
- if (!executions.some((execution) => executionAlive(execution, platform))) break;
735
+ if (!executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
355
736
  await new Promise((resolve) => setTimeout(resolve, 25));
356
737
  }
357
738
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
358
739
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
359
740
  for (const execution of executions) {
360
741
  if (!terminateSandboxControlExecution(execution, {
361
- platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now()
742
+ platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
362
743
  })) {
363
744
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
364
745
  }
365
746
  }
366
- if (executions.some((execution) => executionAlive(execution, platform))) {
747
+ if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
367
748
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
368
749
  }
369
750
  return broker || status ? 'stale' : 'missing';
@@ -372,28 +753,28 @@ export async function quiesceSandboxControlRoot(
372
753
  const remaining = (): number => Math.max(0, deadlineAt - Date.now());
373
754
  signalOwner(owner, platform, false);
374
755
  for (const execution of executions) {
375
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false });
756
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
376
757
  }
377
758
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
378
759
  while (Date.now() < softDeadlineAt) {
379
- if (!ownerLive(owner) && !executions.some((execution) => executionAlive(execution, platform))) break;
760
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
380
761
  await new Promise((resolve) => setTimeout(resolve, 25));
381
762
  }
382
- if (!ownerLive(owner) && !executions.some((execution) => executionAlive(execution, platform))) {
763
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
383
764
  return 'stopped';
384
765
  }
385
766
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
386
767
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
387
768
  for (const execution of executions) {
388
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now() });
769
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now(), identityProbe });
389
770
  }
390
771
  signalOwner(owner, platform, true);
391
772
  while (Date.now() < deadlineAt) {
392
- if (!ownerLive(owner) && !executions.some((execution) => executionAlive(execution, platform))) return 'stopped';
773
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) return 'stopped';
393
774
  await new Promise((resolve) => setTimeout(resolve, 25));
394
775
  }
395
- if (ownerLive(owner)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
396
- if (executions.some((execution) => executionAlive(execution, platform))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
776
+ if (ownerLive(owner, identityProbe)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
777
+ if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
397
778
  return 'stopped';
398
779
  }
399
780
 
@@ -401,6 +782,7 @@ export type RemoveSandboxControlOptions = Readonly<{
401
782
  platform?: NodeJS.Platform;
402
783
  timeoutMs?: number;
403
784
  timing?: SandboxControlTimingPolicy;
785
+ identityProbe?: ProcessIdentityProbe;
404
786
  inspectContainer?: (timeoutMs: number) => Promise<ContainerObservation>;
405
787
  removeContainer: (timeoutMs: number) => Promise<void>;
406
788
  requireAbsent?: boolean;
@@ -416,10 +798,11 @@ export async function removeSandboxControlRoot(
416
798
  const stat = fs.lstatSync(resolvedRoot);
417
799
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
418
800
  const manifestPath = path.join(resolvedRoot, 'manifest.json');
419
- const manifest = readSandboxControlManifest(manifestPath);
801
+ const manifest = readSandboxControlManifestForTransition(manifestPath);
420
802
  const inspectContainer = options.inspectContainer
421
803
  ?? ((timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }));
422
804
  const timeoutMs = options.timeoutMs ?? options.timing?.quiesceDeadlineMs ?? DEFAULT_QUIESCE_TIMEOUT_MS;
805
+ const identityProbe = options.identityProbe ?? getProcessIdentityState;
423
806
  const deadlineAt = Date.now() + timeoutMs;
424
807
  const forceAt = forceDeadline(deadlineAt, timeoutMs);
425
808
  const remaining = (): number => Math.max(0, deadlineAt - Date.now());
@@ -432,7 +815,7 @@ export async function removeSandboxControlRoot(
432
815
  }
433
816
 
434
817
  markSandboxControlRootQuiescing(resolvedRoot);
435
- await waitForStartupTransition(resolvedRoot, Math.max(0, forceAt - Date.now()));
818
+ await waitForStartupTransition(resolvedRoot, Math.max(0, forceAt - Date.now()), identityProbe);
436
819
  const brokerPath = path.join(resolvedRoot, 'broker.json');
437
820
  const statusPath = path.join(resolvedRoot, 'public', 'status.json');
438
821
  const broker = readBrokerOwner(brokerPath);
@@ -444,12 +827,12 @@ export async function removeSandboxControlRoot(
444
827
  throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
445
828
  }
446
829
  if (broker && status && !sameOwner(broker, status.broker)
447
- && (ownerLive(broker) || ownerLive(status.broker))) {
830
+ && (ownerLive(broker, identityProbe) || ownerLive(status.broker, identityProbe))) {
448
831
  throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
449
832
  }
450
833
  const platform = options.platform ?? process.platform;
451
- const owner = broker && ownerLive(broker) ? broker
452
- : status && ownerLive(status.broker) ? status.broker : null;
834
+ const owner = broker && ownerLive(broker, identityProbe) ? broker
835
+ : status && ownerLive(status.broker, identityProbe) ? status.broker : null;
453
836
  const selfOwner = options.selfOwner;
454
837
  const selfOwned = Boolean(selfOwner && broker && sameOwner(broker, selfOwner));
455
838
  if (selfOwner && (!broker || !selfOwned || broker.token !== manifest.token || broker.generation !== manifest.generation)) {
@@ -458,11 +841,12 @@ export async function removeSandboxControlRoot(
458
841
  let executions = readExecutions(manifest);
459
842
  if (owner && !selfOwned) signalOwner(owner, platform, false);
460
843
  for (const execution of executions) {
461
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false });
844
+ terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
462
845
  }
463
846
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
464
847
  while (Date.now() < softDeadlineAt) {
465
- if ((!owner || selfOwned || !ownerLive(owner)) && !executions.some((execution) => executionAlive(execution, platform))) break;
848
+ if ((!owner || selfOwned || !ownerLive(owner, identityProbe))
849
+ && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
466
850
  await new Promise((resolve) => setTimeout(resolve, 25));
467
851
  }
468
852
 
@@ -485,7 +869,7 @@ export async function removeSandboxControlRoot(
485
869
  }
486
870
  }
487
871
 
488
- const currentManifest = readSandboxControlManifest(manifestPath);
872
+ const currentManifest = readSandboxControlManifestForTransition(manifestPath);
489
873
  if (currentManifest.token !== manifest.token || currentManifest.generation !== manifest.generation
490
874
  || !isSandboxControlRootQuiescing(resolvedRoot)) {
491
875
  throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
@@ -498,10 +882,10 @@ export async function removeSandboxControlRoot(
498
882
  if (owner && currentBroker && !sameOwner(owner, currentBroker)) {
499
883
  throw new Error('SANDBOX_CONTROL_OWNER_REPLACED');
500
884
  }
501
- if (!owner && currentBroker && ownerLive(currentBroker)) {
885
+ if (!owner && currentBroker && ownerLive(currentBroker, identityProbe)) {
502
886
  throw new Error('SANDBOX_CONTROL_OWNER_REPLACED');
503
887
  }
504
- if (owner && ownerLive(owner) && !currentBroker) {
888
+ if (owner && ownerLive(owner, identityProbe) && !currentBroker) {
505
889
  throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
506
890
  }
507
891
 
@@ -509,21 +893,21 @@ export async function removeSandboxControlRoot(
509
893
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED');
510
894
  for (const execution of executions) {
511
895
  if (!terminateSandboxControlExecution(execution, {
512
- platform, timeoutMs: remaining(), deadlineAt, forceAt: Date.now()
896
+ platform, timeoutMs: remaining(), deadlineAt, forceAt: Date.now(), identityProbe
513
897
  })) {
514
898
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
515
899
  }
516
900
  }
517
- if (owner && currentBroker && ownerLive(currentBroker) && !selfOwned) {
901
+ if (owner && currentBroker && ownerLive(currentBroker, identityProbe) && !selfOwned) {
518
902
  if (remaining() <= 0) throw new Error('SANDBOX_CONTROL_REMOVE_DEADLINE_EXCEEDED');
519
903
  signalOwner(currentBroker, platform, true);
520
- if (remaining() <= 0 || !await waitForExit(currentBroker, remaining(), deadlineAt)) {
904
+ if (remaining() <= 0 || !await waitForExit(currentBroker, remaining(), deadlineAt, identityProbe)) {
521
905
  throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
522
906
  }
523
907
  }
524
908
 
525
909
  const finalExecutions = mergeExecutions(executions, readExecutions(manifest));
526
- if (finalExecutions.some((execution) => executionAlive(execution, platform))) {
910
+ if (finalExecutions.some((execution) => executionAlive(execution, platform, identityProbe))) {
527
911
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
528
912
  }
529
913
 
@@ -533,7 +917,9 @@ export async function removeSandboxControlRoot(
533
917
  if (!finalBroker) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
534
918
  const finalSelfOwner = Boolean(selfOwner && sameOwner(finalBroker, selfOwner)
535
919
  && finalBroker.token === manifest.token && finalBroker.generation === manifest.generation);
536
- if (ownerLive(finalBroker) && !finalSelfOwner) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
920
+ if (ownerLive(finalBroker, identityProbe) && !finalSelfOwner) {
921
+ throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
922
+ }
537
923
  if (fs.readFileSync(brokerPath, 'utf8') !== finalBrokerRaw) {
538
924
  throw new Error('SANDBOX_CONTROL_OWNER_TRANSITION');
539
925
  }
@@ -543,7 +929,8 @@ export async function removeSandboxControlRoot(
543
929
  if (finalStatusRaw !== null) {
544
930
  const finalStatus = readStatusOwner(statusPath);
545
931
  if (!finalStatus) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
546
- if (ownerLive(finalStatus.broker) && !(selfOwner && sameOwner(finalStatus.broker, selfOwner))) {
932
+ if (ownerLive(finalStatus.broker, identityProbe)
933
+ && !(selfOwner && sameOwner(finalStatus.broker, selfOwner))) {
547
934
  throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
548
935
  }
549
936
  if (fs.readFileSync(statusPath, 'utf8') !== finalStatusRaw) {