@fitlab-ai/agent-infra 0.9.8 → 0.9.10

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 (182) 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/codex-lifecycle/app-server.js +58 -21
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +25 -11
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +173 -10
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +127 -0
  8. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
  9. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +63 -92
  10. package/dist/lib/agent-clients/adapters/codex-sandbox.js +72 -11
  11. package/dist/lib/agent-clients/adapters/index.js +3 -1
  12. package/dist/lib/agent-clients/adapters/traecli.js +66 -0
  13. package/dist/lib/agent-clients/tokens.js +2 -2
  14. package/dist/lib/agent-clients/types.js +2 -1
  15. package/dist/lib/defaults.json +5 -0
  16. package/dist/lib/git/workflow.js +10 -1
  17. package/dist/lib/internal/codex-lifecycle.js +20 -6
  18. package/dist/lib/internal/codex-sandbox-controller.js +9 -15
  19. package/dist/lib/internal/git-workflow.js +5 -7
  20. package/dist/lib/internal/platform-pr.js +20 -7
  21. package/dist/lib/internal/release-workflow.js +2 -1
  22. package/dist/lib/internal/sandbox-control.js +79 -2
  23. package/dist/lib/internal/task-finalization.js +54 -0
  24. package/dist/lib/internal/task-orchestration.js +156 -291
  25. package/dist/lib/{task/commands/validate.js → internal/task-validate.js} +75 -42
  26. package/dist/lib/platform/pr-summary.js +48 -10
  27. package/dist/lib/platform/pull-requests.js +205 -41
  28. package/dist/lib/run/tui.js +5 -2
  29. package/dist/lib/sandbox/commands/rm.js +316 -55
  30. package/dist/lib/sandbox/control/client.js +190 -6
  31. package/dist/lib/sandbox/control/controller-registration.js +275 -0
  32. package/dist/lib/sandbox/control/executor.js +140 -5
  33. package/dist/lib/sandbox/control/lifecycle.js +3 -1
  34. package/dist/lib/sandbox/control/protocol.js +67 -6
  35. package/dist/lib/sandbox/index.js +5 -3
  36. package/dist/lib/sandbox/managed-fs.js +5 -0
  37. package/dist/lib/sandbox/task-resolver.js +50 -6
  38. package/dist/lib/sandbox/workspace-identity.js +27 -0
  39. package/dist/lib/sandbox/worktree-safety.js +215 -2
  40. package/dist/lib/task/codex-orchestration.js +161 -39
  41. package/dist/lib/task/commands/status.js +59 -1
  42. package/dist/lib/task/commit-operation.js +335 -0
  43. package/dist/lib/task/commit-policy.js +18 -0
  44. package/dist/lib/task/delegation-receipts.js +248 -6
  45. package/dist/lib/task/finalization.js +593 -0
  46. package/dist/lib/task/index.js +0 -9
  47. package/dist/lib/task/operation-outcome.js +33 -0
  48. package/dist/lib/task/orchestration.js +187 -761
  49. package/dist/lib/task/short-id.js +59 -1
  50. package/dist/lib/task/task-execution-lock.js +6 -1
  51. package/dist/lib/task/verification-engine.js +110 -60
  52. package/dist/lib/task/verification.js +2 -1
  53. package/dist/lib/task/workflow-warning-intents.js +50 -1
  54. package/dist/lifecycle-build-manifest.json +3 -3
  55. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +61 -19
  56. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +37 -10
  57. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +276 -12
  58. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +163 -0
  59. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
  60. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
  61. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +91 -118
  62. package/lib/agent-clients/adapters/codex-sandbox.ts +89 -14
  63. package/lib/agent-clients/adapters/index.ts +3 -1
  64. package/lib/agent-clients/adapters/traecli.ts +67 -0
  65. package/lib/agent-clients/tokens.ts +2 -2
  66. package/lib/agent-clients/types.ts +2 -1
  67. package/lib/defaults.json +5 -0
  68. package/lib/git/workflow.ts +11 -2
  69. package/lib/internal/codex-lifecycle.ts +19 -6
  70. package/lib/internal/codex-sandbox-controller.ts +9 -14
  71. package/lib/internal/git-workflow.ts +5 -6
  72. package/lib/internal/platform-pr.ts +14 -7
  73. package/lib/internal/release-workflow.ts +2 -1
  74. package/lib/internal/sandbox-control.ts +92 -2
  75. package/lib/internal/task-finalization.ts +68 -0
  76. package/lib/internal/task-orchestration.ts +20 -103
  77. package/lib/{task/commands/validate.ts → internal/task-validate.ts} +75 -40
  78. package/lib/platform/pr-summary.ts +55 -11
  79. package/lib/platform/pull-requests.ts +211 -41
  80. package/lib/run/tui.ts +5 -3
  81. package/lib/sandbox/commands/rm.ts +413 -61
  82. package/lib/sandbox/control/client.ts +255 -6
  83. package/lib/sandbox/control/controller-registration.ts +352 -0
  84. package/lib/sandbox/control/executor.ts +169 -5
  85. package/lib/sandbox/control/lifecycle.ts +2 -1
  86. package/lib/sandbox/control/protocol.ts +80 -7
  87. package/lib/sandbox/index.ts +5 -3
  88. package/lib/sandbox/managed-fs.ts +5 -0
  89. package/lib/sandbox/task-resolver.ts +58 -7
  90. package/lib/sandbox/workspace-identity.ts +50 -2
  91. package/lib/sandbox/workspace-view.ts +3 -3
  92. package/lib/sandbox/worktree-safety.ts +236 -2
  93. package/lib/task/codex-orchestration.ts +168 -40
  94. package/lib/task/commands/status.ts +78 -1
  95. package/lib/task/commit-operation.ts +430 -0
  96. package/lib/task/commit-policy.ts +30 -0
  97. package/lib/task/delegation-receipts.ts +263 -4
  98. package/lib/task/finalization.ts +761 -0
  99. package/lib/task/index.ts +0 -8
  100. package/lib/task/operation-outcome.ts +48 -0
  101. package/lib/task/orchestration.ts +218 -982
  102. package/lib/task/short-id.ts +65 -1
  103. package/lib/task/task-execution-lock.ts +10 -1
  104. package/lib/task/verification-engine.ts +115 -63
  105. package/lib/task/verification.ts +3 -2
  106. package/lib/task/workflow-warning-intents.ts +57 -2
  107. package/package.json +2 -2
  108. package/templates/.agents/QUICKSTART.en.md +3 -4
  109. package/templates/.agents/QUICKSTART.zh-CN.md +3 -4
  110. package/templates/.agents/hooks/lifecycle-delegation.js +1 -21
  111. package/templates/.agents/rules/README.en.md +1 -0
  112. package/templates/.agents/rules/README.zh-CN.md +1 -0
  113. package/templates/.agents/rules/compatibility-policy.en.md +39 -0
  114. package/templates/.agents/rules/compatibility-policy.zh-CN.md +39 -0
  115. package/templates/.agents/rules/lifecycle-orchestration.en.md +4 -4
  116. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +4 -4
  117. package/templates/.agents/rules/pr-sync.en.md +1 -1
  118. package/templates/.agents/rules/pr-sync.github.en.md +2 -1
  119. package/templates/.agents/rules/pr-sync.github.zh-CN.md +2 -0
  120. package/templates/.agents/rules/pr-sync.zh-CN.md +1 -0
  121. package/templates/.agents/rules/review-method.en.md +1 -0
  122. package/templates/.agents/rules/review-method.zh-CN.md +1 -0
  123. package/templates/.agents/rules/task-management.en.md +2 -2
  124. package/templates/.agents/rules/task-management.zh-CN.md +2 -2
  125. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -0
  126. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -0
  127. package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
  128. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
  129. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  130. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  131. package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
  132. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
  133. package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
  134. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
  135. package/templates/.agents/skills/code-task/SKILL.en.md +1 -0
  136. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -0
  137. package/templates/.agents/skills/commit/SKILL.en.md +41 -125
  138. package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
  139. package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
  140. package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
  141. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
  142. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
  143. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
  144. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
  145. package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
  146. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
  147. package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
  148. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
  149. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  150. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  151. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  152. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  153. package/templates/.agents/skills/create-pr/SKILL.en.md +17 -9
  154. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +17 -9
  155. package/templates/.agents/skills/create-pr/reference/change-report.en.md +41 -0
  156. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +41 -0
  157. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
  158. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
  159. package/templates/.agents/skills/plan-task/SKILL.en.md +2 -1
  160. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -1
  161. package/templates/.agents/skills/review-code/reference/migration-risks.en.md +8 -6
  162. package/templates/.agents/skills/review-code/reference/migration-risks.zh-CN.md +8 -6
  163. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +3 -3
  164. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +3 -3
  165. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +1 -1
  166. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +1 -1
  167. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
  168. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
  169. package/templates/.agents/skills/run-task/SKILL.en.md +6 -6
  170. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +6 -6
  171. package/templates/.agents/skills/run-task/reference/host-validation.en.md +2 -2
  172. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +2 -2
  173. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +24 -0
  174. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
  175. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
  176. package/templates/.codex/hooks.json +4 -4
  177. package/templates/.trae/skills/update-agent-infra.en.md +8 -0
  178. package/templates/.trae/skills/update-agent-infra.zh-CN.md +8 -0
  179. package/dist/lib/task/commit-finalization.js +0 -305
  180. package/dist/lib/task/commit-intent.js +0 -165
  181. package/lib/task/commit-finalization.ts +0 -419
  182. package/lib/task/commit-intent.ts +0 -230
@@ -4,8 +4,17 @@ import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { getProcessStartTime } from '../../server/process-state.ts';
6
6
  import { createTask } from '../../task/create-service.ts';
7
+ import { applyTaskFinalization } from '../../task/finalization.ts';
8
+ import { verifyTaskEvent } from '../../task/verification.ts';
7
9
  import { bindSandboxControlTask, type SandboxControlExecution, type SandboxControlManifest, type SandboxControlRequest } from './protocol.ts';
8
10
  import { atomicWriteJson, executionPath, terminateSandboxControlExecution } from './state.ts';
11
+ import { computeLifecycleBuildIdentity } from '../../agent-clients/adapters/codex-lifecycle/build-identity.ts';
12
+ import {
13
+ closeCodexControllerRegistration,
14
+ CodexControllerRegistrationError,
15
+ openCodexControllerRegistration,
16
+ resolveCodexControllerBinding
17
+ } from './controller-registration.ts';
9
18
 
10
19
  export type SandboxControlExecutionResult = {
11
20
  exitCode: number;
@@ -132,7 +141,73 @@ function waitForGate(nonce: string, timeoutMs = 2_000): Promise<void> {
132
141
  });
133
142
  }
134
143
 
135
- function executeRequest(manifest: SandboxControlManifest, request: SandboxControlRequest): SandboxControlExecutionResult {
144
+ function controllerFailure(error: unknown): SandboxControlExecutionResult {
145
+ const code = error instanceof CodexControllerRegistrationError
146
+ ? error.code
147
+ : /^([A-Z][A-Z0-9_]+)/u.exec(error instanceof Error ? error.message : String(error))?.[1]
148
+ ?? 'CODEX_SANDBOX_CONTROLLER_FAILED';
149
+ const message = error instanceof CodexControllerRegistrationError
150
+ ? error.message
151
+ : `${code}: controller operation failed; inspect the sandbox controller and rebuild the sandbox if needed`;
152
+ const payload = {
153
+ version: 1,
154
+ status: 'failed',
155
+ changed: false,
156
+ lease: null,
157
+ error: { code, message, retryable: false }
158
+ };
159
+ return { exitCode: 1, stdout: `${JSON.stringify(payload)}\n`, stderr: '' };
160
+ }
161
+
162
+ function orchestrationFailure(code: string, message: string): SandboxControlExecutionResult {
163
+ return {
164
+ exitCode: 1,
165
+ stdout: `${JSON.stringify({
166
+ status: 'failed', changed: false, taskId: null, run: null, next: null,
167
+ error: { code, message }
168
+ })}\n`,
169
+ stderr: ''
170
+ };
171
+ }
172
+
173
+ function finalizationResult(result: ReturnType<typeof applyTaskFinalization>): SandboxControlExecutionResult {
174
+ const payload = {
175
+ version: 1,
176
+ status: result.status,
177
+ changed: result.changed,
178
+ accepted: true,
179
+ result,
180
+ error: result.error
181
+ };
182
+ return {
183
+ exitCode: result.status === 'completed' ? 0 : result.status === 'blocked' ? 2 : 1,
184
+ stdout: `${JSON.stringify(payload)}\n`,
185
+ stderr: ''
186
+ };
187
+ }
188
+
189
+ function isCodexPrepare(args: readonly string[]): boolean {
190
+ if (args[1] !== 'prepare') return false;
191
+ const values: string[] = [];
192
+ for (let index = 2; index < args.length; index += 1) {
193
+ if (args[index] === '--client') values.push(args[index + 1] ?? '');
194
+ else if (args[index]?.startsWith('--client=')) values.push(args[index]!.slice('--client='.length));
195
+ }
196
+ return values.length === 1 && values[0] === 'codex';
197
+ }
198
+
199
+ type ExecuteRequestOptions = Readonly<{
200
+ buildIdentity?: typeof computeLifecycleBuildIdentity;
201
+ resolveControllerBinding?: typeof resolveCodexControllerBinding;
202
+ spawnDomain?: typeof spawnSync;
203
+ }>;
204
+
205
+ export function executeRequest(
206
+ manifest: SandboxControlManifest,
207
+ manifestPath: string,
208
+ request: SandboxControlRequest,
209
+ options: ExecuteRequestOptions = {}
210
+ ): SandboxControlExecutionResult {
136
211
  if (request.family === 'task-create') {
137
212
  const result = createTask(request.candidate, { repoRoot: manifest.repoRoot });
138
213
  return {
@@ -141,15 +216,104 @@ function executeRequest(manifest: SandboxControlManifest, request: SandboxContro
141
216
  stderr: ''
142
217
  };
143
218
  }
219
+ if (request.family === 'codex-controller') {
220
+ try {
221
+ if (request.command === 'verify') {
222
+ const binding = (options.resolveControllerBinding ?? resolveCodexControllerBinding)({
223
+ manifest,
224
+ manifestPath,
225
+ proof: request.controllerProof!,
226
+ buildIdentity: (options.buildIdentity ?? computeLifecycleBuildIdentity)(manifest.repoRoot)
227
+ });
228
+ return {
229
+ exitCode: 0,
230
+ stdout: `${JSON.stringify({
231
+ version: 1,
232
+ status: 'verified',
233
+ changed: false,
234
+ lease: null,
235
+ binding: {
236
+ taskId: manifest.taskId,
237
+ controlGeneration: binding.controlGeneration,
238
+ controllerInstanceDigest: binding.instanceDigest
239
+ },
240
+ error: null
241
+ })}\n`,
242
+ stderr: ''
243
+ };
244
+ }
245
+ const result = request.command === 'open'
246
+ ? openCodexControllerRegistration({
247
+ manifest,
248
+ manifestPath,
249
+ controllerProcess: request.controllerProcess!,
250
+ buildIdentity: (options.buildIdentity ?? computeLifecycleBuildIdentity)(manifest.repoRoot)
251
+ })
252
+ : closeCodexControllerRegistration({
253
+ manifest,
254
+ manifestPath,
255
+ proof: request.controllerProof!
256
+ });
257
+ return { exitCode: 0, stdout: `${JSON.stringify(result)}\n`, stderr: '' };
258
+ } catch (error) {
259
+ return controllerFailure(error);
260
+ }
261
+ }
262
+ if (request.family === 'task-finalization') {
263
+ return finalizationResult(applyTaskFinalization(
264
+ { taskRef: manifest.taskId!, intent: 'complete', agent: request.agent },
265
+ { repoRoot: manifest.repoRoot, preflight: (input, options) => verifyTaskEvent(
266
+ { ...input, event: 'complete-task.hard-preflight' }, options
267
+ ) }
268
+ ));
269
+ }
144
270
  const boundArgs = bindSandboxControlTask(request, manifest.taskId!);
145
- if (request.family === 'task-orchestration') boundArgs.push('--git-worktree-root', manifest.worktreeRoot);
146
- const result = spawnSync(
271
+ let controllerBinding: Readonly<{
272
+ instanceDigest: string;
273
+ controlGeneration: string;
274
+ }> | null = null;
275
+ if (request.family === 'task-orchestration') {
276
+ if (isCodexPrepare(request.args)) {
277
+ if (!request.controllerProof) {
278
+ return orchestrationFailure(
279
+ 'CODEX_SANDBOX_CONTROLLER_PROOF_REQUIRED',
280
+ 'Codex prepare requires a current controller lease proof'
281
+ );
282
+ }
283
+ try {
284
+ controllerBinding = (options.resolveControllerBinding ?? resolveCodexControllerBinding)({
285
+ manifest,
286
+ manifestPath,
287
+ proof: request.controllerProof,
288
+ buildIdentity: (options.buildIdentity ?? computeLifecycleBuildIdentity)(manifest.repoRoot)
289
+ });
290
+ } catch (error) {
291
+ const code = error instanceof CodexControllerRegistrationError
292
+ ? error.code
293
+ : 'CODEX_SANDBOX_CONTROLLER_PROOF_INVALID';
294
+ return orchestrationFailure(code, `${code}: Codex controller proof was rejected`);
295
+ }
296
+ } else if (request.controllerProof !== null) {
297
+ return orchestrationFailure(
298
+ 'CODEX_SANDBOX_CONTROLLER_PROOF_INVALID',
299
+ 'Controller proof is only accepted for canonical Codex prepare'
300
+ );
301
+ }
302
+ boundArgs.push('--git-worktree-root', manifest.worktreeRoot);
303
+ }
304
+ const result = (options.spawnDomain ?? spawnSync)(
147
305
  process.execPath,
148
306
  nodeEntryArgs(process.argv[1]!, [request.family, ...boundArgs]),
149
307
  {
150
308
  cwd: manifest.repoRoot,
151
309
  encoding: 'utf8',
152
- env: { ...safeEnv(process.env), AGENT_INFRA_RUNTIME_DIR: manifest.runtimeDir }
310
+ env: {
311
+ ...safeEnv(process.env),
312
+ AGENT_INFRA_RUNTIME_DIR: manifest.runtimeDir,
313
+ ...(controllerBinding
314
+ ? { AGENT_INFRA_CONTROL_CONTROLLER_BINDING: JSON.stringify(controllerBinding) }
315
+ : {})
316
+ }
153
317
  }
154
318
  );
155
319
  return { exitCode: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
@@ -164,7 +328,7 @@ export async function runSandboxControlExecutor(requestPath: string, nonce: stri
164
328
  if (!manifestPath) throw new Error('SANDBOX_CONTROL_EXECUTOR_MANIFEST_MISSING');
165
329
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as SandboxControlManifest;
166
330
  if (fs.realpathSync.native(manifest.repoRoot) !== root) throw new Error('SANDBOX_CONTROL_EXECUTOR_ROOT_INVALID');
167
- const result = executeRequest(manifest, request);
331
+ const result = executeRequest(manifest, manifestPath, request);
168
332
  process.stdout.write(result.stdout);
169
333
  process.stderr.write(result.stderr);
170
334
  process.exitCode = result.exitCode;
@@ -105,7 +105,7 @@ async function waitForStartupTransition(
105
105
  ): Promise<void> {
106
106
  const transitionPath = path.join(root, BROKER_STARTING_FILE);
107
107
  const deadline = Date.now() + timeoutMs;
108
- while (Date.now() < deadline) {
108
+ while (true) {
109
109
  const transition = readStartupOwner(transitionPath);
110
110
  if (!fs.existsSync(transitionPath)) return;
111
111
  if (transition && !ownerLive(transition.owner, identityProbe)) {
@@ -116,6 +116,7 @@ async function waitForStartupTransition(
116
116
  }
117
117
  continue;
118
118
  }
119
+ if (Date.now() >= deadline) break;
119
120
  await new Promise((resolve) => setTimeout(resolve, 25));
120
121
  }
121
122
  throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
@@ -1,12 +1,14 @@
1
1
  import { validateTaskCreateCandidate, type TaskCreateCandidateV1 } from '../../task/create.ts';
2
+ import { normalizeAgentToken } from '../../agent-clients/tokens.ts';
2
3
  import type { ProcessIdentity } from '../../server/process-state.ts';
4
+ import type { CodexControllerLeaseProofV1 } from './controller-registration.ts';
3
5
 
4
6
  export const SANDBOX_CONTROL_MAX_BYTES = 64 * 1024;
5
7
  export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
6
8
  export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
7
9
  export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
8
10
  export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
9
- export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-create'] as const;
11
+ export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-finalization', 'task-create', 'codex-controller'] as const;
10
12
  export type SandboxControlTimingPolicy = Readonly<{
11
13
  controlTickMs: number;
12
14
  parkedBindingInitialMs: number;
@@ -43,15 +45,25 @@ export type SandboxControlBrokerOwner = ProcessIdentity & Readonly<{
43
45
  generation: string;
44
46
  }>;
45
47
  type RequestBase = Readonly<{
46
- version: 2; id: string; token: string; generation: string; issuedAt: number; expiresAt: number;
48
+ version: 3; id: string; token: string; generation: string; issuedAt: number; expiresAt: number;
49
+ controllerProcess: ProcessIdentity | null;
50
+ controllerProof: CodexControllerLeaseProofV1 | null;
47
51
  }>;
48
52
  export type SandboxTaskCommandRequest = RequestBase & Readonly<{
49
53
  family: 'task-lifecycle' | 'task-orchestration'; args: string[];
50
54
  }>;
55
+ export type SandboxTaskFinalizationRequest = RequestBase & Readonly<{
56
+ family: 'task-finalization'; operation: 'complete'; agent: string; args: [];
57
+ }>;
51
58
  export type SandboxTaskCreateRequest = RequestBase & Readonly<{
52
59
  family: 'task-create'; candidate: TaskCreateCandidateV1;
53
60
  }>;
54
- export type SandboxControlRequest = SandboxTaskCommandRequest | SandboxTaskCreateRequest;
61
+ export type SandboxCodexControllerRequest = RequestBase & Readonly<{
62
+ family: 'codex-controller';
63
+ command: 'open' | 'close' | 'verify';
64
+ args: [];
65
+ }>;
66
+ export type SandboxControlRequest = SandboxTaskCommandRequest | SandboxTaskFinalizationRequest | SandboxTaskCreateRequest | SandboxCodexControllerRequest;
55
67
  export type SandboxControlError = Readonly<{ code: string; message: string; retryable: boolean }>;
56
68
  export type SandboxControlResponse = Readonly<{
57
69
  version: 2; id: string; phase: 'accepted' | 'completed' | 'rejected'; exitCode: number | null;
@@ -101,7 +113,7 @@ export function validateSandboxControlRequest(
101
113
  }
102
114
  const request = value as Record<string, unknown>;
103
115
  if (
104
- request.version !== 2 || typeof request.id !== 'string'
116
+ request.version !== 3 || typeof request.id !== 'string'
105
117
  || !/^[a-f0-9-]{16,64}$/.test(request.id) || request.token !== manifest.token
106
118
  || typeof request.family !== 'string' || !isSandboxControlFamily(request.family)
107
119
  || !Number.isSafeInteger(request.issuedAt) || !Number.isSafeInteger(request.expiresAt)
@@ -120,13 +132,47 @@ export function validateSandboxControlRequest(
120
132
  fail('SANDBOX_CONTROL_REQUEST_TOO_LARGE', 'request exceeds the control limit');
121
133
  }
122
134
  if (request.family === 'task-create') {
123
- const expected = ['candidate', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
135
+ const expected = ['candidate', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
124
136
  if (Object.keys(request).sort().join(',') !== expected.sort().join(',')) {
125
137
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
126
138
  }
139
+ if (request.controllerProcess !== null || request.controllerProof !== null) {
140
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-create cannot carry controller authority');
141
+ }
127
142
  return { ...request, candidate: validateTaskCreateCandidate(request.candidate) } as SandboxTaskCreateRequest;
128
143
  }
129
- const expected = ['args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
144
+ if (request.family === 'codex-controller') {
145
+ const expected = ['args', 'command', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
146
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
147
+ || !Array.isArray(request.args) || request.args.length !== 0
148
+ || !['open', 'close', 'verify'].includes(request.command as string)
149
+ || !validControllerProcess(request.controllerProcess)
150
+ || (request.command === 'open' && request.controllerProof !== null)
151
+ || (request.command !== 'open' && !validControllerProof(request.controllerProof))
152
+ || (request.command === 'verify'
153
+ && JSON.stringify(request.controllerProcess) !== JSON.stringify((request.controllerProof as CodexControllerLeaseProofV1).controllerProcess))) {
154
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller request schema is invalid');
155
+ }
156
+ if (manifest.mode !== 'task-bound' || !manifest.taskId) {
157
+ fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot register a Codex controller');
158
+ }
159
+ return request as SandboxCodexControllerRequest;
160
+ }
161
+ if (request.family === 'task-finalization') {
162
+ const expected = ['agent', 'args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'operation', 'token', 'version'];
163
+ if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
164
+ || request.operation !== 'complete'
165
+ || !Array.isArray(request.args) || request.args.length !== 0
166
+ || typeof request.agent !== 'string' || normalizeAgentToken(request.agent) !== request.agent
167
+ || request.controllerProcess !== null || request.controllerProof !== null) {
168
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization request schema or authorization is invalid');
169
+ }
170
+ if (manifest.mode !== 'task-bound' || !manifest.taskId) {
171
+ fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks');
172
+ }
173
+ return request as SandboxTaskFinalizationRequest;
174
+ }
175
+ const expected = ['args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
130
176
  if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
131
177
  || !Array.isArray(request.args) || !request.args.every((arg) => typeof arg === 'string')) {
132
178
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
@@ -141,15 +187,42 @@ export function validateSandboxControlRequest(
141
187
  && request.args.some((arg) => arg === '--git-worktree-root' || arg.startsWith('--git-worktree-root='))) {
142
188
  fail('SANDBOX_CONTROL_REQUEST_INVALID', 'worktree binding is reserved for the control broker');
143
189
  }
190
+ if (request.controllerProcess !== null
191
+ || (request.controllerProof !== null && !validControllerProof(request.controllerProof))) {
192
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller authority is invalid');
193
+ }
194
+ if (request.family !== 'task-orchestration' && request.controllerProof !== null) {
195
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller proof is not allowed for this family');
196
+ }
144
197
  return request as SandboxTaskCommandRequest;
145
198
  }
146
199
 
147
200
  export function bindSandboxControlTask(request: SandboxControlRequest, taskId: string): string[] {
148
- if (request.family === 'task-create') fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-create requests do not bind a current task');
201
+ if (request.family === 'task-create' || request.family === 'codex-controller' || request.family === 'task-finalization') {
202
+ fail('SANDBOX_CONTROL_REQUEST_INVALID', `${request.family} requests do not bind a current task`);
203
+ }
149
204
  if (request.args.length === 0) fail('SANDBOX_CONTROL_REQUEST_INVALID', 'command arguments are required');
150
205
  return [taskId, ...request.args.slice(1)];
151
206
  }
152
207
 
208
+ function validControllerProcess(value: unknown): value is ProcessIdentity {
209
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
210
+ const processValue = value as Record<string, unknown>;
211
+ return Object.keys(processValue).sort().join(',') === 'pid,startTime'
212
+ && Number.isSafeInteger(processValue.pid) && (processValue.pid as number) > 0
213
+ && Number.isSafeInteger(processValue.startTime) && (processValue.startTime as number) >= 0;
214
+ }
215
+
216
+ function validControllerProof(value: unknown): value is CodexControllerLeaseProofV1 {
217
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
218
+ const proof = value as Record<string, unknown>;
219
+ return Object.keys(proof).sort().join(',') === 'controllerProcess,leaseId,leaseSecret,version'
220
+ && proof.version === 1
221
+ && typeof proof.leaseId === 'string' && /^[a-f0-9]{64}$/u.test(proof.leaseId)
222
+ && typeof proof.leaseSecret === 'string' && /^[a-f0-9]{64}$/u.test(proof.leaseSecret)
223
+ && validControllerProcess(proof.controllerProcess);
224
+ }
225
+
153
226
  export function controlError(error: unknown): SandboxControlError {
154
227
  if (error instanceof SandboxControlProtocolError) {
155
228
  return { code: error.code, message: error.message, retryable: error.retryable };
@@ -15,9 +15,11 @@ Commands:
15
15
  rebuild [--quiet] [--refresh]
16
16
  Rebuild the sandbox image (--refresh pulls base + tools)
17
17
  refresh Sync host Claude Code credentials to all sandbox copies
18
- rm <branch> | --unbound | --purge
19
- Remove one sandbox, all sandboxes not bound to an
20
- active task (--unbound), or tear down everything (--purge)
18
+ rm <branch | TASK-id> | --unbound | --purge
19
+ Remove one sandbox; use a full TASK-id for task-bound
20
+ cleanup and a branch for branch-only cleanup. --unbound
21
+ removes completed task-bound and branch-only sandboxes;
22
+ active, blocked, and archive tasks are protected.
21
23
  start [--recreate] <branch | TASK-id | N>
22
24
  Verify or recover an existing sandbox container;
23
25
  optionally replace only the container on failure
@@ -36,6 +36,11 @@ export function removeWorktreeDir(
36
36
  throw new Error(`Worktree permit target mismatch: ${dir}`);
37
37
  }
38
38
  verifyWorktreePermit(permit);
39
+ if (permit.snapshot.source === 'recovered') {
40
+ removeManagedDir(worktreeBase, dir);
41
+ runSafeFn('git', ['-C', repoRoot, 'worktree', 'prune']);
42
+ return;
43
+ }
39
44
  try {
40
45
  runFn('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
41
46
  } catch (error) {
@@ -5,7 +5,14 @@ import { isRemovedHashShortIdInput } from '../task/short-id.ts';
5
5
 
6
6
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
7
7
  const SHORT_ID_RE = /^\d+$/;
8
- const WORKSPACE_DIRS = ['active', 'completed', 'blocked', 'archive'];
8
+ export const TASK_WORKSPACE_STATES = ['active', 'completed', 'blocked', 'archive'] as const;
9
+ export type TaskWorkspaceState = typeof TASK_WORKSPACE_STATES[number];
10
+ export type TaskWorkspace = Readonly<{
11
+ taskId: string;
12
+ branch: string;
13
+ state: TaskWorkspaceState;
14
+ taskMd: string;
15
+ }>;
9
16
 
10
17
  function resolveShortIdStrict(arg: string, repoRoot: string): string {
11
18
  const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
@@ -27,16 +34,30 @@ function stripQuotes(value: string): string {
27
34
  return value.replace(/^(["'])(.*)\1$/, '$2');
28
35
  }
29
36
 
30
- function readTaskContent(repoRoot: string, taskId: string): string {
31
- for (const dir of WORKSPACE_DIRS) {
32
- const taskPath = path.join(repoRoot, '.agents', 'workspace', dir, taskId, 'task.md');
37
+ function readTaskContent(repoRoot: string, taskId: string): { content: string; state: TaskWorkspaceState; taskMd: string } {
38
+ for (const state of TASK_WORKSPACE_STATES) {
39
+ const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
33
40
  if (fs.existsSync(taskPath)) {
34
- return fs.readFileSync(taskPath, 'utf8');
41
+ return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
35
42
  }
36
43
  }
37
44
  throw new Error(`Task not found: ${taskId}`);
38
45
  }
39
46
 
47
+ function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspace[] {
48
+ return TASK_WORKSPACE_STATES.flatMap((state) => {
49
+ const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
50
+ if (!fs.existsSync(taskMd)) return [];
51
+ const content = fs.readFileSync(taskMd, 'utf8');
52
+ return [{
53
+ taskId,
54
+ branch: resolveBranchFromTaskContent(content, taskId),
55
+ state,
56
+ taskMd
57
+ }];
58
+ });
59
+ }
60
+
40
61
  function resolveBranchFromTaskContent(content: string, taskId: string): string {
41
62
  const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
42
63
  if (frontmatterBranch?.[1]?.trim()) {
@@ -57,12 +78,42 @@ export function resolveTaskBranch(arg: string, repoRoot: string): string {
57
78
  }
58
79
  if (SHORT_ID_RE.test(arg)) {
59
80
  const taskId = resolveShortIdStrict(arg, repoRoot);
60
- const content = readTaskContent(repoRoot, taskId);
81
+ const { content } = readTaskContent(repoRoot, taskId);
61
82
  return resolveBranchFromTaskContent(content, taskId);
62
83
  }
63
84
  if (!TASK_ID_RE.test(arg)) {
64
85
  return arg;
65
86
  }
66
- const content = readTaskContent(repoRoot, arg);
87
+ const { content } = readTaskContent(repoRoot, arg);
67
88
  return resolveBranchFromTaskContent(content, arg);
68
89
  }
90
+
91
+ export function resolveTaskWorkspace(taskId: string, repoRoot: string): TaskWorkspace {
92
+ if (!TASK_ID_RE.test(taskId)) throw new Error(`Invalid task id: ${taskId}`);
93
+ const matches = taskWorkspaceCandidates(repoRoot, taskId);
94
+ if (matches.length === 0) throw new Error(`Task not found: ${taskId}`);
95
+ if (matches.length !== 1) {
96
+ throw new Error(
97
+ `SANDBOX_TASK_STATE_AMBIGUOUS: task ${taskId} exists in ${matches.map((match) => match.state).join(', ')}`
98
+ );
99
+ }
100
+ return matches[0]!;
101
+ }
102
+
103
+ export function listTaskWorkspaces(repoRoot: string, state: TaskWorkspaceState): TaskWorkspace[] {
104
+ const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
105
+ if (!fs.existsSync(stateRoot)) return [];
106
+ return fs.readdirSync(stateRoot, { withFileTypes: true })
107
+ .filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
108
+ .flatMap((entry) => {
109
+ const taskMd = path.join(stateRoot, entry.name, 'task.md');
110
+ if (!fs.existsSync(taskMd)) return [];
111
+ const content = fs.readFileSync(taskMd, 'utf8');
112
+ return [{
113
+ taskId: entry.name,
114
+ branch: resolveBranchFromTaskContent(content, entry.name),
115
+ state,
116
+ taskMd
117
+ }];
118
+ });
119
+ }
@@ -1,6 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRemovedHashShortIdInput } from '../task/short-id.ts';
4
+ import {
5
+ resolveTaskWorkspace,
6
+ type TaskWorkspaceState
7
+ } from './task-resolver.ts';
4
8
 
5
9
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
10
  const SHORT_ID_RE = /^\d+$/;
@@ -9,6 +13,17 @@ export type SandboxWorkspaceIdentity =
9
13
  | Readonly<{ mode: 'task-bound'; taskId: string; shortId: string }>
10
14
  | Readonly<{ mode: 'branch-only' }>;
11
15
 
16
+ export type SandboxWorkspaceKey =
17
+ | Readonly<{ mode: 'task-bound'; taskId: string }>
18
+ | Readonly<{ mode: 'branch-only' }>;
19
+
20
+ export type SandboxCleanupTarget = Readonly<{
21
+ requestedRef: string;
22
+ branch: string;
23
+ workspace: SandboxWorkspaceKey;
24
+ taskState: TaskWorkspaceState | 'branch-only';
25
+ }>;
26
+
12
27
  export type SandboxContainerWorkspaceIdentity =
13
28
  | Readonly<{ mode: 'task-bound'; taskId: string }>
14
29
  | Readonly<{ mode: 'branch-only' }>
@@ -101,6 +116,39 @@ export function resolveSandboxTarget(requestedRef: string, repoRoot: string): Sa
101
116
  return { requestedRef, branch: requestedRef, workspace: { mode: 'branch-only' } };
102
117
  }
103
118
 
119
+ export function resolveSandboxCleanupTarget(
120
+ requestedRef: string,
121
+ repoRoot: string,
122
+ options: Readonly<{ allowProtected?: boolean }> = {}
123
+ ): SandboxCleanupTarget {
124
+ if (isRemovedHashShortIdInput(requestedRef)) {
125
+ throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
126
+ }
127
+
128
+ if (TASK_ID_RE.test(requestedRef)) {
129
+ const task = resolveTaskWorkspace(requestedRef, repoRoot);
130
+ if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
131
+ throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
132
+ }
133
+ return {
134
+ requestedRef,
135
+ branch: task.branch,
136
+ workspace: { mode: 'task-bound', taskId: task.taskId },
137
+ taskState: task.state
138
+ };
139
+ }
140
+
141
+ const target = resolveSandboxTarget(requestedRef, repoRoot);
142
+ return {
143
+ requestedRef,
144
+ branch: target.branch,
145
+ workspace: target.workspace.mode === 'task-bound'
146
+ ? { mode: 'task-bound', taskId: target.workspace.taskId }
147
+ : { mode: 'branch-only' },
148
+ taskState: target.workspace.mode === 'task-bound' ? 'active' : 'branch-only'
149
+ };
150
+ }
151
+
104
152
  export function parseSandboxWorkspaceIdentity(
105
153
  labels: Readonly<Record<string, string>>,
106
154
  keys: Readonly<{ mode: string; taskId: string }>
@@ -115,8 +163,8 @@ export function parseSandboxWorkspaceIdentity(
115
163
  }
116
164
 
117
165
  export function sameSandboxWorkspaceIdentity(
118
- left: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity,
119
- right: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity
166
+ left: SandboxWorkspaceIdentity | SandboxWorkspaceKey | SandboxContainerWorkspaceIdentity,
167
+ right: SandboxWorkspaceIdentity | SandboxWorkspaceKey | SandboxContainerWorkspaceIdentity
120
168
  ): boolean {
121
169
  return left.mode === right.mode
122
170
  && (left.mode !== 'task-bound' || (right.mode === 'task-bound' && left.taskId === right.taskId));
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import type { SandboxWorkspaceIdentity } from './workspace-identity.ts';
4
+ import type { SandboxWorkspaceIdentity, SandboxWorkspaceKey } from './workspace-identity.ts';
5
5
  import type { SandboxControlManifest } from './control/protocol.ts';
6
6
 
7
7
  export type SandboxWorkspaceView = Readonly<{
@@ -30,7 +30,7 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
30
30
  base: string;
31
31
  project: string;
32
32
  container: string;
33
- identity: SandboxWorkspaceIdentity;
33
+ identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
34
34
  }>): SandboxWorkspaceView {
35
35
  const identityKey = params.identity.mode === 'task-bound'
36
36
  ? `task-bound:${params.identity.taskId}`
@@ -60,7 +60,7 @@ export function sandboxControlPaths(params: Readonly<{
60
60
  base: string;
61
61
  project: string;
62
62
  container: string;
63
- identity: SandboxWorkspaceIdentity;
63
+ identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
64
64
  }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; runtimeDir: string; manifestPath: string }> {
65
65
  const identityKey = params.identity.mode === 'task-bound'
66
66
  ? `task-bound:${params.identity.taskId}`