@fitlab-ai/agent-infra 0.8.1 → 0.8.3

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 (131) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/bin/cli.ts +3 -3
  4. package/dist/bin/cli.js +4 -4
  5. package/dist/lib/cp.js +1 -1
  6. package/dist/lib/decide.js +2 -2
  7. package/dist/lib/defaults.json +2 -0
  8. package/dist/lib/init.js +7 -7
  9. package/dist/lib/merge.js +1 -1
  10. package/dist/lib/prompt.js +1 -1
  11. package/dist/lib/run/index.js +7 -7
  12. package/dist/lib/run/prompt.js +1 -1
  13. package/dist/lib/sandbox/capture.js +5 -5
  14. package/dist/lib/sandbox/clipboard/bridge.js +48 -13
  15. package/dist/lib/sandbox/clipboard/index.js +5 -6
  16. package/dist/lib/sandbox/clipboard/linux.js +91 -0
  17. package/dist/lib/sandbox/clipboard/paths.js +1 -1
  18. package/dist/lib/sandbox/clipboard/win32.js +144 -0
  19. package/dist/lib/sandbox/commands/create.js +101 -66
  20. package/dist/lib/sandbox/commands/enter.js +9 -9
  21. package/dist/lib/sandbox/commands/list-running.js +2 -2
  22. package/dist/lib/sandbox/commands/ls.js +7 -7
  23. package/dist/lib/sandbox/commands/prune.js +8 -8
  24. package/dist/lib/sandbox/commands/rebuild.js +38 -50
  25. package/dist/lib/sandbox/commands/refresh.js +2 -2
  26. package/dist/lib/sandbox/commands/rm.js +11 -11
  27. package/dist/lib/sandbox/commands/show.js +4 -4
  28. package/dist/lib/sandbox/commands/start.js +4 -4
  29. package/dist/lib/sandbox/commands/vm.js +4 -4
  30. package/dist/lib/sandbox/config.js +11 -5
  31. package/dist/lib/sandbox/constants.js +4 -1
  32. package/dist/lib/sandbox/credentials.js +1 -1
  33. package/dist/lib/sandbox/dotfiles.js +1 -1
  34. package/dist/lib/sandbox/engine.js +3 -3
  35. package/dist/lib/sandbox/engines/index.js +5 -5
  36. package/dist/lib/sandbox/engines/wsl2-paths.js +1 -1
  37. package/dist/lib/sandbox/image-build.js +80 -0
  38. package/dist/lib/sandbox/image-prune.js +2 -2
  39. package/dist/lib/sandbox/index.js +10 -10
  40. package/dist/lib/sandbox/managed-fs.js +1 -1
  41. package/dist/lib/sandbox/readme-scaffold.js +1 -1
  42. package/dist/lib/sandbox/runtimes/node20.dockerfile +1 -1
  43. package/dist/lib/sandbox/runtimes/node22.dockerfile +1 -1
  44. package/dist/lib/sandbox/tools.js +11 -2
  45. package/dist/lib/server/adapters/feishu/index.js +12 -3
  46. package/dist/lib/server/adapters/feishu/renderer.js +88 -0
  47. package/dist/lib/server/adapters/feishu/transport.js +9 -2
  48. package/dist/lib/server/daemon.js +64 -43
  49. package/dist/lib/server/display.js +83 -0
  50. package/dist/lib/server/index.js +2 -2
  51. package/dist/lib/server/process-control.js +2 -2
  52. package/dist/lib/server/protocol.js +22 -6
  53. package/dist/lib/server/streamer.js +5 -4
  54. package/dist/lib/task/commands/cat.js +2 -2
  55. package/dist/lib/task/commands/decisions.js +4 -4
  56. package/dist/lib/task/commands/files.js +3 -3
  57. package/dist/lib/task/commands/grep.js +3 -3
  58. package/dist/lib/task/commands/issue-body.js +4 -4
  59. package/dist/lib/task/commands/log.js +5 -5
  60. package/dist/lib/task/commands/ls.js +3 -3
  61. package/dist/lib/task/commands/show.js +1 -1
  62. package/dist/lib/task/commands/status.js +71 -25
  63. package/dist/lib/task/index.js +9 -9
  64. package/dist/lib/task/resolve-ref.js +1 -1
  65. package/dist/lib/task/workflow-warnings.js +94 -0
  66. package/dist/lib/update.js +35 -4
  67. package/lib/defaults.json +2 -0
  68. package/lib/sandbox/clipboard/bridge.ts +50 -8
  69. package/lib/sandbox/clipboard/index.ts +5 -6
  70. package/lib/sandbox/clipboard/linux.ts +124 -0
  71. package/lib/sandbox/clipboard/win32.ts +173 -0
  72. package/lib/sandbox/commands/create.ts +123 -65
  73. package/lib/sandbox/commands/rebuild.ts +42 -54
  74. package/lib/sandbox/config.ts +14 -2
  75. package/lib/sandbox/constants.ts +4 -0
  76. package/lib/sandbox/image-build.ts +134 -0
  77. package/lib/sandbox/runtimes/node20.dockerfile +1 -1
  78. package/lib/sandbox/runtimes/node22.dockerfile +1 -1
  79. package/lib/sandbox/tools.ts +9 -0
  80. package/lib/server/adapters/_contract.ts +3 -0
  81. package/lib/server/adapters/feishu/index.ts +12 -2
  82. package/lib/server/adapters/feishu/renderer.ts +99 -0
  83. package/lib/server/adapters/feishu/transport.ts +19 -3
  84. package/lib/server/daemon.ts +74 -38
  85. package/lib/server/display.ts +136 -0
  86. package/lib/server/protocol.ts +20 -4
  87. package/lib/server/streamer.ts +5 -4
  88. package/lib/task/commands/log.ts +3 -3
  89. package/lib/task/commands/status.ts +102 -21
  90. package/lib/task/workflow-warnings.ts +121 -0
  91. package/lib/update.ts +34 -0
  92. package/package.json +3 -3
  93. package/templates/.agents/README.en.md +3 -3
  94. package/templates/.agents/README.zh-CN.md +3 -3
  95. package/templates/.agents/rules/create-issue.github.en.md +3 -3
  96. package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
  97. package/templates/.agents/rules/issue-pr-commands.github.en.md +7 -2
  98. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +7 -2
  99. package/templates/.agents/rules/issue-sync.github.en.md +13 -0
  100. package/templates/.agents/rules/issue-sync.github.zh-CN.md +13 -0
  101. package/templates/.agents/rules/next-step-output.en.md +15 -1
  102. package/templates/.agents/rules/next-step-output.zh-CN.md +15 -1
  103. package/templates/.agents/rules/pr-sync.github.en.md +17 -2
  104. package/templates/.agents/rules/pr-sync.github.zh-CN.md +17 -2
  105. package/templates/.agents/rules/task-management.en.md +1 -1
  106. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  107. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +57 -0
  108. package/templates/.agents/scripts/validate-artifact.js +120 -0
  109. package/templates/.agents/scripts/workflow-warnings.js +290 -0
  110. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +118 -0
  111. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +118 -0
  112. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +49 -0
  113. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +49 -0
  114. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +48 -0
  115. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +48 -0
  116. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +60 -0
  117. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +87 -0
  118. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  119. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -0
  120. package/templates/.agents/skills/create-task/SKILL.en.md +4 -1
  121. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -1
  122. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +33 -1
  123. package/templates/.agents/templates/task.en.md +7 -0
  124. package/templates/.agents/templates/task.zh-CN.md +7 -0
  125. package/templates/.claude/commands/complete-manual-validation.en.md +9 -0
  126. package/templates/.claude/commands/complete-manual-validation.zh-CN.md +9 -0
  127. package/templates/.gemini/commands/_project_/complete-manual-validation.en.toml +8 -0
  128. package/templates/.gemini/commands/_project_/complete-manual-validation.zh-CN.toml +8 -0
  129. package/templates/.github/workflows/metadata-sync.yml +5 -0
  130. package/templates/.opencode/commands/complete-manual-validation.en.md +11 -0
  131. package/templates/.opencode/commands/complete-manual-validation.zh-CN.md +11 -0
@@ -7,21 +7,21 @@ import { parseArgs } from 'node:util';
7
7
  import * as p from '@clack/prompts';
8
8
  import pc from 'picocolors';
9
9
  import * as toml from 'smol-toml';
10
- import { loadConfig } from "../config.js";
11
- import { assertValidBranchName, containerName, containerNameCandidates, parsePositiveIntegerOption, sandboxBranchLabel, sandboxImageConfigLabel, sandboxLabel, shareBranchDir, shareCommonDir, shellConfigDir, worktreeDirCandidates } from "../constants.js";
12
- import { prepareDockerfile } from "../dockerfile.js";
13
- import { detectEngine, ensureDocker } from "../engine.js";
14
- import { commandForEngine, execEngine, run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine, runVerboseEngine } from "../shell.js";
15
- import { resolveTaskBranch } from "../task-resolver.js";
16
- import { imageSignatureFields, resolveTools, toolConfigDirCandidates, toolNpmPackagesArg, toolShellInstallScriptBase64 } from "../tools.js";
17
- import { hostJoin, toEnginePath, volumeArg } from "../engines/wsl2-paths.js";
18
- import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from "../clipboard/paths.js";
19
- import { validateSelinuxDisableEnv } from "../engines/selinux.js";
20
- import { resolveBuildUid } from "../engines/native.js";
21
- import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
22
- import { ensureSandboxDiscoveryReadmes } from "../readme-scaffold.js";
23
- import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
24
- import { detectHostTimezone } from "../host-timezone.js";
10
+ import { loadConfig } from '../config.js';
11
+ import { assertValidBranchName, containerName, containerNameCandidates, parsePositiveIntegerOption, sandboxBranchLabel, sandboxImageConfigLabel, sandboxImageRefreshLabel, sandboxLabel, shareBranchDir, shareCommonDir, shellConfigDir, worktreeDirCandidates } from '../constants.js';
12
+ import { prepareDockerfile } from '../dockerfile.js';
13
+ import { detectEngine, ensureDocker } from '../engine.js';
14
+ import { commandForEngine, execEngine, run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine, runVerboseEngine } from '../shell.js';
15
+ import { resolveTaskBranch } from '../task-resolver.js';
16
+ import { resolveTools, toolConfigDirCandidates } from '../tools.js';
17
+ import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
18
+ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.js';
19
+ import { validateSelinuxDisableEnv } from '../engines/selinux.js';
20
+ import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
21
+ import { ensureSandboxDiscoveryReadmes } from '../readme-scaffold.js';
22
+ import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from '../credentials.js';
23
+ import { detectHostTimezone } from '../host-timezone.js';
24
+ import { buildImageSignature, buildSandboxImageArgs, isRefreshDisabled, isRefreshDue, parseImageLabels, parseRefreshTimestamp } from '../image-build.js';
25
25
  const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
26
26
  const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
27
27
  const SANDBOX_ALIAS_BLOCK_END = '# <<< agent-infra managed aliases <<<';
@@ -47,22 +47,13 @@ alias gy='gemini --yolo; tput ed'
47
47
  `;
48
48
  const CONTAINER_HOME = '/home/devuser';
49
49
  const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
50
- const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>]
50
+ const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh]
51
51
 
52
52
  Host aliases:
53
53
  ${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
54
54
  as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
55
55
  shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
56
56
  symlinked into $HOME).`;
57
- function buildSignature(preparedDockerfile, tools) {
58
- return createHash('sha256')
59
- .update(JSON.stringify({
60
- dockerfile: preparedDockerfile.signature,
61
- tools: imageSignatureFields(tools)
62
- }))
63
- .digest('hex')
64
- .slice(0, 12);
65
- }
66
57
  function resolveToolDirs(config, tools, branch) {
67
58
  return tools.map((tool) => {
68
59
  const candidates = toolConfigDirCandidates(tool, config.project, branch);
@@ -678,6 +669,38 @@ function resolveHostCatalogPath(value, hostHomeDir) {
678
669
  return null;
679
670
  }
680
671
  }
672
+ const CODEX_DISABLED_FEATURE_FLAGS = ['apps', 'enable_mcp_apps'];
673
+ function removeCodexMcpServers(sandboxParsed) {
674
+ if (!Object.hasOwn(sandboxParsed, 'mcp_servers')) {
675
+ return false;
676
+ }
677
+ delete sandboxParsed.mcp_servers;
678
+ return true;
679
+ }
680
+ function inheritDisabledCodexFeatureFlags(sandboxParsed, hostParsed) {
681
+ if (!isJsonObjectRecord(hostParsed.features)) {
682
+ return false;
683
+ }
684
+ if (Object.hasOwn(sandboxParsed, 'features') && !isJsonObjectRecord(sandboxParsed.features)) {
685
+ return false;
686
+ }
687
+ let sandboxFeatures = sandboxParsed.features;
688
+ let changed = false;
689
+ for (const key of CODEX_DISABLED_FEATURE_FLAGS) {
690
+ if (hostParsed.features[key] !== false) {
691
+ continue;
692
+ }
693
+ if (!sandboxFeatures) {
694
+ sandboxFeatures = {};
695
+ sandboxParsed.features = sandboxFeatures;
696
+ }
697
+ if (sandboxFeatures[key] !== false) {
698
+ sandboxFeatures[key] = false;
699
+ changed = true;
700
+ }
701
+ }
702
+ return changed;
703
+ }
681
704
  export function ensureCodexModelInheritance(toolDir, hostHomeDir, containerCodexDir = '/home/devuser/.codex') {
682
705
  if (!hostHomeDir) {
683
706
  return;
@@ -704,12 +727,13 @@ export function ensureCodexModelInheritance(toolDir, hostHomeDir, containerCodex
704
727
  return;
705
728
  }
706
729
  }
730
+ let changed = removeCodexMcpServers(sandboxParsed);
731
+ changed = inheritDisabledCodexFeatureFlags(sandboxParsed, hostParsed) || changed;
707
732
  const inheritSpecs = [
708
733
  ['model', 'string'],
709
734
  ['model_reasoning_effort', 'string'],
710
735
  ['model_auto_compact_token_limit', 'number']
711
736
  ];
712
- let changed = false;
713
737
  for (const [key, type] of inheritSpecs) {
714
738
  if (Object.hasOwn(sandboxParsed, key)) {
715
739
  continue;
@@ -875,34 +899,25 @@ export function buildTmpfsRunArgs(containerMount, tmpfs) {
875
899
  const size = tmpfs.size ?? '512m';
876
900
  return ['--tmpfs', `${containerMount}:rw,size=${size}`];
877
901
  }
878
- export function buildImage(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, runVerboseFn = runVerboseEngine, env = process.env } = {}) {
902
+ export function buildImage(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, runVerboseFn = runVerboseEngine, env = process.env, refresh = false, lastRefresh } = {}) {
879
903
  const selectedEngine = engine ?? detectEngine({ engine: config.engine });
880
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
904
+ runVerboseFn(selectedEngine, 'docker', buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
881
905
  engine: selectedEngine,
882
906
  runFn,
883
907
  runSafeFn,
884
- env
885
- });
886
- runVerboseFn(selectedEngine, 'docker', [
887
- 'build',
888
- '-t',
889
- config.imageName,
890
- '--build-arg',
891
- `HOST_UID=${hostUid}`,
892
- '--build-arg',
893
- `HOST_GID=${hostGid}`,
894
- '--build-arg',
895
- `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
896
- '--build-arg',
897
- `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
898
- '--label',
899
- sandboxLabel(config),
900
- '--label',
901
- `${sandboxImageConfigLabel(config)}=${imageSignature}`,
902
- '-f',
903
- toEnginePath(selectedEngine, dockerfilePath),
904
- toEnginePath(selectedEngine, config.repoRoot)
905
- ], { cwd: config.repoRoot });
908
+ env,
909
+ refresh,
910
+ lastRefresh
911
+ }), { cwd: config.repoRoot });
912
+ }
913
+ function readImageLabels(config, engine) {
914
+ return parseImageLabels(runSafeEngine(engine, 'docker', [
915
+ 'image',
916
+ 'inspect',
917
+ '--format',
918
+ '{{ json .Config.Labels }}',
919
+ config.imageName
920
+ ]));
906
921
  }
907
922
  export async function create(args) {
908
923
  const { values, positionals } = parseArgs({
@@ -912,6 +927,7 @@ export async function create(args) {
912
927
  options: {
913
928
  cpu: { type: 'string' },
914
929
  memory: { type: 'string' },
930
+ 'no-refresh': { type: 'boolean' },
915
931
  help: { type: 'boolean', short: 'h' }
916
932
  }
917
933
  });
@@ -953,7 +969,7 @@ export async function create(args) {
953
969
  const shareBranch = shareBranchDir(effectiveConfig, branch);
954
970
  const preparedDockerfile = prepareDockerfile(effectiveConfig);
955
971
  const baseBranch = base ?? runSafe('git', ['-C', effectiveConfig.repoRoot, 'branch', '--show-current']);
956
- const expectedImageSignature = buildSignature(preparedDockerfile, tools);
972
+ const expectedImageSignature = buildImageSignature(preparedDockerfile, tools);
957
973
  const engine = detectEngine(effectiveConfig);
958
974
  p.intro(pc.cyan('AI Sandbox'));
959
975
  p.log.info(`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`);
@@ -969,20 +985,41 @@ export async function create(args) {
969
985
  });
970
986
  p.log.success('Docker is ready');
971
987
  const imageExists = runOkEngine(engine, 'docker', ['image', 'inspect', effectiveConfig.imageName]);
972
- const currentImageSignature = imageExists
973
- ? runSafeEngine(engine, 'docker', [
974
- 'image',
975
- 'inspect',
976
- '--format',
977
- `{{ index .Config.Labels "${sandboxImageConfigLabel(effectiveConfig)}" }}`,
978
- effectiveConfig.imageName
979
- ])
980
- : '';
981
- const needsImageBuild = !imageExists || currentImageSignature !== expectedImageSignature;
988
+ const imageLabels = imageExists ? readImageLabels(effectiveConfig, engine) : {};
989
+ const currentImageSignature = imageLabels[sandboxImageConfigLabel(effectiveConfig)] ?? '';
990
+ const currentLastRefresh = parseRefreshTimestamp(imageLabels[sandboxImageRefreshLabel(effectiveConfig)] ?? '');
991
+ const signatureStale = !imageExists || currentImageSignature !== expectedImageSignature;
992
+ const now = Date.now();
993
+ const refreshDue = imageExists
994
+ && !signatureStale
995
+ && !isRefreshDisabled(process.env, values['no-refresh'] ?? false)
996
+ && isRefreshDue(currentLastRefresh, now, effectiveConfig.refreshIntervalDays);
997
+ const needsImageBuild = signatureStale || refreshDue;
982
998
  if (needsImageBuild) {
983
- p.log.step(imageExists ? 'Rebuilding stale image...' : 'Building image for first use...');
984
- buildImage(effectiveConfig, tools, preparedDockerfile.path, expectedImageSignature, { engine });
985
- p.log.success(imageExists ? 'Image rebuilt' : 'Image built');
999
+ const buildRefresh = !imageExists || refreshDue;
1000
+ const buildLastRefresh = buildRefresh ? now : currentLastRefresh || 0;
1001
+ p.log.step(refreshDue
1002
+ ? 'Refreshing stale image...'
1003
+ : imageExists
1004
+ ? 'Rebuilding stale image...'
1005
+ : 'Building image for first use...');
1006
+ try {
1007
+ buildImage(effectiveConfig, tools, preparedDockerfile.path, expectedImageSignature, { engine, refresh: buildRefresh, lastRefresh: buildLastRefresh });
1008
+ p.log.success(refreshDue
1009
+ ? 'Image refreshed'
1010
+ : imageExists
1011
+ ? 'Image rebuilt'
1012
+ : 'Image built');
1013
+ }
1014
+ catch (error) {
1015
+ if (refreshDue && !signatureStale && imageExists) {
1016
+ p.log.warn('Scheduled sandbox image refresh failed; continuing with the existing image. ' +
1017
+ commandErrorMessage(error));
1018
+ }
1019
+ else {
1020
+ throw error;
1021
+ }
1022
+ }
986
1023
  }
987
1024
  else {
988
1025
  p.log.step(`Using existing image ${effectiveConfig.imageName}`);
@@ -1198,8 +1235,6 @@ export async function create(args) {
1198
1235
  ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1199
1236
  '-v',
1200
1237
  volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
1201
- '-v',
1202
- volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
1203
1238
  ...dotfilesMount,
1204
1239
  ...toolVolumes,
1205
1240
  ...tmpfsArgs,
@@ -1,12 +1,12 @@
1
- import { loadConfig } from "../config.js";
2
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
3
- import { detectEngine } from "../engine.js";
4
- import { formatCredentialWarnings, formatRemaining, hasClaudeProviderAuth, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
5
- import { runInteractiveEngine } from "../shell.js";
6
- import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
7
- import { runInteractiveWithClipboardBridge } from "../clipboard/bridge.js";
8
- import { detectHostTimezone } from "../host-timezone.js";
9
- import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from "./list-running.js";
1
+ import { loadConfig } from '../config.js';
2
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.js';
3
+ import { detectEngine } from '../engine.js';
4
+ import { formatCredentialWarnings, formatRemaining, hasClaudeProviderAuth, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from '../credentials.js';
5
+ import { runInteractiveEngine } from '../shell.js';
6
+ import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
7
+ import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.js';
8
+ import { detectHostTimezone } from '../host-timezone.js';
9
+ import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from './list-running.js';
10
10
  const USAGE = `Usage: ai sandbox exec <branch | TASK-id | N | '#N'> [cmd...]
11
11
 
12
12
  N (bare) and '#N' both reference the same active task short id from
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { runSafeEngine, runVerboseEngine } from "../shell.js";
5
- import { resolveTaskBranch } from "../task-resolver.js";
4
+ import { runSafeEngine, runVerboseEngine } from '../shell.js';
5
+ import { resolveTaskBranch } from '../task-resolver.js';
6
6
  export function containerListFormat() {
7
7
  return '{{.Names}}\t{{.Status}}\t{{.Labels}}';
8
8
  }
@@ -1,12 +1,12 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import pc from 'picocolors';
3
- import { loadConfig } from "../config.js";
4
- import { sandboxBranchLabel, sandboxLabel } from "../constants.js";
5
- import { detectEngine } from "../engine.js";
6
- import { formatTable } from "../../table.js";
7
- import { lookupShortIdByBranch } from "../../task/short-id.js";
8
- import { fetchSandboxRows } from "./list-running.js";
9
- export { containerListFormat, parseLabels } from "./list-running.js";
3
+ import { loadConfig } from '../config.js';
4
+ import { sandboxBranchLabel, sandboxLabel } from '../constants.js';
5
+ import { detectEngine } from '../engine.js';
6
+ import { formatTable } from '../../table.js';
7
+ import { lookupShortIdByBranch } from '../../task/short-id.js';
8
+ import { fetchSandboxRows } from './list-running.js';
9
+ export { containerListFormat, parseLabels } from './list-running.js';
10
10
  const USAGE = `Usage: ai sandbox ls
11
11
 
12
12
  Lists all containers for the current project. The '#' column is a
@@ -3,14 +3,14 @@ import path from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import * as p from '@clack/prompts';
5
5
  import pc from 'picocolors';
6
- import { loadConfig } from "../config.js";
7
- import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
8
- import { detectEngine } from "../engine.js";
9
- import { hostJoin } from "../engines/wsl2-paths.js";
10
- import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
11
- import { parseLabels } from "./ls.js";
12
- import { runEngine, runSafe } from "../shell.js";
13
- import { resolveTools } from "../tools.js";
6
+ import { loadConfig } from '../config.js';
7
+ import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.js';
8
+ import { detectEngine } from '../engine.js';
9
+ import { hostJoin } from '../engines/wsl2-paths.js';
10
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.js';
11
+ import { parseLabels } from './ls.js';
12
+ import { runEngine, runSafe } from '../shell.js';
13
+ import { resolveTools } from '../tools.js';
14
14
  const USAGE = `Usage: ai sandbox prune [--dry-run]`;
15
15
  function listChildDirs(base) {
16
16
  if (!fs.existsSync(base)) {
@@ -1,58 +1,37 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { createHash } from 'node:crypto';
3
2
  import * as p from '@clack/prompts';
4
3
  import pc from 'picocolors';
5
- import { loadConfig } from "../config.js";
6
- import { prepareDockerfile } from "../dockerfile.js";
7
- import { sandboxImageConfigLabel, sandboxLabel } from "../constants.js";
8
- import { detectEngine, ensureDocker } from "../engine.js";
9
- import { runEngine, runSafeEngine, runVerboseEngine } from "../shell.js";
10
- import { pruneSandboxDanglingImages } from "../image-prune.js";
11
- import { imageSignatureFields, resolveTools, toolNpmPackagesArg, toolShellInstallScriptBase64 } from "../tools.js";
12
- import { toEnginePath } from "../engines/wsl2-paths.js";
13
- import { resolveBuildUid } from "../engines/native.js";
4
+ import { loadConfig } from '../config.js';
5
+ import { prepareDockerfile } from '../dockerfile.js';
6
+ import { sandboxImageRefreshLabel } from '../constants.js';
7
+ import { detectEngine, ensureDocker } from '../engine.js';
8
+ import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from '../shell.js';
9
+ import { pruneSandboxDanglingImages } from '../image-prune.js';
10
+ import { resolveTools } from '../tools.js';
11
+ import { buildImageSignature, buildSandboxImageArgs, parseImageLabels, parseRefreshTimestamp } from '../image-build.js';
14
12
  const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
15
- function buildSignature(preparedDockerfile, tools) {
16
- return createHash('sha256')
17
- .update(JSON.stringify({
18
- dockerfile: preparedDockerfile.signature,
19
- tools: imageSignatureFields(tools)
20
- }))
21
- .digest('hex')
22
- .slice(0, 12);
23
- }
24
- export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env, refresh = false } = {}) {
25
- const selectedEngine = engine ?? detectEngine(config);
26
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
27
- engine: selectedEngine,
13
+ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env, refresh = false, lastRefresh } = {}) {
14
+ return buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
15
+ engine: engine ?? detectEngine(config),
28
16
  runFn,
29
17
  runSafeFn,
30
- env
18
+ env,
19
+ refresh,
20
+ lastRefresh
31
21
  });
32
- const args = [
33
- 'build',
34
- '-t',
35
- config.imageName,
36
- '--build-arg',
37
- `HOST_UID=${hostUid}`,
38
- '--build-arg',
39
- `HOST_GID=${hostGid}`,
40
- '--build-arg',
41
- `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
42
- '--build-arg',
43
- `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
44
- '--label',
45
- sandboxLabel(config),
46
- '--label',
47
- `${sandboxImageConfigLabel(config)}=${imageSignature}`,
48
- '-f',
49
- toEnginePath(selectedEngine, dockerfilePath),
50
- toEnginePath(selectedEngine, config.repoRoot)
51
- ];
52
- if (refresh) {
53
- args.splice(1, 0, '--no-cache', '--pull');
22
+ }
23
+ function readExistingLastRefresh(config, engine) {
24
+ if (!runOkEngine(engine, 'docker', ['image', 'inspect', config.imageName])) {
25
+ return 0;
54
26
  }
55
- return args;
27
+ const labels = parseImageLabels(runSafeEngine(engine, 'docker', [
28
+ 'image',
29
+ 'inspect',
30
+ '--format',
31
+ '{{ json .Config.Labels }}',
32
+ config.imageName
33
+ ]));
34
+ return parseRefreshTimestamp(labels[sandboxImageRefreshLabel(config)] ?? '');
56
35
  }
57
36
  export async function rebuild(args) {
58
37
  const { values } = parseArgs({
@@ -72,24 +51,33 @@ export async function rebuild(args) {
72
51
  const config = loadConfig();
73
52
  const tools = resolveTools(config);
74
53
  const preparedDockerfile = prepareDockerfile(config);
75
- const imageSignature = buildSignature(preparedDockerfile, tools);
54
+ const imageSignature = buildImageSignature(preparedDockerfile, tools);
76
55
  const quiet = values.quiet ?? false;
77
56
  const refresh = values.refresh ?? false;
78
57
  const engine = detectEngine(config);
79
58
  await ensureDocker(config, undefined);
59
+ const lastRefresh = refresh ? Date.now() : readExistingLastRefresh(config, engine);
80
60
  p.intro(pc.cyan('Rebuilding sandbox image'));
81
61
  try {
82
62
  if (quiet) {
83
63
  const spinner = p.spinner();
84
64
  spinner.start('Building image...');
85
- runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
65
+ runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
66
+ engine,
67
+ refresh,
68
+ lastRefresh
69
+ }), {
86
70
  cwd: config.repoRoot
87
71
  });
88
72
  spinner.stop(pc.green('Sandbox image rebuilt'));
89
73
  }
90
74
  else {
91
75
  p.log.step('Building image');
92
- runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), { cwd: config.repoRoot });
76
+ runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
77
+ engine,
78
+ refresh,
79
+ lastRefresh
80
+ }), { cwd: config.repoRoot });
93
81
  p.log.success(pc.green('Sandbox image rebuilt'));
94
82
  }
95
83
  pruneSandboxDanglingImages(config, engine);
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { parseArgs } from 'node:util';
3
- import { buildLockedGuidance, discoverProjects, formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
4
- import { runProbe } from "../shell.js";
3
+ import { buildLockedGuidance, discoverProjects, formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from '../credentials.js';
4
+ import { runProbe } from '../shell.js';
5
5
  const USAGE = 'Usage: ai sandbox refresh';
6
6
  export function probeClaudeStatus(spawnFn = runProbe) {
7
7
  const result = spawnFn('claude', ['/status'], {
@@ -3,21 +3,21 @@ import path from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import * as p from '@clack/prompts';
5
5
  import pc from 'picocolors';
6
- import { loadConfig } from "../config.js";
7
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from "../constants.js";
8
- import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from "../engine.js";
9
- import { pruneSandboxDanglingImages } from "../image-prune.js";
10
- import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
11
- import { runOk, runSafe, runSafeEngine } from "../shell.js";
12
- import { resolveTaskBranch } from "../task-resolver.js";
13
- import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from "../tools.js";
14
- import { fetchSandboxRows } from "./list-running.js";
15
- import { lookupShortIdByBranch } from "../../task/short-id.js";
6
+ import { loadConfig } from '../config.js';
7
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from '../constants.js';
8
+ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.js';
9
+ import { pruneSandboxDanglingImages } from '../image-prune.js';
10
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.js';
11
+ import { runOk, runSafe, runSafeEngine } from '../shell.js';
12
+ import { resolveTaskBranch } from '../task-resolver.js';
13
+ import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.js';
14
+ import { fetchSandboxRows } from './list-running.js';
15
+ import { lookupShortIdByBranch } from '../../task/short-id.js';
16
16
  const USAGE = `Usage:
17
17
  ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
18
18
  ai sandbox rm --all [--dry-run] [--yes] Remove every sandbox not bound to an active task
19
19
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
20
- export { assertManagedPath } from "../managed-fs.js";
20
+ export { assertManagedPath } from '../managed-fs.js';
21
21
  function projectToolDirs(config, tools) {
22
22
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
23
23
  }
@@ -1,10 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import * as p from '@clack/prompts';
3
3
  import pc from 'picocolors';
4
- import { loadConfig } from "../config.js";
5
- import { assertValidBranchName, worktreeDirCandidates } from "../constants.js";
6
- import { resolveBranchArg } from "./list-running.js";
7
- import { resolveTools, toolConfigDirCandidates } from "../tools.js";
4
+ import { loadConfig } from '../config.js';
5
+ import { assertValidBranchName, worktreeDirCandidates } from '../constants.js';
6
+ import { resolveBranchArg } from './list-running.js';
7
+ import { resolveTools, toolConfigDirCandidates } from '../tools.js';
8
8
  const USAGE = `Usage: ai sandbox show <branch | TASK-id | N | '#N'>
9
9
 
10
10
  Shows one sandbox's worktree path and per-tool state paths (Claude Code,
@@ -1,7 +1,7 @@
1
- import { loadConfig } from "../config.js";
2
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
3
- import { detectEngine } from "../engine.js";
4
- import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from "./list-running.js";
1
+ import { loadConfig } from '../config.js';
2
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.js';
3
+ import { detectEngine } from '../engine.js';
4
+ import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from './list-running.js';
5
5
  const USAGE = `Usage: ai sandbox start <branch | TASK-id | N | '#N'>
6
6
 
7
7
  Start an existing sandbox container that has stopped (for example after the
@@ -1,10 +1,10 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import * as p from '@clack/prompts';
3
3
  import pc from 'picocolors';
4
- import { loadConfig } from "../config.js";
5
- import { parsePositiveIntegerOption } from "../constants.js";
6
- import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, startManagedVm, stopManagedVm } from "../engine.js";
7
- import { runOk, runSafe } from "../shell.js";
4
+ import { loadConfig } from '../config.js';
5
+ import { parsePositiveIntegerOption } from '../constants.js';
6
+ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, startManagedVm, stopManagedVm } from '../engine.js';
7
+ import { runOk, runSafe } from '../shell.js';
8
8
  const USAGE = `Usage: ai sandbox vm <status|start|stop> [--cpu <n>] [--memory <n>]`;
9
9
  export function ensureManagedVm(engine) {
10
10
  if (engine === ENGINES.NATIVE) {
@@ -3,14 +3,15 @@ import path from 'node:path';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import pc from 'picocolors';
6
- import { validateSandboxEngine } from "./engine.js";
7
- import { hostJoin } from "./engines/wsl2-paths.js";
8
- import { findRuntimeEngineMismatches } from "./runtime-engines.js";
9
- import { parseCustomTools } from "./tools.js";
6
+ import { validateSandboxEngine } from './engine.js';
7
+ import { hostJoin } from './engines/wsl2-paths.js';
8
+ import { findRuntimeEngineMismatches } from './runtime-engines.js';
9
+ import { parseCustomTools } from './tools.js';
10
10
  const DEFAULTS = Object.freeze({
11
11
  engine: null,
12
12
  runtimes: ['node22'],
13
- tools: ['claude-code', 'codex', 'gemini-cli', 'opencode'],
13
+ tools: ['agent-infra', 'claude-code', 'codex', 'gemini-cli', 'opencode'],
14
+ refreshIntervalDays: 7,
14
15
  dockerfile: null,
15
16
  vm: {
16
17
  cpu: null,
@@ -32,11 +33,15 @@ function detectRepoRoot() {
32
33
  function asPositiveNumberOrNull(value) {
33
34
  return typeof value === 'number' ? value : null;
34
35
  }
36
+ function asNonNegativeIntegerOrDefault(value, fallback) {
37
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
38
+ }
35
39
  function cloneDefaults() {
36
40
  return {
37
41
  engine: DEFAULTS.engine,
38
42
  runtimes: [...DEFAULTS.runtimes],
39
43
  tools: [...DEFAULTS.tools],
44
+ refreshIntervalDays: DEFAULTS.refreshIntervalDays,
40
45
  dockerfile: DEFAULTS.dockerfile,
41
46
  vm: { ...DEFAULTS.vm }
42
47
  };
@@ -96,6 +101,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
96
101
  ? [...sandbox.tools]
97
102
  : defaults.tools,
98
103
  customTools,
104
+ refreshIntervalDays: asNonNegativeIntegerOrDefault(sandbox.refreshIntervalDays, defaults.refreshIntervalDays),
99
105
  dockerfile,
100
106
  vm: {
101
107
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { hostJoin } from "./engines/wsl2-paths.js";
3
+ import { hostJoin } from './engines/wsl2-paths.js';
4
4
  const validatedBranches = new Set();
5
5
  function dedupe(items) {
6
6
  return [...new Set(items)];
@@ -72,6 +72,9 @@ export function sandboxBranchLabel(config) {
72
72
  export function sandboxImageConfigLabel(config) {
73
73
  return `${sandboxLabel(config)}.image-config`;
74
74
  }
75
+ export function sandboxImageRefreshLabel(config) {
76
+ return `${sandboxLabel(config)}.last-refresh`;
77
+ }
75
78
  export function parsePositiveIntegerOption(value, optionName) {
76
79
  if (value === undefined || value === null) {
77
80
  return undefined;
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { hostJoin } from "./engines/wsl2-paths.js";
5
+ import { hostJoin } from './engines/wsl2-paths.js';
6
6
  const LOCKED_PATTERN = /errSecInteractionNotAllowed|User interaction is not allowed/i;
7
7
  const NOT_FOUND_PATTERN = /errSecItemNotFound|specified item could not be found/i;
8
8
  const REDACTION_PATTERNS = [
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { hostJoin } from "./engines/wsl2-paths.js";
3
+ import { hostJoin } from './engines/wsl2-paths.js';
4
4
  export function dotfilesCacheDir(home, project) {
5
5
  return hostJoin(home, '.agent-infra', '.cache', 'dotfiles-resolved', project);
6
6
  }