@fitlab-ai/agent-infra 0.11.12 → 0.11.14

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 (120) hide show
  1. package/dist/bin/internal-cli.js +2 -7
  2. package/dist/lib/agent-clients/adapter.js +0 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +5 -9
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/cli.js +1 -5
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/recovery.js +1 -5
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +11 -25
  7. package/dist/lib/agent-clients/adapters/codex-orchestration.js +1 -3
  8. package/dist/lib/agent-clients/adapters/codex.js +0 -2
  9. package/dist/lib/internal/cli-route-inventory.js +0 -1
  10. package/dist/lib/internal/sandbox-control.js +1 -81
  11. package/dist/lib/internal/task-finalization.js +29 -20
  12. package/dist/lib/internal/task-lifecycle.js +4 -2
  13. package/dist/lib/internal/task-operation-registry.js +1 -3
  14. package/dist/lib/internal/task-validate.js +14 -124
  15. package/dist/lib/platform/github-client.js +33 -3
  16. package/dist/lib/platform/release-notes.js +2 -2
  17. package/dist/lib/sandbox/commands/control-status.js +0 -1
  18. package/dist/lib/sandbox/commands/create.js +11 -6
  19. package/dist/lib/sandbox/commands/ls.js +4 -5
  20. package/dist/lib/sandbox/commands/rm.js +1 -1
  21. package/dist/lib/sandbox/config.js +12 -0
  22. package/dist/lib/sandbox/control/audit.js +1 -2
  23. package/dist/lib/sandbox/control/client.js +15 -110
  24. package/dist/lib/sandbox/control/executor.js +9 -59
  25. package/dist/lib/sandbox/control/host-recovery.js +89 -27
  26. package/dist/lib/sandbox/control/lifecycle.js +75 -9
  27. package/dist/lib/sandbox/control/protocol.js +11 -50
  28. package/dist/lib/sandbox/control/server.js +203 -253
  29. package/dist/lib/sandbox/control/task-view.js +0 -13
  30. package/dist/lib/sandbox/environment.js +7 -8
  31. package/dist/lib/sandbox/mounts.js +71 -52
  32. package/dist/lib/sandbox/recovery.js +30 -12
  33. package/dist/lib/sandbox/removal.js +37 -3
  34. package/dist/lib/sandbox/workspace-identity.js +18 -6
  35. package/dist/lib/sandbox/workspace-view.js +89 -6
  36. package/dist/lib/task/commands/ls.js +3 -3
  37. package/dist/lib/task/control-authority.js +41 -17
  38. package/dist/lib/task/control-recovery.js +2 -4
  39. package/dist/lib/task/finalization.js +27 -2
  40. package/dist/lib/task/lifecycle.js +294 -63
  41. package/dist/lib/task/orchestration.js +26 -72
  42. package/dist/lib/task/short-id.js +37 -27
  43. package/dist/lib/task/verification-engine.js +4 -11
  44. package/dist/lifecycle-build-manifest.json +3 -3
  45. package/lib/agent-clients/adapter.ts +0 -27
  46. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +6 -12
  47. package/lib/agent-clients/adapters/codex-lifecycle/cli.ts +1 -5
  48. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +0 -7
  49. package/lib/agent-clients/adapters/codex-lifecycle/recovery.ts +1 -5
  50. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +21 -27
  51. package/lib/agent-clients/adapters/codex-orchestration.ts +1 -3
  52. package/lib/agent-clients/adapters/codex.ts +0 -2
  53. package/lib/internal/cli-route-inventory.ts +0 -1
  54. package/lib/internal/sandbox-control.ts +0 -97
  55. package/lib/internal/task-finalization.ts +33 -19
  56. package/lib/internal/task-lifecycle.ts +4 -2
  57. package/lib/internal/task-operation-registry.ts +1 -3
  58. package/lib/internal/task-validate.ts +13 -127
  59. package/lib/platform/github-client.ts +34 -3
  60. package/lib/platform/release-notes.ts +2 -2
  61. package/lib/sandbox/commands/control-status.ts +0 -1
  62. package/lib/sandbox/commands/create.ts +11 -5
  63. package/lib/sandbox/commands/ls.ts +4 -5
  64. package/lib/sandbox/commands/rm.ts +1 -1
  65. package/lib/sandbox/config.ts +13 -0
  66. package/lib/sandbox/control/audit.ts +1 -2
  67. package/lib/sandbox/control/client.ts +17 -138
  68. package/lib/sandbox/control/executor.ts +8 -65
  69. package/lib/sandbox/control/host-recovery.ts +94 -21
  70. package/lib/sandbox/control/lifecycle.ts +75 -10
  71. package/lib/sandbox/control/protocol.ts +12 -62
  72. package/lib/sandbox/control/server.ts +11 -61
  73. package/lib/sandbox/control/task-view.ts +0 -19
  74. package/lib/sandbox/environment.ts +7 -8
  75. package/lib/sandbox/mounts.ts +93 -68
  76. package/lib/sandbox/recovery.ts +37 -19
  77. package/lib/sandbox/removal.ts +36 -5
  78. package/lib/sandbox/workspace-identity.ts +16 -6
  79. package/lib/sandbox/workspace-view.ts +81 -7
  80. package/lib/task/commands/ls.ts +3 -3
  81. package/lib/task/control-authority.ts +41 -26
  82. package/lib/task/control-recovery.ts +3 -5
  83. package/lib/task/finalization.ts +29 -2
  84. package/lib/task/lifecycle.ts +282 -57
  85. package/lib/task/orchestration.ts +30 -68
  86. package/lib/task/short-id.ts +33 -40
  87. package/lib/task/verification-engine.ts +3 -9
  88. package/package.json +1 -1
  89. package/templates/.agents/rules/README.en.md +1 -0
  90. package/templates/.agents/rules/README.zh-CN.md +1 -0
  91. package/templates/.agents/rules/lifecycle-orchestration.en.md +3 -4
  92. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +3 -4
  93. package/templates/.agents/rules/strategy-pattern.en.md +25 -0
  94. package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
  95. package/templates/.agents/rules/task-short-id.en.md +18 -12
  96. package/templates/.agents/rules/task-short-id.zh-CN.md +12 -7
  97. package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
  98. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
  99. package/templates/.agents/skills/complete-task/SKILL.en.md +5 -26
  100. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -23
  101. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  102. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  103. package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
  104. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
  105. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  106. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  107. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-client.js +0 -130
  108. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +0 -127
  109. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-operation.js +0 -131
  110. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-registration.js +0 -275
  111. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.js +0 -106
  112. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +0 -345
  113. package/dist/lib/sandbox/control/agent-operation.js +0 -34
  114. package/lib/agent-clients/adapters/codex-lifecycle/controller-client.ts +0 -158
  115. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +0 -163
  116. package/lib/agent-clients/adapters/codex-lifecycle/controller-operation.ts +0 -151
  117. package/lib/agent-clients/adapters/codex-lifecycle/controller-registration.ts +0 -352
  118. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.ts +0 -109
  119. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +0 -437
  120. package/lib/sandbox/control/agent-operation.ts +0 -49
@@ -2,10 +2,13 @@ import path from 'node:path';
2
2
  import { shareBranchDir, shareCommonDir, shellConfigDirCandidates, worktreeDirCandidates } from './constants.js';
3
3
  import { sandboxWorkspaceViewStatePaths } from './workspace-view.js';
4
4
  import { SANDBOX_GIT_COMMON_DIR } from './git-metadata.js';
5
- export function sandboxCoreBindMounts(config, branch, overrides) {
6
- const taskSources = overrides.taskSources ?? [];
7
- const taskBound = Boolean(taskSources.length > 0 && overrides.taskId);
8
- const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(({ state, hostPath }) => taskBound && state === 'active'
5
+ function workspaceStateMounts(workspaceViewRoot, taskBound) {
6
+ return sandboxWorkspaceViewStatePaths(workspaceViewRoot)
7
+ // The writable completed-task mount below is nested under this path. Keep
8
+ // the worktree target visible so Docker can mount it without creating a
9
+ // mountpoint inside a read-only parent bind mount.
10
+ .filter(({ state }) => !(taskBound && state === 'completed'))
11
+ .map(({ state, hostPath }) => taskBound && state === 'active'
9
12
  ? {
10
13
  hostPaths: [path.join(hostPath, '.short-ids.json')],
11
14
  containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
@@ -16,17 +19,69 @@ export function sandboxCoreBindMounts(config, branch, overrides) {
16
19
  containerPath: path.posix.join('/workspace/.agents/workspace', state),
17
20
  readOnly: true
18
21
  });
22
+ }
23
+ function assertGitMetadataPair(overrides) {
19
24
  if (Boolean(overrides.gitCommonDir) !== Boolean(overrides.worktreeGitFile)) {
20
25
  throw new Error('SANDBOX_GIT_METADATA_INCOMPLETE');
21
26
  }
22
- const gitCommonMount = overrides.gitCommonDir
23
- ? { hostPaths: [overrides.gitCommonDir], containerPath: SANDBOX_GIT_COMMON_DIR, readOnly: false }
24
- : null;
25
- const worktreeGitFileMount = overrides.worktreeGitFile
26
- ? { hostPaths: [overrides.worktreeGitFile], containerPath: '/workspace/.git', readOnly: true }
27
- : null;
27
+ }
28
+ function gitCommonMount(gitCommonDir) {
29
+ return gitCommonDir ? [{ hostPaths: [gitCommonDir], containerPath: SANDBOX_GIT_COMMON_DIR, readOnly: false }] : [];
30
+ }
31
+ function worktreeGitFileMount(worktreeGitFile) {
32
+ return worktreeGitFile ? [{ hostPaths: [worktreeGitFile], containerPath: '/workspace/.git', readOnly: true }] : [];
33
+ }
34
+ function taskWorkspaceMounts(taskSources, taskBound, overrides) {
35
+ if (!taskBound)
36
+ return [];
37
+ const taskId = overrides.taskId;
38
+ const mounts = [{
39
+ hostPaths: taskSources,
40
+ containerPath: path.posix.join('/workspace/.agents/workspace/active', taskId),
41
+ readOnly: false
42
+ }];
43
+ if (overrides.completedTaskSource)
44
+ mounts.push({
45
+ hostPaths: [overrides.completedTaskSource],
46
+ containerPath: path.posix.join('/workspace/.agents/workspace/completed', taskId),
47
+ readOnly: false
48
+ });
49
+ return mounts;
50
+ }
51
+ function sharedMounts(config, branch, shellConfigHostDir) {
52
+ return [{
53
+ hostPaths: [shareCommonDir(config)], containerPath: '/share/common', readOnly: false
54
+ }, {
55
+ hostPaths: [shareBranchDir(config, branch)], containerPath: '/share/branch', readOnly: false
56
+ }, {
57
+ hostPaths: shellConfigHostDir ? [shellConfigHostDir] : shellConfigDirCandidates(config, branch),
58
+ containerPath: '/home/devuser/.host-shell-config', readOnly: true
59
+ }];
60
+ }
61
+ function controlMounts(overrides, taskBound) {
62
+ const mounts = [{
63
+ hostPaths: [overrides.controlStatusDir], containerPath: '/run/agent-infra/control-status', readOnly: true
64
+ }, {
65
+ hostPaths: [overrides.controlDir], containerPath: '/run/agent-infra/control', readOnly: false
66
+ }];
67
+ if (taskBound && overrides.runtimeDir)
68
+ mounts.push({
69
+ hostPaths: [overrides.runtimeDir], containerPath: '/run/agent-infra/runtime', readOnly: false
70
+ });
71
+ if (taskBound && overrides.controlRootDir)
72
+ mounts.push({
73
+ hostPaths: [overrides.controlRootDir], containerPath: '/run/agent-infra/control-root', readOnly: false
74
+ });
75
+ return mounts;
76
+ }
77
+ export function sandboxCoreBindMounts(config, branch, overrides) {
78
+ const taskSources = overrides.taskSources ?? [];
79
+ const taskBound = Boolean(taskSources.length > 0 && overrides.taskId);
80
+ assertGitMetadataPair(overrides);
81
+ if (taskBound && !overrides.controlRootDir)
82
+ throw new Error('SANDBOX_CONTROL_ROOT_MISSING');
28
83
  const mounts = [
29
- ...(gitCommonMount ? [gitCommonMount] : []),
84
+ ...gitCommonMount(overrides.gitCommonDir),
30
85
  {
31
86
  hostPaths: overrides.worktree
32
87
  ? [overrides.worktree]
@@ -34,48 +89,12 @@ export function sandboxCoreBindMounts(config, branch, overrides) {
34
89
  containerPath: '/workspace',
35
90
  readOnly: false
36
91
  },
37
- ...(worktreeGitFileMount ? [worktreeGitFileMount] : []),
38
- ...workspaceMounts,
92
+ ...worktreeGitFileMount(overrides.worktreeGitFile),
93
+ ...workspaceStateMounts(overrides.workspaceViewRoot, taskBound),
94
+ ...taskWorkspaceMounts(taskSources, taskBound, overrides),
95
+ ...sharedMounts(config, branch, overrides.shellConfigHostDir),
96
+ ...controlMounts(overrides, taskBound)
39
97
  ];
40
- if (taskBound) {
41
- const taskMountPath = path.posix.join('/workspace/.agents/workspace/active', overrides.taskId);
42
- mounts.push({
43
- hostPaths: taskSources,
44
- containerPath: taskMountPath,
45
- readOnly: false
46
- });
47
- }
48
- mounts.push({
49
- hostPaths: [shareCommonDir(config)],
50
- containerPath: '/share/common',
51
- readOnly: false
52
- }, {
53
- hostPaths: [shareBranchDir(config, branch)],
54
- containerPath: '/share/branch',
55
- readOnly: false
56
- }, {
57
- hostPaths: overrides.shellConfigHostDir
58
- ? [overrides.shellConfigHostDir]
59
- : shellConfigDirCandidates(config, branch),
60
- containerPath: '/home/devuser/.host-shell-config',
61
- readOnly: true
62
- });
63
- mounts.push({
64
- hostPaths: [overrides.controlStatusDir],
65
- containerPath: '/run/agent-infra/control-status',
66
- readOnly: true
67
- }, {
68
- hostPaths: [overrides.controlDir],
69
- containerPath: '/run/agent-infra/control',
70
- readOnly: false
71
- });
72
- if (taskBound && overrides.runtimeDir) {
73
- mounts.push({
74
- hostPaths: [overrides.runtimeDir],
75
- containerPath: '/run/agent-infra/runtime',
76
- readOnly: false
77
- });
78
- }
79
98
  return mounts;
80
99
  }
81
100
  //# sourceMappingURL=mounts.js.map
@@ -9,7 +9,7 @@ import { createSandboxCapabilityPlan } from './agent-client-reconciler.js';
9
9
  import { toEnginePath } from './engines/wsl2-paths.js';
10
10
  import { sandboxCoreBindMounts } from './mounts.js';
11
11
  import { resolveSandboxGitMetadata } from './git-metadata.js';
12
- import { assertSandboxTaskSource, sandboxControlPaths, sandboxWorkspaceViewPaths } from './workspace-view.js';
12
+ import { assertSandboxTaskSource, sandboxCompletedTaskMountSource, sandboxControlPaths, sandboxWorkspaceViewPaths } from './workspace-view.js';
13
13
  import { resolveTaskWorkspace } from './task-resolver.js';
14
14
  import { parseSandboxWorkspaceIdentity, sameSandboxWorkspaceIdentity } from './workspace-identity.js';
15
15
  import { inspectWorktree } from './worktree-safety.js';
@@ -174,7 +174,6 @@ export async function startSandboxControlBroker(repoRoot, manifestPath) {
174
174
  async function ensureSandboxControlBroker(params) {
175
175
  const control = sandboxControlPaths({
176
176
  base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
177
- repoRoot: params.config.repoRoot,
178
177
  project: params.config.project,
179
178
  container: params.container,
180
179
  identity: params.workspace
@@ -460,25 +459,38 @@ export function startCompletedSandboxContainer(params) {
460
459
  const activeRoot = path.join(params.config.repoRoot, '.agents', 'workspace', 'active');
461
460
  const historicalSource = path.join(activeRoot, params.taskId);
462
461
  fs.mkdirSync(activeRoot, { recursive: true });
462
+ let replacedEmptyRoot = false;
463
+ let emptyRootMode = 0o700;
463
464
  try {
464
- fs.lstatSync(historicalSource);
465
- throw new Error(`SANDBOX_COMPLETED_SOURCE_CONFLICT: historical task source already exists at ${historicalSource}`);
465
+ const existing = fs.lstatSync(historicalSource);
466
+ if (existing.isSymbolicLink() || !existing.isDirectory() || fs.readdirSync(historicalSource).length > 0) {
467
+ throw new Error(`SANDBOX_COMPLETED_SOURCE_CONFLICT: historical task source already exists at ${historicalSource}`);
468
+ }
469
+ emptyRootMode = existing.mode & 0o777;
470
+ fs.rmdirSync(historicalSource);
471
+ replacedEmptyRoot = true;
466
472
  }
467
473
  catch (error) {
468
474
  if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
469
475
  throw error;
470
476
  }
471
- fs.symlinkSync(completedSource, historicalSource, process.platform === 'win32' ? 'junction' : 'dir');
477
+ let temporaryLink = false;
472
478
  try {
479
+ fs.symlinkSync(completedSource, historicalSource, process.platform === 'win32' ? 'junction' : 'dir');
480
+ temporaryLink = true;
473
481
  params.start(params.engine, params.container);
474
482
  }
475
483
  finally {
476
- const temporarySource = fs.lstatSync(historicalSource);
477
- const resolvedSource = fs.realpathSync.native(historicalSource);
478
- if (!temporarySource.isSymbolicLink() || resolvedSource !== completedSource) {
479
- throw new Error(`SANDBOX_COMPLETED_SOURCE_CHANGED: refusing to remove unexpected path at ${historicalSource}`);
484
+ if (temporaryLink) {
485
+ const temporarySource = fs.lstatSync(historicalSource);
486
+ const resolvedSource = fs.realpathSync.native(historicalSource);
487
+ if (!temporarySource.isSymbolicLink() || resolvedSource !== completedSource) {
488
+ throw new Error(`SANDBOX_COMPLETED_SOURCE_CHANGED: refusing to remove unexpected path at ${historicalSource}`);
489
+ }
490
+ fs.unlinkSync(historicalSource);
480
491
  }
481
- fs.unlinkSync(historicalSource);
492
+ if (replacedEmptyRoot)
493
+ fs.mkdirSync(historicalSource, { mode: emptyRootMode });
482
494
  }
483
495
  }
484
496
  function sameHostSource(left, right) {
@@ -510,7 +522,6 @@ function expectedMounts(params) {
510
522
  });
511
523
  const control = sandboxControlPaths({
512
524
  base: config.controlBase ?? path.join(config.home, '.agent-infra', 'sandbox-control'),
513
- repoRoot: config.repoRoot,
514
525
  project: config.project,
515
526
  container: params.container,
516
527
  identity: params.workspace
@@ -519,6 +530,7 @@ function expectedMounts(params) {
519
530
  ? recoveryTaskSources(config.repoRoot, params.workspace.taskId)
520
531
  : null;
521
532
  const taskId = params.workspace.mode === 'task-bound' ? params.workspace.taskId : null;
533
+ const completedTaskSource = taskId ? sandboxCompletedTaskMountSource(config.repoRoot, taskId) : null;
522
534
  const worktrees = worktreeDirCandidates(config, branch).filter((candidate) => fs.existsSync(path.join(candidate, '.git')));
523
535
  const gitMetadata = worktrees.length === 1
524
536
  ? resolveSandboxGitMetadata(worktrees[0], control.channelDir)
@@ -532,12 +544,14 @@ function expectedMounts(params) {
532
544
  }),
533
545
  workspaceViewRoot: view.root,
534
546
  controlDir: control.channelDir,
547
+ ...(params.workspace.mode === 'task-bound' ? { controlRootDir: control.root } : {}),
535
548
  controlStatusDir: control.statusDir,
536
549
  ...(taskSources === null
537
550
  ? {}
538
551
  : {
539
552
  runtimeDir: control.runtimeDir,
540
553
  taskSources: taskSources.accessiblePaths,
554
+ completedTaskSource: completedTaskSource,
541
555
  taskId: taskId
542
556
  })
543
557
  }).map((mount) => ({
@@ -548,6 +562,10 @@ function expectedMounts(params) {
548
562
  && mount.containerPath === `/workspace/.agents/workspace/active/${taskId}`
549
563
  ? { sourceAccessiblePaths: taskSources.accessiblePaths }
550
564
  : {}),
565
+ ...(completedTaskSource !== null && taskId !== null
566
+ && mount.containerPath === `/workspace/.agents/workspace/completed/${taskId}`
567
+ ? { sourceAccessiblePaths: [completedTaskSource] }
568
+ : {}),
551
569
  expectedRW: !mount.readOnly
552
570
  }));
553
571
  const live = tools.flatMap((tool) => (tool.hostLiveMounts ?? []).flatMap(({ hostPath, containerSubpath }) => {
@@ -907,7 +925,7 @@ export async function ensureSandboxReady(params) {
907
925
  let completedReentry = null;
908
926
  if (params.reentry === 'completed' && params.workspace?.mode === 'task-bound') {
909
927
  const control = sandboxControlPaths({
910
- base: params.config.controlBase, repoRoot: params.config.repoRoot, project: params.config.project,
928
+ base: params.config.controlBase, project: params.config.project,
911
929
  container: params.row.name, identity: params.workspace
912
930
  });
913
931
  const manifest = readSandboxControlManifest(control.manifestPath);
@@ -568,7 +568,7 @@ function resolveRmTarget(config, tools, cleanupTarget, options = {}) {
568
568
  ? matchedContainers
569
569
  : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
570
570
  const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
571
- base: config.controlBase, repoRoot: config.repoRoot, project: config.project, container, identity
571
+ base: config.controlBase, project: config.project, container, identity
572
572
  }).root));
573
573
  const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
574
574
  base: config.workspaceViewBase, project: config.project, container, identity
@@ -758,11 +758,35 @@ async function rmPurgeCore(config, tools, prompt = {}) {
758
758
  const confirm = prompt.confirm ?? p.confirm;
759
759
  const isCancel = prompt.isCancel ?? p.isCancel;
760
760
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
761
+ const listed = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config), { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) });
762
+ const rows = [...listed.running, ...listed.nonRunning];
761
763
  const containers = runEngine(engine, 'docker', [
762
764
  'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
763
765
  ]).split('\n').filter(Boolean);
766
+ const controlRoots = rows.flatMap((row) => {
767
+ if (!row.branch)
768
+ return [];
769
+ const identity = row.workspaceMode === 'task-bound' && row.taskId
770
+ ? { mode: 'task-bound', taskId: row.taskId }
771
+ : row.workspaceMode === 'branch-only'
772
+ ? { mode: 'branch-only' }
773
+ : null;
774
+ if (!identity)
775
+ return [];
776
+ return [sandboxControlPaths({
777
+ base: config.controlBase, project: config.project,
778
+ container: row.name, identity
779
+ }).root];
780
+ });
781
+ const completedTaskIds = [...new Set(rows
782
+ .filter((row) => row.workspaceMode === 'task-bound' && row.taskId)
783
+ .map((row) => row.taskId))];
764
784
  for (const name of containers)
765
785
  runSafeEngine(engine, 'docker', ['rm', '-f', name]);
786
+ if (!sandboxContainersRemoved(engine, containers)) {
787
+ throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${containers.join(', ')}`);
788
+ }
789
+ let cleanupCompleted = true;
766
790
  const worktrees = fs.existsSync(config.worktreeBase)
767
791
  ? fs.readdirSync(config.worktreeBase).map((entry) => path.join(config.worktreeBase, entry))
768
792
  : [];
@@ -773,6 +797,8 @@ async function rmPurgeCore(config, tools, prompt = {}) {
773
797
  fs.rmSync(worktree, { recursive: true, force: true });
774
798
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
775
799
  }
800
+ else
801
+ cleanupCompleted = false;
776
802
  }
777
803
  for (const dir of projectToolDirs(config, tools))
778
804
  fs.rmSync(dir, { recursive: true, force: true });
@@ -780,14 +806,22 @@ async function rmPurgeCore(config, tools, prompt = {}) {
780
806
  const selected = await confirm({ message: `Remove all shell config dirs in ${config.shellConfigBase}?`, initialValue: true });
781
807
  if (!isCancel(selected) && selected)
782
808
  fs.rmSync(config.shellConfigBase, { recursive: true, force: true });
809
+ else
810
+ cleanupCompleted = false;
783
811
  }
784
812
  if (fs.existsSync(config.shareBase)) {
785
813
  const selected = await confirm({ message: `Remove all share dirs for project (${config.shareBase})?`, initialValue: true });
786
814
  if (!isCancel(selected) && selected)
787
815
  fs.rmSync(config.shareBase, { recursive: true, force: true });
816
+ else
817
+ cleanupCompleted = false;
788
818
  }
789
- for (const base of [config.workspaceViewBase, config.controlBase]) {
790
- fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
819
+ fs.rmSync(path.join(config.workspaceViewBase, config.project), { recursive: true, force: true });
820
+ for (const root of new Set(controlRoots))
821
+ fs.rmSync(root, { recursive: true, force: true });
822
+ if (cleanupCompleted) {
823
+ for (const taskId of completedTaskIds)
824
+ releaseStaleShortIdRegistry(config.repoRoot, taskId);
791
825
  }
792
826
  const removeImage = await confirm({ message: `Remove image ${config.imageName}?`, initialValue: false });
793
827
  if (!isCancel(removeImage) && removeImage)
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRemovedHashShortIdInput } from '../task/short-id.js';
4
+ import { resolveTaskRef } from '../task/resolve-ref.js';
4
5
  import { resolveTaskWorkspace } from './task-resolver.js';
5
6
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
7
  const SHORT_ID_RE = /^\d+$/;
@@ -38,6 +39,16 @@ function taskIdentity(repoRoot, taskId, registry) {
38
39
  }
39
40
  return { mode: 'task-bound', taskId, shortId: shortIds[0] };
40
41
  }
42
+ function cleanupTaskId(requestedRef, repoRoot) {
43
+ if (TASK_ID_RE.test(requestedRef))
44
+ return requestedRef;
45
+ if (!SHORT_ID_RE.test(requestedRef))
46
+ return null;
47
+ const task = resolveTaskRef(requestedRef, { repoRoot });
48
+ if (!task.ok)
49
+ throw new Error(task.message);
50
+ return task.taskId;
51
+ }
41
52
  export function resolveSandboxTarget(requestedRef, repoRoot) {
42
53
  if (isRemovedHashShortIdInput(requestedRef)) {
43
54
  throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
@@ -83,26 +94,27 @@ export function resolveSandboxCleanupTarget(requestedRef, repoRoot, options = {}
83
94
  if (isRemovedHashShortIdInput(requestedRef)) {
84
95
  throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
85
96
  }
86
- if (TASK_ID_RE.test(requestedRef)) {
97
+ const taskId = cleanupTaskId(requestedRef, repoRoot);
98
+ if (taskId) {
87
99
  let task;
88
100
  try {
89
- task = resolveTaskWorkspace(requestedRef, repoRoot);
101
+ task = resolveTaskWorkspace(taskId, repoRoot);
90
102
  }
91
103
  catch (error) {
92
- if (!(error instanceof Error && error.message === `Task not found: ${requestedRef}`))
104
+ if (!(error instanceof Error && error.message === `Task not found: ${taskId}`))
93
105
  throw error;
94
- const branch = options.resolveMissingTask?.(requestedRef);
106
+ const branch = options.resolveMissingTask?.(taskId);
95
107
  if (!branch)
96
108
  throw error;
97
109
  return {
98
110
  requestedRef,
99
111
  branch,
100
- workspace: { mode: 'task-bound', taskId: requestedRef },
112
+ workspace: { mode: 'task-bound', taskId },
101
113
  taskState: 'unknown'
102
114
  };
103
115
  }
104
116
  if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
105
- throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
117
+ throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${taskId} is ${task.state}`);
106
118
  }
107
119
  return {
108
120
  requestedRef,
@@ -2,7 +2,6 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { resolveTaskWorkspace } from './task-resolver.js';
5
- import { resolveTaskRuntimeRoot } from '../task/runtime-paths.js';
6
5
  import { createSandboxControlIdentitySentinel, writeSandboxControlIdentitySentinel } from './control/identity-sentinel.js';
7
6
  export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
8
7
  'active',
@@ -27,7 +26,17 @@ export function sandboxWorkspaceViewPaths(params) {
27
26
  }
28
27
  export function sandboxControlPaths(params) {
29
28
  const root = params.identity.mode === 'task-bound'
30
- ? path.join(resolveTaskRuntimeRoot(params.identity.taskId, { repoRoot: params.repoRoot }), 'sandbox-control')
29
+ ? (() => {
30
+ if (!/^TASK-\d{8}-\d{6}$/.test(params.identity.taskId)
31
+ || !params.project || path.basename(params.project) !== params.project || params.project === '.' || params.project === '..') {
32
+ throw new Error('SANDBOX_CONTROL_ROOT_INVALID');
33
+ }
34
+ const candidate = path.resolve(params.base, params.project, 'tasks', params.identity.taskId);
35
+ const relative = path.relative(path.resolve(params.base), candidate);
36
+ if (relative.startsWith('..') || path.isAbsolute(relative))
37
+ throw new Error('SANDBOX_CONTROL_ROOT_INVALID');
38
+ return candidate;
39
+ })()
31
40
  : (() => {
32
41
  const digest = createHash('sha256').update('branch-only').digest('hex').slice(0, 16);
33
42
  return path.resolve(params.base, params.project, params.container, digest);
@@ -50,6 +59,22 @@ function assertSafeDirectory(directory, expectedBase) {
50
59
  throw new Error(`Sandbox workspace view must not be a symbolic link: ${directory}`);
51
60
  }
52
61
  }
62
+ function assertSafeTaskBoundControlRoot(basePath, root) {
63
+ const relative = path.relative(basePath, root);
64
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
65
+ throw new Error(`Sandbox control root escapes its configured base: ${root}`);
66
+ }
67
+ if (fs.existsSync(basePath) && fs.lstatSync(basePath).isSymbolicLink()) {
68
+ throw new Error(`Sandbox control base must not be a symbolic link: ${basePath}`);
69
+ }
70
+ let current = basePath;
71
+ for (const segment of relative.split(path.sep)) {
72
+ current = path.join(current, segment);
73
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
74
+ throw new Error(`Sandbox control root must not contain a symbolic link: ${current}`);
75
+ }
76
+ }
77
+ }
53
78
  function assertSafeMountTargetPath(target, worktreeRoot, canonicalWorktreeRoot) {
54
79
  const relative = path.relative(worktreeRoot, target);
55
80
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
@@ -111,13 +136,16 @@ export function assertSandboxTaskSource(repoRoot, taskId) {
111
136
  }
112
137
  return canonical;
113
138
  }
114
- export function prepareSandboxWorkspaceMountTargets(worktreeRoot) {
139
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot, taskId) {
115
140
  const resolvedWorktreeRoot = path.resolve(worktreeRoot);
116
141
  const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
117
142
  const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
118
143
  const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
119
144
  const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
120
- for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
145
+ const completedTaskTarget = taskId ? path.join(workspaceRoot, 'completed', taskId) : null;
146
+ if (taskId && !/^TASK-\d{8}-\d{6}$/.test(taskId))
147
+ throw new Error('SANDBOX_TASK_SOURCE_INVALID');
148
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget, ...(completedTaskTarget ? [completedTaskTarget] : [])]) {
121
149
  assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
122
150
  }
123
151
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
@@ -125,9 +153,52 @@ export function prepareSandboxWorkspaceMountTargets(worktreeRoot) {
125
153
  fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
126
154
  fs.chmodSync(hostPath, 0o700);
127
155
  }
156
+ if (completedTaskTarget)
157
+ fs.mkdirSync(completedTaskTarget, { recursive: true, mode: 0o700 });
128
158
  fs.chmodSync(workspaceRoot, 0o700);
129
159
  fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
130
160
  }
161
+ export function prepareSandboxCompletedTaskMountSource(repoRoot, taskId) {
162
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId))
163
+ throw new Error('SANDBOX_TASK_SOURCE_INVALID');
164
+ const workspaceRoot = path.resolve(repoRoot, '.agents', 'workspace');
165
+ fs.mkdirSync(path.join(workspaceRoot, 'completed'), { recursive: true, mode: 0o700 });
166
+ const completedRoot = path.join(workspaceRoot, 'completed');
167
+ const completedStat = fs.lstatSync(completedRoot);
168
+ if (!completedStat.isDirectory() || completedStat.isSymbolicLink())
169
+ throw new Error('SANDBOX_TASK_SOURCE_INVALID');
170
+ const target = path.join(completedRoot, taskId);
171
+ try {
172
+ const stat = fs.lstatSync(target);
173
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(target).length > 0) {
174
+ throw new Error(`SANDBOX_COMPLETED_MOUNT_CONFLICT: ${target}`);
175
+ }
176
+ }
177
+ catch (error) {
178
+ if (error.code !== 'ENOENT')
179
+ throw error;
180
+ fs.mkdirSync(target, { mode: 0o700 });
181
+ }
182
+ return sandboxCompletedTaskMountSource(repoRoot, taskId);
183
+ }
184
+ export function sandboxCompletedTaskMountSource(repoRoot, taskId) {
185
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId))
186
+ throw new Error('SANDBOX_TASK_SOURCE_INVALID');
187
+ const source = path.resolve(repoRoot, '.agents', 'workspace', 'completed', taskId);
188
+ const stat = fs.lstatSync(source);
189
+ if (!stat.isDirectory() || stat.isSymbolicLink())
190
+ throw new Error('SANDBOX_TASK_SOURCE_INVALID');
191
+ const canonicalWorkspace = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace'));
192
+ const canonicalSource = fs.realpathSync.native(source);
193
+ const canonicalRepoRoot = fs.realpathSync.native(repoRoot);
194
+ const workspaceRelative = path.relative(canonicalRepoRoot, canonicalWorkspace);
195
+ const relative = path.relative(canonicalWorkspace, canonicalSource);
196
+ if (workspaceRelative.startsWith('..') || path.isAbsolute(workspaceRelative)
197
+ || path.dirname(canonicalSource) !== fs.realpathSync.native(path.dirname(source))
198
+ || relative.startsWith('..') || path.isAbsolute(relative))
199
+ throw new Error('SANDBOX_TASK_SOURCE_INVALID');
200
+ return canonicalSource;
201
+ }
131
202
  export function materializeSandboxWorkspaceView(params) {
132
203
  const projectRoot = path.resolve(params.base, params.project);
133
204
  const { root } = sandboxWorkspaceViewPaths(params);
@@ -157,7 +228,12 @@ export function materializeSandboxWorkspaceView(params) {
157
228
  }
158
229
  export function materializeSandboxControl(params) {
159
230
  const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
160
- assertSafeDirectory(root, params.identity.mode === 'task-bound' ? path.dirname(root) : path.resolve(params.base));
231
+ if (params.identity.mode === 'task-bound') {
232
+ assertSafeTaskBoundControlRoot(path.resolve(params.base), root);
233
+ }
234
+ else {
235
+ assertSafeDirectory(root, path.resolve(params.base));
236
+ }
161
237
  if (fs.existsSync(root)) {
162
238
  if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
163
239
  throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
@@ -215,9 +291,16 @@ export function finalizeSandboxControlManifest(setup, identity) {
215
291
  containerIdentity: { id: identity.id, labels: { ...identity.labels } },
216
292
  authorityEvidence: identity.authorityEvidence
217
293
  };
294
+ const persistedManifest = {
295
+ ...manifest,
296
+ channelDir: 'channel',
297
+ publicStatusDir: 'public',
298
+ processingDir: 'processing',
299
+ runtimeDir: 'runtime'
300
+ };
218
301
  const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
219
302
  try {
220
- fs.writeFileSync(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
303
+ fs.writeFileSync(temporary, `${JSON.stringify(persistedManifest)}\n`, { mode: 0o600, flag: 'wx' });
221
304
  fs.renameSync(temporary, setup.manifestPath);
222
305
  }
223
306
  finally {
@@ -58,9 +58,9 @@ function collectTasks(repoRoot, state) {
58
58
  const dir = path.join(repoRoot, '.agents', 'workspace', state);
59
59
  if (!fs.existsSync(dir))
60
60
  return [];
61
- // Short ids live only in the registry and only for active tasks; archived
62
- // (blocked/completed) tasks have released their short id and render '-'.
63
- const shortIdByTaskId = state === 'active' ? loadShortIdByTaskId(repoRoot) : new Map();
61
+ const shortIdByTaskId = state === 'active' || state === 'completed'
62
+ ? loadShortIdByTaskId(repoRoot)
63
+ : new Map();
64
64
  const rows = [];
65
65
  for (const entry of fs.readdirSync(dir).sort()) {
66
66
  if (!TASK_ID_RE.test(entry))