@fitlab-ai/agent-infra 0.9.7 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/bin/internal-cli.js +5 -0
  2. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +2 -1
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +2 -1
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +30 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +2 -1
  7. package/dist/lib/decide.js +68 -14
  8. package/dist/lib/init.js +1 -1
  9. package/dist/lib/internal/release-workflow.js +1 -3
  10. package/dist/lib/internal/task-activity.js +57 -7
  11. package/dist/lib/internal/task-event.js +57 -4
  12. package/dist/lib/internal/task-ledger.js +59 -5
  13. package/dist/lib/internal/task-lifecycle.js +59 -8
  14. package/dist/lib/internal/task-orchestration.js +11 -3
  15. package/dist/lib/internal/task-override.js +131 -0
  16. package/dist/lib/internal/task-review.js +64 -6
  17. package/dist/lib/internal/task-verify.js +65 -3
  18. package/dist/lib/internal/task-warning.js +57 -4
  19. package/dist/lib/platform/pr-summary.js +5 -3
  20. package/dist/lib/runtime/agent-runtime.js +56 -0
  21. package/dist/lib/sandbox/commands/create.js +389 -311
  22. package/dist/lib/sandbox/control/container-identity.js +1 -1
  23. package/dist/lib/sandbox/control/executor.js +10 -2
  24. package/dist/lib/sandbox/control/lifecycle.js +392 -72
  25. package/dist/lib/sandbox/control/state.js +27 -11
  26. package/dist/lib/sandbox/engine.js +14 -7
  27. package/dist/lib/sandbox/mounts.js +20 -5
  28. package/dist/lib/sandbox/recovery.js +78 -10
  29. package/dist/lib/sandbox/workspace-view.js +62 -40
  30. package/dist/lib/server/process-state.js +51 -1
  31. package/dist/lib/task/activity-intent.js +4 -1
  32. package/dist/lib/task/decision-intents.js +4 -2
  33. package/dist/lib/task/delegation-receipts.js +37 -16
  34. package/dist/lib/task/events.js +9 -5
  35. package/dist/lib/task/guard-override.js +31 -0
  36. package/dist/lib/task/human-override.js +1240 -0
  37. package/dist/lib/task/ledger-intents.js +6 -3
  38. package/dist/lib/task/lifecycle.js +69 -14
  39. package/dist/lib/task/orchestration.js +60 -6
  40. package/dist/lib/task/review-finalization.js +3 -2
  41. package/dist/lib/task/verification-engine.js +40 -14
  42. package/dist/lib/task/verification.js +13 -1
  43. package/dist/lib/task/workflow-warning-intents.js +4 -2
  44. package/dist/lib/task/write.js +2 -1
  45. package/dist/lifecycle-build-manifest.json +3 -3
  46. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  47. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +2 -1
  48. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +2 -1
  49. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +29 -2
  50. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +2 -1
  51. package/lib/decide.ts +59 -14
  52. package/lib/init.ts +1 -1
  53. package/lib/internal/release-workflow.ts +1 -3
  54. package/lib/internal/task-activity.ts +46 -6
  55. package/lib/internal/task-event.ts +53 -4
  56. package/lib/internal/task-ledger.ts +48 -4
  57. package/lib/internal/task-lifecycle.ts +56 -12
  58. package/lib/internal/task-orchestration.ts +12 -4
  59. package/lib/internal/task-override.ts +138 -0
  60. package/lib/internal/task-review.ts +51 -6
  61. package/lib/internal/task-verify.ts +52 -3
  62. package/lib/internal/task-warning.ts +44 -3
  63. package/lib/platform/pr-summary.ts +10 -3
  64. package/lib/runtime/agent-runtime.ts +76 -0
  65. package/lib/sandbox/commands/create.ts +88 -4
  66. package/lib/sandbox/config.ts +2 -1
  67. package/lib/sandbox/control/container-identity.ts +3 -3
  68. package/lib/sandbox/control/executor.ts +10 -2
  69. package/lib/sandbox/control/lifecycle.ts +463 -76
  70. package/lib/sandbox/control/protocol.ts +5 -2
  71. package/lib/sandbox/control/state.ts +35 -18
  72. package/lib/sandbox/engine.ts +20 -9
  73. package/lib/sandbox/mounts.ts +23 -5
  74. package/lib/sandbox/recovery.ts +90 -9
  75. package/lib/sandbox/workspace-view.ts +70 -40
  76. package/lib/server/process-state.ts +48 -1
  77. package/lib/task/activity-intent.ts +4 -2
  78. package/lib/task/decision-intents.ts +4 -2
  79. package/lib/task/delegation-receipts.ts +46 -18
  80. package/lib/task/events.ts +10 -5
  81. package/lib/task/guard-override.ts +50 -0
  82. package/lib/task/human-override.ts +1503 -0
  83. package/lib/task/ledger-intents.ts +6 -3
  84. package/lib/task/lifecycle.ts +71 -14
  85. package/lib/task/orchestration.ts +89 -7
  86. package/lib/task/review-finalization.ts +6 -2
  87. package/lib/task/verification-engine.ts +42 -16
  88. package/lib/task/verification.ts +15 -1
  89. package/lib/task/workflow-warning-intents.ts +4 -2
  90. package/lib/task/write.ts +4 -1
  91. package/package.json +1 -1
  92. package/templates/.agents/hooks/lifecycle-delegation.js +8 -3
  93. package/templates/.agents/rules/lifecycle-orchestration.en.md +3 -2
  94. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +3 -2
  95. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -1
  96. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -1
@@ -17,9 +17,11 @@ import { parseSandboxWorkspaceIdentity, resolveSandboxTarget, sameSandboxWorkspa
17
17
  import { createSandboxCapabilityPlan, runSandboxHooks, runBoundedSandboxHookCommand } from '../agent-client-reconciler.js';
18
18
  import { resolveTools, tmpfsSeedStagingPath, tmpfsSeedTargetPath, toolConfigDirCandidates } from '../tools.js';
19
19
  import { assertFreshSandboxReady, hydrateTmpfsSeedEntries, prepareTmpfsMounts, startSandboxControlBroker } from '../recovery.js';
20
+ import { acquireSandboxControlReplacement, assertSandboxControlCutoverSnapshot, beginSandboxControlReplacement, captureSandboxControlCutoverSnapshot, commitSandboxControlReplacement, quiesceSandboxControlRoot, recoverSandboxControlReplacement, readSandboxControlManifestForTransition } from '../control/lifecycle.js';
21
+ import { inspectSandboxControlContainer } from '../control/container-identity.js';
20
22
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
21
23
  import { sandboxCoreBindMounts } from '../mounts.js';
22
- import { assertSandboxTaskSource, finalizeSandboxControlManifest, materializeSandboxControl, materializeSandboxWorkspaceView, prepareSandboxWorkspaceMountTarget, } from '../workspace-view.js';
24
+ import { assertSandboxTaskSource, finalizeSandboxControlManifest, materializeSandboxControl, materializeSandboxWorkspaceView, prepareSandboxWorkspaceMountTargets, sandboxControlPaths, } from '../workspace-view.js';
23
25
  import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.js';
24
26
  import { validateSelinuxDisableEnv } from '../engines/selinux.js';
25
27
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
@@ -702,6 +704,12 @@ export async function create(args) {
702
704
  }
703
705
  const effectiveResolvedTools = resolvedTools;
704
706
  const container = containerName(effectiveConfig, branch);
707
+ const controlPaths = sandboxControlPaths({
708
+ base: effectiveConfig.controlBase,
709
+ project: effectiveConfig.project,
710
+ container,
711
+ identity: target.workspace
712
+ });
705
713
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
706
714
  const shareCommon = shareCommonDir(effectiveConfig);
707
715
  const shareBranch = shareBranchDir(effectiveConfig, branch);
@@ -863,344 +871,414 @@ export async function create(args) {
863
871
  {
864
872
  title: `Starting container '${container}'`,
865
873
  task: async (message) => {
866
- const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
867
- const matchedContainers = containerNameCandidates(effectiveConfig, branch)
868
- .filter((name) => existing.includes(name));
869
- if (matchedContainers.length > 0) {
870
- for (const name of matchedContainers) {
871
- const rawLabels = runSafeEngine(engine, 'docker', [
872
- 'inspect', '--format', '{{json .Config.Labels}}', name
873
- ]).trim();
874
- let labels = {};
875
- try {
876
- labels = JSON.parse(rawLabels);
877
- }
878
- catch {
879
- labels = {};
874
+ const hadControlRootBeforeAcquire = fs.existsSync(controlPaths.root);
875
+ const replacementLease = acquireSandboxControlReplacement(controlPaths.root);
876
+ let replacementLeaseHeld = true;
877
+ let replacementCutover = null;
878
+ try {
879
+ const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
880
+ const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
881
+ const previousManifest = fs.existsSync(controlPaths.manifestPath)
882
+ ? readSandboxControlManifestForTransition(controlPaths.manifestPath)
883
+ : null;
884
+ if (hadExistingControlRoot) {
885
+ await quiesceSandboxControlRoot(controlPaths.root);
886
+ }
887
+ const previousCutoverSnapshot = hadExistingControlRoot
888
+ ? captureSandboxControlCutoverSnapshot(controlPaths.root)
889
+ : null;
890
+ const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
891
+ const matchedContainers = containerNameCandidates(effectiveConfig, branch)
892
+ .filter((name) => existing.includes(name));
893
+ if (matchedContainers.length > 0) {
894
+ for (const name of matchedContainers) {
895
+ const rawLabels = runSafeEngine(engine, 'docker', [
896
+ 'inspect', '--format', '{{json .Config.Labels}}', name
897
+ ]).trim();
898
+ let labels = {};
899
+ try {
900
+ labels = JSON.parse(rawLabels);
901
+ }
902
+ catch {
903
+ labels = {};
904
+ }
905
+ const existingIdentity = parseSandboxWorkspaceIdentity(labels, {
906
+ mode: sandboxWorkspaceModeLabel(effectiveConfig),
907
+ taskId: sandboxTaskIdLabel(effectiveConfig)
908
+ });
909
+ if (!sameSandboxWorkspaceIdentity(existingIdentity, target.workspace)) {
910
+ const existingDescription = existingIdentity.mode === 'task-bound'
911
+ ? `task-bound:${existingIdentity.taskId}`
912
+ : existingIdentity.mode;
913
+ const requestedDescription = target.workspace.mode === 'task-bound'
914
+ ? `task-bound:${target.workspace.taskId}`
915
+ : target.workspace.mode;
916
+ const recoveryTarget = target.workspace.mode === 'task-bound'
917
+ ? target.workspace.taskId
918
+ : branch;
919
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${name}' is ${existingDescription}, `
920
+ + `but this request is ${requestedDescription}. `
921
+ + `Run 'ai sandbox start --recreate ${recoveryTarget}' on the host, then retry.`);
922
+ }
880
923
  }
881
- const existingIdentity = parseSandboxWorkspaceIdentity(labels, {
882
- mode: sandboxWorkspaceModeLabel(effectiveConfig),
883
- taskId: sandboxTaskIdLabel(effectiveConfig)
884
- });
885
- if (!sameSandboxWorkspaceIdentity(existingIdentity, target.workspace)) {
886
- const existingDescription = existingIdentity.mode === 'task-bound'
887
- ? `task-bound:${existingIdentity.taskId}`
888
- : existingIdentity.mode;
889
- const requestedDescription = target.workspace.mode === 'task-bound'
890
- ? `task-bound:${target.workspace.taskId}`
891
- : target.workspace.mode;
892
- const recoveryTarget = target.workspace.mode === 'task-bound'
893
- ? target.workspace.taskId
894
- : branch;
895
- throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${name}' is ${existingDescription}, `
896
- + `but this request is ${requestedDescription}. `
897
- + `Run 'ai sandbox start --recreate ${recoveryTarget}' on the host, then retry.`);
924
+ message('Removing old container instance...');
925
+ for (const name of matchedContainers) {
926
+ runSafeEngine(engine, 'docker', ['stop', name]);
927
+ runSafeEngine(engine, 'docker', ['rm', name]);
898
928
  }
899
929
  }
900
- message('Removing old container instance...');
901
- for (const name of matchedContainers) {
902
- runSafeEngine(engine, 'docker', ['stop', name]);
903
- runSafeEngine(engine, 'docker', ['rm', name]);
930
+ if (previousManifest) {
931
+ const oldContainer = await inspectSandboxControlContainer(previousManifest);
932
+ if (oldContainer.state !== 'absent') {
933
+ throw new Error(oldContainer.state === 'unknown'
934
+ ? `SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${oldContainer.reason}`
935
+ : 'SANDBOX_CONTROL_CONTAINER_STILL_EXISTS');
936
+ }
904
937
  }
905
- }
906
- const aliasesFile = ensureSandboxAliasesFile(effectiveConfig.home, capabilityPlan.aliases);
907
- if (aliasesFile.created) {
908
- message(`Created default sandbox aliases at ${aliasesFile.path}`);
909
- }
910
- const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
911
- for (const { created, path: readmePath } of readmeResults) {
912
- if (created) {
913
- message(`Created discovery README at ${readmePath}`);
938
+ const aliasesFile = ensureSandboxAliasesFile(effectiveConfig.home, capabilityPlan.aliases);
939
+ if (aliasesFile.created) {
940
+ message(`Created default sandbox aliases at ${aliasesFile.path}`);
914
941
  }
915
- }
916
- const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
917
- const gitconfigContent = fs.existsSync(gitconfigPath)
918
- ? fs.readFileSync(gitconfigPath, 'utf8')
919
- : '';
920
- const needsGpg = detectGpgConfig(gitconfigContent);
921
- const hasHostGpgKeys = needsGpg && hostHasGpgKeys(effectiveConfig.home);
922
- const signingKey = needsGpg
923
- ? getGitSigningKey({ repoPath: worktree, home: effectiveConfig.home })
924
- : null;
925
- const cachedGpg = needsGpg
926
- ? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
927
- : null;
928
- const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
929
- const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
930
- additionalEntries: proxyEntries
931
- });
932
- let hostShellConfig;
933
- let tmpfsSeedPlan = [];
934
- try {
935
- const beforeCreateResults = await runSandboxHooks({
936
- hooks: capabilityPlan.hooksByPhase['before-container-create'],
937
- phase: 'before-container-create',
938
- context: {
939
- config: effectiveConfig,
940
- plan: capabilityPlan,
941
- create: {
942
- hostHome: effectiveConfig.home,
943
- hostEnv: { ...process.env },
944
- project: effectiveConfig.project,
945
- resolvedTools: effectiveResolvedTools
946
- }
947
- },
948
- runCommand: runBoundedSandboxHookCommand
949
- });
950
- const beforeCreateFailure = beforeCreateResults.find((result) => result.status === 'fatal');
951
- if (beforeCreateFailure) {
952
- throw new Error(beforeCreateFailure.message
953
- ?? `Sandbox hook '${beforeCreateFailure.hookId}' failed.`);
942
+ const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
943
+ for (const { created, path: readmePath } of readmeResults) {
944
+ if (created) {
945
+ message(`Created discovery README at ${readmePath}`);
946
+ }
954
947
  }
955
- const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => tool.tmpfs ? [] : ['-v', volumeArg(engine, dir, tool.containerMount)]);
956
- const tmpfsArgs = effectiveResolvedTools.flatMap(({ tool }) => tool.tmpfs ? buildTmpfsRunArgs(tool.containerMount, tool.tmpfs) : []);
957
- const workspaceView = materializeSandboxWorkspaceView({
958
- base: effectiveConfig.workspaceViewBase,
959
- project: effectiveConfig.project,
960
- container,
961
- identity: target.workspace
962
- });
963
- prepareSandboxWorkspaceMountTarget(worktree);
964
- const control = materializeSandboxControl({
965
- base: effectiveConfig.controlBase,
966
- repoRoot: effectiveConfig.repoRoot,
967
- worktreeRoot: worktree,
968
- project: effectiveConfig.project,
969
- container,
970
- branch,
971
- identity: target.workspace,
972
- engine
948
+ const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
949
+ const gitconfigContent = fs.existsSync(gitconfigPath)
950
+ ? fs.readFileSync(gitconfigPath, 'utf8')
951
+ : '';
952
+ const needsGpg = detectGpgConfig(gitconfigContent);
953
+ const hasHostGpgKeys = needsGpg && hostHasGpgKeys(effectiveConfig.home);
954
+ const signingKey = needsGpg
955
+ ? getGitSigningKey({ repoPath: worktree, home: effectiveConfig.home })
956
+ : null;
957
+ const cachedGpg = needsGpg
958
+ ? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
959
+ : null;
960
+ const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
961
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
962
+ additionalEntries: proxyEntries
973
963
  });
974
- hostShellConfig = prepareHostShellConfig({
975
- home: effectiveConfig.home,
976
- project: effectiveConfig.project,
977
- branch,
978
- repoRoot: effectiveConfig.repoRoot
979
- });
980
- const coreVolumes = sandboxCoreBindMounts(effectiveConfig, branch, {
981
- worktree,
982
- shellConfigHostDir: hostShellConfig.hostDir,
983
- workspaceViewRoot: workspaceView.root,
984
- controlDir: control.channelDir,
985
- controlStatusDir: control.statusDir,
986
- ...(target.workspace.mode === 'task-bound'
987
- ? {
988
- taskSource: assertSandboxTaskSource(effectiveConfig.repoRoot, target.workspace.taskId),
989
- taskId: target.workspace.taskId
964
+ let hostShellConfig;
965
+ let tmpfsSeedPlan = [];
966
+ try {
967
+ const beforeCreateResults = await runSandboxHooks({
968
+ hooks: capabilityPlan.hooksByPhase['before-container-create'],
969
+ phase: 'before-container-create',
970
+ context: {
971
+ config: effectiveConfig,
972
+ plan: capabilityPlan,
973
+ create: {
974
+ hostHome: effectiveConfig.home,
975
+ hostEnv: { ...process.env },
976
+ project: effectiveConfig.project,
977
+ resolvedTools: effectiveResolvedTools
978
+ }
979
+ },
980
+ runCommand: runBoundedSandboxHookCommand
981
+ });
982
+ const beforeCreateFailure = beforeCreateResults.find((result) => result.status === 'fatal');
983
+ if (beforeCreateFailure) {
984
+ throw new Error(beforeCreateFailure.message
985
+ ?? `Sandbox hook '${beforeCreateFailure.hookId}' failed.`);
986
+ }
987
+ const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => tool.tmpfs ? [] : ['-v', volumeArg(engine, dir, tool.containerMount)]);
988
+ const tmpfsArgs = effectiveResolvedTools.flatMap(({ tool }) => tool.tmpfs ? buildTmpfsRunArgs(tool.containerMount, tool.tmpfs) : []);
989
+ const workspaceView = materializeSandboxWorkspaceView({
990
+ base: effectiveConfig.workspaceViewBase,
991
+ project: effectiveConfig.project,
992
+ container,
993
+ identity: target.workspace
994
+ });
995
+ prepareSandboxWorkspaceMountTargets(worktree);
996
+ if (previousCutoverSnapshot) {
997
+ assertSandboxControlCutoverSnapshot(previousCutoverSnapshot);
998
+ if (previousManifest) {
999
+ const finalOldContainer = await inspectSandboxControlContainer(previousManifest);
1000
+ if (finalOldContainer.state !== 'absent') {
1001
+ throw new Error(finalOldContainer.state === 'unknown'
1002
+ ? `SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${finalOldContainer.reason}`
1003
+ : 'SANDBOX_CONTROL_CONTAINER_REAPPEARED');
1004
+ }
990
1005
  }
991
- : {})
992
- }).flatMap(({ hostPaths, containerPath, readOnly }) => [
993
- '-v',
994
- volumeArg(engine, hostPaths[0], containerPath, readOnly ? ':ro' : '')
995
- ]);
996
- // Mount each declared seed read-only at an isolated staging path.
997
- // After docker run mounts the empty tmpfs, the container's default
998
- // user copies these entries into it so the runtime targets are normal,
999
- // writable files rather than nested mount points. The allowlist keeps
1000
- // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1001
- // being exposed or written through.
1002
- tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1003
- const targetPath = tmpfsSeedTargetPath(tool.containerMount, entry);
1004
- const hostPath = path.join(dir, entry);
1005
- if (!fs.existsSync(hostPath)) {
1006
- return [];
1006
+ replacementCutover = beginSandboxControlReplacement(controlPaths.root, replacementLease);
1007
1007
  }
1008
- const stagingPath = tmpfsSeedStagingPath(tool.id, index);
1009
- return [{
1010
- toolId: tool.id,
1011
- containerMount: tool.containerMount,
1012
- stagingPath,
1013
- targetPath,
1014
- volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1015
- }];
1016
- }));
1017
- const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1018
- .filter(({ hostPath }) => fs.existsSync(hostPath))
1019
- .flatMap(({ hostPath, containerSubpath }) => [
1020
- '-v',
1021
- volumeArg(engine, hostPath, path.posix.join(tool.containerMount, containerSubpath))
1022
- ]));
1023
- fs.mkdirSync(shareCommon, { recursive: true });
1024
- fs.mkdirSync(shareBranch, { recursive: true });
1025
- fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
1026
- const dotfilesSnapshot = materializeDotfiles(effectiveConfig.dotfilesDir, dotfilesCacheDir(effectiveConfig.home, effectiveConfig.project));
1027
- const dotfilesMount = dotfilesSnapshot
1028
- ? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
1029
- : [];
1030
- const hostTz = detectHostTimezone();
1031
- const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
1032
- let createdContainerRef = null;
1033
- try {
1034
- const dockerRunId = runEngineTaskCommand(engine, 'docker', [
1035
- 'run',
1036
- '-d',
1037
- '--init',
1038
- '--name',
1008
+ const control = materializeSandboxControl({
1009
+ base: effectiveConfig.controlBase,
1010
+ repoRoot: effectiveConfig.repoRoot,
1011
+ worktreeRoot: worktree,
1012
+ project: effectiveConfig.project,
1039
1013
  container,
1040
- '--hostname',
1041
- `${effectiveConfig.project}-sandbox`,
1042
- '--label',
1043
- sandboxLabel(effectiveConfig),
1044
- '--label',
1045
- `${sandboxBranchLabel(effectiveConfig)}=${branch}`,
1046
- '--label',
1047
- `${sandboxWorkspaceModeLabel(effectiveConfig)}=${target.workspace.mode}`,
1014
+ branch,
1015
+ identity: target.workspace,
1016
+ engine,
1017
+ replacementLease
1018
+ });
1019
+ hostShellConfig = prepareHostShellConfig({
1020
+ home: effectiveConfig.home,
1021
+ project: effectiveConfig.project,
1022
+ branch,
1023
+ repoRoot: effectiveConfig.repoRoot
1024
+ });
1025
+ const coreVolumes = sandboxCoreBindMounts(effectiveConfig, branch, {
1026
+ worktree,
1027
+ shellConfigHostDir: hostShellConfig.hostDir,
1028
+ workspaceViewRoot: workspaceView.root,
1029
+ controlDir: control.channelDir,
1030
+ controlStatusDir: control.statusDir,
1031
+ ...(target.workspace.mode === 'task-bound' ? { runtimeDir: control.runtimeDir } : {}),
1048
1032
  ...(target.workspace.mode === 'task-bound'
1049
- ? [
1050
- '--label',
1051
- `${sandboxTaskIdLabel(effectiveConfig)}=${target.workspace.taskId}`,
1052
- '-e',
1053
- `AGENT_INFRA_TASK_ID=${target.workspace.taskId}`
1054
- ]
1055
- : []),
1056
- '-e',
1057
- `AGENT_INFRA_CONTROL_TOKEN=${control.token}`,
1058
- '-e',
1059
- `AGENT_INFRA_CONTROL_GENERATION=${control.generation}`,
1060
- '-e',
1061
- 'AGENT_INFRA_CONTROL_DIR=/run/agent-infra/control',
1062
- '-e',
1063
- 'AGENT_INFRA_CONTROL_STATUS_DIR=/run/agent-infra/control-status',
1064
- '--label',
1065
- `${sandboxRuntimeCapabilityLabel(effectiveConfig)}=${capabilityPlan.runtimeSignature}`,
1066
- ...coreVolumes,
1067
- ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1033
+ ? {
1034
+ taskSource: assertSandboxTaskSource(effectiveConfig.repoRoot, target.workspace.taskId),
1035
+ taskId: target.workspace.taskId
1036
+ }
1037
+ : {})
1038
+ }).flatMap(({ hostPaths, containerPath, readOnly }) => [
1068
1039
  '-v',
1069
- volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
1070
- ...dotfilesMount,
1071
- ...toolVolumes,
1072
- ...tmpfsArgs,
1073
- ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1074
- ...liveMountVolumes,
1075
- ...envFile.dockerArgs,
1076
- ...tzFlags,
1077
- '-w',
1078
- '/workspace',
1079
- effectiveConfig.imageName
1040
+ volumeArg(engine, hostPaths[0], containerPath, readOnly ? ':ro' : '')
1080
1041
  ]);
1081
- createdContainerRef = dockerRunId.trim();
1082
- const containerId = runEngineTaskCommand(engine, 'docker', [
1083
- 'inspect', '--format', '{{.Id}}', dockerRunId.trim()
1084
- ]).trim();
1085
- if (!containerId)
1086
- throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
1087
- const rawContainerLabels = runEngineTaskCommand(engine, 'docker', [
1088
- 'inspect', '--format', '{{json .Config.Labels}}', containerId
1089
- ]).trim();
1090
- let inspectedLabels;
1042
+ // Mount each declared seed read-only at an isolated staging path.
1043
+ // After docker run mounts the empty tmpfs, the container's default
1044
+ // user copies these entries into it so the runtime targets are normal,
1045
+ // writable files rather than nested mount points. The allowlist keeps
1046
+ // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1047
+ // being exposed or written through.
1048
+ tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) => (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1049
+ const targetPath = tmpfsSeedTargetPath(tool.containerMount, entry);
1050
+ const hostPath = path.join(dir, entry);
1051
+ if (!fs.existsSync(hostPath)) {
1052
+ return [];
1053
+ }
1054
+ const stagingPath = tmpfsSeedStagingPath(tool.id, index);
1055
+ return [{
1056
+ toolId: tool.id,
1057
+ containerMount: tool.containerMount,
1058
+ stagingPath,
1059
+ targetPath,
1060
+ volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1061
+ }];
1062
+ }));
1063
+ const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1064
+ .filter(({ hostPath }) => fs.existsSync(hostPath))
1065
+ .flatMap(({ hostPath, containerSubpath }) => [
1066
+ '-v',
1067
+ volumeArg(engine, hostPath, path.posix.join(tool.containerMount, containerSubpath))
1068
+ ]));
1069
+ fs.mkdirSync(shareCommon, { recursive: true });
1070
+ fs.mkdirSync(shareBranch, { recursive: true });
1071
+ fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
1072
+ const dotfilesSnapshot = materializeDotfiles(effectiveConfig.dotfilesDir, dotfilesCacheDir(effectiveConfig.home, effectiveConfig.project));
1073
+ const dotfilesMount = dotfilesSnapshot
1074
+ ? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
1075
+ : [];
1076
+ const hostTz = detectHostTimezone();
1077
+ const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
1078
+ let createdContainerRef = null;
1091
1079
  try {
1092
- inspectedLabels = JSON.parse(rawContainerLabels);
1093
- }
1094
- catch {
1095
- throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1096
- }
1097
- if (!inspectedLabels || typeof inspectedLabels !== 'object' || Array.isArray(inspectedLabels)) {
1098
- throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1080
+ const dockerRunId = runEngineTaskCommand(engine, 'docker', [
1081
+ 'run',
1082
+ '-d',
1083
+ '--init',
1084
+ '--name',
1085
+ container,
1086
+ '--hostname',
1087
+ `${effectiveConfig.project}-sandbox`,
1088
+ '--label',
1089
+ sandboxLabel(effectiveConfig),
1090
+ '--label',
1091
+ `${sandboxBranchLabel(effectiveConfig)}=${branch}`,
1092
+ '--label',
1093
+ `${sandboxWorkspaceModeLabel(effectiveConfig)}=${target.workspace.mode}`,
1094
+ ...(target.workspace.mode === 'task-bound'
1095
+ ? [
1096
+ '--label',
1097
+ `${sandboxTaskIdLabel(effectiveConfig)}=${target.workspace.taskId}`,
1098
+ '-e',
1099
+ `AGENT_INFRA_TASK_ID=${target.workspace.taskId}`
1100
+ ]
1101
+ : []),
1102
+ '-e',
1103
+ `AGENT_INFRA_CONTROL_TOKEN=${control.token}`,
1104
+ '-e',
1105
+ `AGENT_INFRA_CONTROL_GENERATION=${control.generation}`,
1106
+ '-e',
1107
+ 'AGENT_INFRA_CONTROL_DIR=/run/agent-infra/control',
1108
+ '-e',
1109
+ 'AGENT_INFRA_CONTROL_STATUS_DIR=/run/agent-infra/control-status',
1110
+ ...(target.workspace.mode === 'task-bound'
1111
+ ? ['-e', 'AGENT_INFRA_RUNTIME_DIR=/run/agent-infra/runtime']
1112
+ : []),
1113
+ '--label',
1114
+ `${sandboxRuntimeCapabilityLabel(effectiveConfig)}=${capabilityPlan.runtimeSignature}`,
1115
+ ...coreVolumes,
1116
+ ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1117
+ '-v',
1118
+ volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
1119
+ ...dotfilesMount,
1120
+ ...toolVolumes,
1121
+ ...tmpfsArgs,
1122
+ ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1123
+ ...liveMountVolumes,
1124
+ ...envFile.dockerArgs,
1125
+ ...tzFlags,
1126
+ '-w',
1127
+ '/workspace',
1128
+ effectiveConfig.imageName
1129
+ ]);
1130
+ createdContainerRef = dockerRunId.trim();
1131
+ const containerId = runEngineTaskCommand(engine, 'docker', [
1132
+ 'inspect', '--format', '{{.Id}}', dockerRunId.trim()
1133
+ ]).trim();
1134
+ if (!containerId)
1135
+ throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
1136
+ const rawContainerLabels = runEngineTaskCommand(engine, 'docker', [
1137
+ 'inspect', '--format', '{{json .Config.Labels}}', containerId
1138
+ ]).trim();
1139
+ let inspectedLabels;
1140
+ try {
1141
+ inspectedLabels = JSON.parse(rawContainerLabels);
1142
+ }
1143
+ catch {
1144
+ throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1145
+ }
1146
+ if (!inspectedLabels || typeof inspectedLabels !== 'object' || Array.isArray(inspectedLabels)) {
1147
+ throw new Error('SANDBOX_CONTROL_CONTAINER_LABELS_INVALID');
1148
+ }
1149
+ const expectedLabels = {
1150
+ [sandboxLabel(effectiveConfig)]: '',
1151
+ [sandboxBranchLabel(effectiveConfig)]: branch,
1152
+ [sandboxWorkspaceModeLabel(effectiveConfig)]: target.workspace.mode,
1153
+ [sandboxRuntimeCapabilityLabel(effectiveConfig)]: capabilityPlan.runtimeSignature,
1154
+ ...(target.workspace.mode === 'task-bound'
1155
+ ? { [sandboxTaskIdLabel(effectiveConfig)]: target.workspace.taskId }
1156
+ : {})
1157
+ };
1158
+ const labels = inspectedLabels;
1159
+ for (const [key, expected] of Object.entries(expectedLabels)) {
1160
+ if (labels[key] !== expected)
1161
+ throw new Error('SANDBOX_CONTROL_CONTAINER_IDENTITY_MISMATCH');
1162
+ }
1163
+ finalizeSandboxControlManifest(control, {
1164
+ engine,
1165
+ id: containerId,
1166
+ labels: expectedLabels
1167
+ });
1168
+ replacementLease.clearQuiescing();
1169
+ await startSandboxControlBroker(effectiveConfig.repoRoot, control.manifestPath);
1170
+ if (replacementCutover) {
1171
+ commitSandboxControlReplacement(control.root, replacementLease);
1172
+ replacementCutover = null;
1173
+ }
1174
+ createdTmpfsSeedPlan = tmpfsSeedPlan;
1099
1175
  }
1100
- const expectedLabels = {
1101
- [sandboxLabel(effectiveConfig)]: '',
1102
- [sandboxBranchLabel(effectiveConfig)]: branch,
1103
- [sandboxWorkspaceModeLabel(effectiveConfig)]: target.workspace.mode,
1104
- [sandboxRuntimeCapabilityLabel(effectiveConfig)]: capabilityPlan.runtimeSignature,
1105
- ...(target.workspace.mode === 'task-bound'
1106
- ? { [sandboxTaskIdLabel(effectiveConfig)]: target.workspace.taskId }
1107
- : {})
1108
- };
1109
- const labels = inspectedLabels;
1110
- for (const [key, expected] of Object.entries(expectedLabels)) {
1111
- if (labels[key] !== expected)
1112
- throw new Error('SANDBOX_CONTROL_CONTAINER_IDENTITY_MISMATCH');
1176
+ catch (error) {
1177
+ if (createdContainerRef) {
1178
+ runSafeEngine(engine, 'docker', ['stop', createdContainerRef]);
1179
+ runSafeEngine(engine, 'docker', ['rm', createdContainerRef]);
1180
+ }
1181
+ if (!hadExistingControlRoot)
1182
+ removeDirRecursive(control.root);
1183
+ if (replacementCutover) {
1184
+ try {
1185
+ await recoverSandboxControlReplacement(control.root, replacementLease);
1186
+ replacementCutover = null;
1187
+ }
1188
+ catch {
1189
+ // Preserve the cutover state for the next explicit recreate to recover.
1190
+ }
1191
+ }
1192
+ removeDirRecursive(workspaceView.root);
1193
+ throw error;
1113
1194
  }
1114
- finalizeSandboxControlManifest(control, {
1115
- engine,
1116
- id: containerId,
1117
- labels: expectedLabels
1118
- });
1119
- await startSandboxControlBroker(effectiveConfig.repoRoot, control.manifestPath);
1120
- createdTmpfsSeedPlan = tmpfsSeedPlan;
1121
1195
  }
1122
- catch (error) {
1123
- if (createdContainerRef) {
1124
- runSafeEngine(engine, 'docker', ['stop', createdContainerRef]);
1125
- runSafeEngine(engine, 'docker', ['rm', createdContainerRef]);
1126
- }
1127
- removeDirRecursive(control.root);
1128
- removeDirRecursive(workspaceView.root);
1129
- throw error;
1196
+ finally {
1197
+ envFile.cleanup();
1130
1198
  }
1131
- }
1132
- finally {
1133
- envFile.cleanup();
1134
- }
1135
- prepareTmpfsMounts({
1136
- engine,
1137
- container,
1138
- mountPaths: effectiveResolvedTools
1139
- .filter(({ tool }) => tool.tmpfs)
1140
- .map(({ tool }) => tool.containerMount)
1141
- });
1142
- hydrateTmpfsSeedEntries({
1143
- engine,
1144
- container,
1145
- entries: tmpfsSeedPlan,
1146
- replace: true
1147
- });
1148
- // Belt-and-suspenders: re-create the four shell-config symlinks at
1149
- // runtime so users with a custom `sandbox.dockerfile` (which won't
1150
- // include the ai-tools.dockerfile symlink fragment) still get
1151
- // ~/.gitconfig and friends pointing into the host bind-mount.
1152
- // `ln -sf` is idempotent for the default image.
1153
- ensureShellConfigSymlinks(engine, container);
1154
- if (needsGpg) {
1155
- message(cachedGpg
1156
- ? 'Syncing GPG keys from cache...'
1157
- : hasHostGpgKeys
1158
- ? 'Syncing GPG keys (you may be prompted for your passphrase)...'
1159
- : 'Checking GPG cache before falling back to stripped git config...');
1160
- try {
1161
- if (syncGpgKeys(container, effectiveConfig.home, effectiveConfig.project, undefined, undefined, {
1162
- cachedOverride: cachedGpg,
1163
- repoPath: worktree,
1164
- signingKey,
1165
- dockerExecFn: (cmd, args, opts) => execEngine(engine, cmd, args, opts),
1166
- dockerRunSafeFn: (cmd, args, opts) => runSafeEngine(engine, cmd, args, opts)
1167
- })) {
1168
- writeSanitizedGitconfig({
1169
- home: effectiveConfig.home,
1170
- hostConfigDir: hostShellConfig.hostDir,
1171
- stripGpg: false,
1172
- repoRoot: effectiveConfig.repoRoot
1173
- });
1199
+ prepareTmpfsMounts({
1200
+ engine,
1201
+ container,
1202
+ mountPaths: effectiveResolvedTools
1203
+ .filter(({ tool }) => tool.tmpfs)
1204
+ .map(({ tool }) => tool.containerMount)
1205
+ });
1206
+ hydrateTmpfsSeedEntries({
1207
+ engine,
1208
+ container,
1209
+ entries: tmpfsSeedPlan,
1210
+ replace: true
1211
+ });
1212
+ // Belt-and-suspenders: re-create the four shell-config symlinks at
1213
+ // runtime so users with a custom `sandbox.dockerfile` (which won't
1214
+ // include the ai-tools.dockerfile symlink fragment) still get
1215
+ // ~/.gitconfig and friends pointing into the host bind-mount.
1216
+ // `ln -sf` is idempotent for the default image.
1217
+ ensureShellConfigSymlinks(engine, container);
1218
+ if (needsGpg) {
1219
+ message(cachedGpg
1220
+ ? 'Syncing GPG keys from cache...'
1221
+ : hasHostGpgKeys
1222
+ ? 'Syncing GPG keys (you may be prompted for your passphrase)...'
1223
+ : 'Checking GPG cache before falling back to stripped git config...');
1224
+ try {
1225
+ if (syncGpgKeys(container, effectiveConfig.home, effectiveConfig.project, undefined, undefined, {
1226
+ cachedOverride: cachedGpg,
1227
+ repoPath: worktree,
1228
+ signingKey,
1229
+ dockerExecFn: (cmd, args, opts) => execEngine(engine, cmd, args, opts),
1230
+ dockerRunSafeFn: (cmd, args, opts) => runSafeEngine(engine, cmd, args, opts)
1231
+ })) {
1232
+ writeSanitizedGitconfig({
1233
+ home: effectiveConfig.home,
1234
+ hostConfigDir: hostShellConfig.hostDir,
1235
+ stripGpg: false,
1236
+ repoRoot: effectiveConfig.repoRoot
1237
+ });
1238
+ }
1239
+ else {
1240
+ message(hasHostGpgKeys
1241
+ ? 'GPG key sync failed; using stripped git config fallback...'
1242
+ : 'Host GPG keys unavailable; using stripped git config fallback...');
1243
+ }
1174
1244
  }
1175
- else {
1245
+ catch {
1176
1246
  message(hasHostGpgKeys
1177
1247
  ? 'GPG key sync failed; using stripped git config fallback...'
1178
1248
  : 'Host GPG keys unavailable; using stripped git config fallback...');
1179
1249
  }
1180
1250
  }
1181
- catch {
1182
- message(hasHostGpgKeys
1183
- ? 'GPG key sync failed; using stripped git config fallback...'
1184
- : 'Host GPG keys unavailable; using stripped git config fallback...');
1251
+ for (const { tool } of effectiveResolvedTools) {
1252
+ for (const command of tool.postSetupCmds ?? []) {
1253
+ runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
1254
+ }
1185
1255
  }
1186
- }
1187
- for (const { tool } of effectiveResolvedTools) {
1188
- for (const command of tool.postSetupCmds ?? []) {
1189
- runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
1256
+ const afterStartResults = await runSandboxHooks({
1257
+ hooks: capabilityPlan.hooksByPhase['after-container-start'],
1258
+ phase: 'after-container-start',
1259
+ context: { config: effectiveConfig, plan: capabilityPlan },
1260
+ runCommand: runBoundedSandboxHookCommand
1261
+ });
1262
+ const afterStartFailure = afterStartResults.find((result) => result.status === 'fatal');
1263
+ if (afterStartFailure) {
1264
+ throw new Error(afterStartFailure.message
1265
+ ?? `Sandbox hook '${afterStartFailure.hookId}' failed.`);
1190
1266
  }
1267
+ replacementLease.release();
1268
+ replacementLeaseHeld = false;
1269
+ return 'Container started';
1191
1270
  }
1192
- const afterStartResults = await runSandboxHooks({
1193
- hooks: capabilityPlan.hooksByPhase['after-container-start'],
1194
- phase: 'after-container-start',
1195
- context: { config: effectiveConfig, plan: capabilityPlan },
1196
- runCommand: runBoundedSandboxHookCommand
1197
- });
1198
- const afterStartFailure = afterStartResults.find((result) => result.status === 'fatal');
1199
- if (afterStartFailure) {
1200
- throw new Error(afterStartFailure.message
1201
- ?? `Sandbox hook '${afterStartFailure.hookId}' failed.`);
1271
+ catch (error) {
1272
+ if (replacementLeaseHeld) {
1273
+ try {
1274
+ replacementLease.release();
1275
+ }
1276
+ catch {
1277
+ // Preserve the original cutover failure and its evidence.
1278
+ }
1279
+ }
1280
+ throw error;
1202
1281
  }
1203
- return 'Container started';
1204
1282
  }
1205
1283
  }
1206
1284
  ]);