@fitlab-ai/agent-infra 0.9.7 → 0.9.9

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