@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
@@ -16,6 +16,7 @@ import {
16
16
  parsePositiveIntegerOption,
17
17
  sandboxBranchLabel,
18
18
  sandboxImageConfigLabel,
19
+ sandboxImageRefreshLabel,
19
20
  sandboxLabel,
20
21
  shareBranchDir,
21
22
  shareCommonDir,
@@ -37,17 +38,13 @@ import {
37
38
  } from '../shell.ts';
38
39
  import { resolveTaskBranch } from '../task-resolver.ts';
39
40
  import {
40
- imageSignatureFields,
41
41
  resolveTools,
42
- toolConfigDirCandidates,
43
- toolNpmPackagesArg,
44
- toolShellInstallScriptBase64
42
+ toolConfigDirCandidates
45
43
  } from '../tools.ts';
46
44
  import type { SandboxTool } from '../tools.ts';
47
45
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
48
46
  import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.ts';
49
47
  import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
50
- import { resolveBuildUid } from '../engines/native.ts';
51
48
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
52
49
  import { ensureSandboxDiscoveryReadmes } from '../readme-scaffold.ts';
53
50
  import {
@@ -56,6 +53,14 @@ import {
56
53
  validateClaudeCredentialsEnvOverride
57
54
  } from '../credentials.ts';
58
55
  import { detectHostTimezone } from '../host-timezone.ts';
56
+ import {
57
+ buildImageSignature,
58
+ buildSandboxImageArgs,
59
+ isRefreshDisabled,
60
+ isRefreshDue,
61
+ parseImageLabels,
62
+ parseRefreshTimestamp
63
+ } from '../image-build.ts';
59
64
 
60
65
  const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
61
66
  const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
@@ -82,7 +87,7 @@ alias gy='gemini --yolo; tput ed'
82
87
  `;
83
88
  const CONTAINER_HOME = '/home/devuser';
84
89
  const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
85
- const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>]
90
+ const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh]
86
91
 
87
92
  Host aliases:
88
93
  ${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
@@ -115,16 +120,6 @@ type HostShellConfig = {
115
120
  mounts: Array<{ hostPath: string; containerPath: string }>;
116
121
  };
117
122
 
118
- function buildSignature(preparedDockerfile: PreparedDockerfile, tools: SandboxTool[]): string {
119
- return createHash('sha256')
120
- .update(JSON.stringify({
121
- dockerfile: preparedDockerfile.signature,
122
- tools: imageSignatureFields(tools)
123
- }))
124
- .digest('hex')
125
- .slice(0, 12);
126
- }
127
-
128
123
  function resolveToolDirs(config: Pick<SandboxCreateConfig, 'project'>, tools: SandboxTool[], branch: string): ResolvedTool[] {
129
124
  return tools.map((tool) => {
130
125
  const candidates = toolConfigDirCandidates(tool, config.project, branch);
@@ -915,6 +910,42 @@ function resolveHostCatalogPath(value: unknown, hostHomeDir: string): string | n
915
910
  }
916
911
  }
917
912
 
913
+ const CODEX_DISABLED_FEATURE_FLAGS = ['apps', 'enable_mcp_apps'] as const;
914
+
915
+ function removeCodexMcpServers(sandboxParsed: JsonObject): boolean {
916
+ if (!Object.hasOwn(sandboxParsed, 'mcp_servers')) {
917
+ return false;
918
+ }
919
+ delete sandboxParsed.mcp_servers;
920
+ return true;
921
+ }
922
+
923
+ function inheritDisabledCodexFeatureFlags(sandboxParsed: JsonObject, hostParsed: JsonObject): boolean {
924
+ if (!isJsonObjectRecord(hostParsed.features)) {
925
+ return false;
926
+ }
927
+ if (Object.hasOwn(sandboxParsed, 'features') && !isJsonObjectRecord(sandboxParsed.features)) {
928
+ return false;
929
+ }
930
+
931
+ let sandboxFeatures = sandboxParsed.features as JsonObject | undefined;
932
+ let changed = false;
933
+ for (const key of CODEX_DISABLED_FEATURE_FLAGS) {
934
+ if (hostParsed.features[key] !== false) {
935
+ continue;
936
+ }
937
+ if (!sandboxFeatures) {
938
+ sandboxFeatures = {};
939
+ sandboxParsed.features = sandboxFeatures;
940
+ }
941
+ if (sandboxFeatures[key] !== false) {
942
+ sandboxFeatures[key] = false;
943
+ changed = true;
944
+ }
945
+ }
946
+ return changed;
947
+ }
948
+
918
949
  export function ensureCodexModelInheritance(
919
950
  toolDir: string,
920
951
  hostHomeDir?: string,
@@ -947,13 +978,15 @@ export function ensureCodexModelInheritance(
947
978
  }
948
979
  }
949
980
 
981
+ let changed = removeCodexMcpServers(sandboxParsed);
982
+ changed = inheritDisabledCodexFeatureFlags(sandboxParsed, hostParsed) || changed;
983
+
950
984
  const inheritSpecs: Array<readonly [string, 'string' | 'number']> = [
951
985
  ['model', 'string'],
952
986
  ['model_reasoning_effort', 'string'],
953
987
  ['model_auto_compact_token_limit', 'number']
954
988
  ];
955
989
 
956
- let changed = false;
957
990
  for (const [key, type] of inheritSpecs) {
958
991
  if (Object.hasOwn(sandboxParsed, key)) {
959
992
  continue;
@@ -1153,43 +1186,43 @@ export function buildImage(
1153
1186
  runFn = runEngine,
1154
1187
  runSafeFn = runSafeEngine,
1155
1188
  runVerboseFn = runVerboseEngine,
1156
- env = process.env
1189
+ env = process.env,
1190
+ refresh = false,
1191
+ lastRefresh
1157
1192
  }: {
1158
1193
  engine?: string;
1159
1194
  runFn?: EngineRunFn;
1160
1195
  runSafeFn?: EngineRunSafeFn;
1161
1196
  runVerboseFn?: EngineRunVerboseFn;
1162
1197
  env?: NodeJS.ProcessEnv;
1198
+ refresh?: boolean;
1199
+ lastRefresh?: number;
1163
1200
  } = {}
1164
1201
  ): void {
1165
1202
  const selectedEngine = engine ?? detectEngine({ engine: config.engine });
1166
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
1167
- engine: selectedEngine,
1168
- runFn,
1169
- runSafeFn,
1170
- env
1171
- });
1203
+ runVerboseFn(
1204
+ selectedEngine,
1205
+ 'docker',
1206
+ buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
1207
+ engine: selectedEngine,
1208
+ runFn,
1209
+ runSafeFn,
1210
+ env,
1211
+ refresh,
1212
+ lastRefresh
1213
+ }),
1214
+ { cwd: config.repoRoot }
1215
+ );
1216
+ }
1172
1217
 
1173
- runVerboseFn(selectedEngine, 'docker', [
1174
- 'build',
1175
- '-t',
1176
- config.imageName,
1177
- '--build-arg',
1178
- `HOST_UID=${hostUid}`,
1179
- '--build-arg',
1180
- `HOST_GID=${hostGid}`,
1181
- '--build-arg',
1182
- `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
1183
- '--build-arg',
1184
- `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
1185
- '--label',
1186
- sandboxLabel(config),
1187
- '--label',
1188
- `${sandboxImageConfigLabel(config)}=${imageSignature}`,
1189
- '-f',
1190
- toEnginePath(selectedEngine, dockerfilePath),
1191
- toEnginePath(selectedEngine, config.repoRoot)
1192
- ], { cwd: config.repoRoot });
1218
+ function readImageLabels(config: Pick<SandboxCreateConfig, 'imageName'> & Pick<SandboxCreateConfig, 'project'>, engine: string): Record<string, string> {
1219
+ return parseImageLabels(runSafeEngine(engine, 'docker', [
1220
+ 'image',
1221
+ 'inspect',
1222
+ '--format',
1223
+ '{{ json .Config.Labels }}',
1224
+ config.imageName
1225
+ ]));
1193
1226
  }
1194
1227
 
1195
1228
  export async function create(args: string[]): Promise<void> {
@@ -1200,6 +1233,7 @@ export async function create(args: string[]): Promise<void> {
1200
1233
  options: {
1201
1234
  cpu: { type: 'string' },
1202
1235
  memory: { type: 'string' },
1236
+ 'no-refresh': { type: 'boolean' },
1203
1237
  help: { type: 'boolean', short: 'h' }
1204
1238
  }
1205
1239
  });
@@ -1249,7 +1283,7 @@ export async function create(args: string[]): Promise<void> {
1249
1283
  const shareBranch = shareBranchDir(effectiveConfig, branch);
1250
1284
  const preparedDockerfile = prepareDockerfile(effectiveConfig);
1251
1285
  const baseBranch = base ?? runSafe('git', ['-C', effectiveConfig.repoRoot, 'branch', '--show-current']);
1252
- const expectedImageSignature = buildSignature(preparedDockerfile, tools);
1286
+ const expectedImageSignature = buildImageSignature(preparedDockerfile, tools);
1253
1287
  const engine = detectEngine(effectiveConfig);
1254
1288
 
1255
1289
  p.intro(pc.cyan('AI Sandbox'));
@@ -1272,27 +1306,53 @@ export async function create(args: string[]): Promise<void> {
1272
1306
  p.log.success('Docker is ready');
1273
1307
 
1274
1308
  const imageExists = runOkEngine(engine, 'docker', ['image', 'inspect', effectiveConfig.imageName]);
1275
- const currentImageSignature = imageExists
1276
- ? runSafeEngine(engine, 'docker', [
1277
- 'image',
1278
- 'inspect',
1279
- '--format',
1280
- `{{ index .Config.Labels "${sandboxImageConfigLabel(effectiveConfig)}" }}`,
1281
- effectiveConfig.imageName
1282
- ])
1283
- : '';
1284
- const needsImageBuild = !imageExists || currentImageSignature !== expectedImageSignature;
1309
+ const imageLabels = imageExists ? readImageLabels(effectiveConfig, engine) : {};
1310
+ const currentImageSignature = imageLabels[sandboxImageConfigLabel(effectiveConfig)] ?? '';
1311
+ const currentLastRefresh = parseRefreshTimestamp(imageLabels[sandboxImageRefreshLabel(effectiveConfig)] ?? '');
1312
+ const signatureStale = !imageExists || currentImageSignature !== expectedImageSignature;
1313
+ const now = Date.now();
1314
+ const refreshDue = imageExists
1315
+ && !signatureStale
1316
+ && !isRefreshDisabled(process.env, values['no-refresh'] ?? false)
1317
+ && isRefreshDue(currentLastRefresh, now, effectiveConfig.refreshIntervalDays);
1318
+ const needsImageBuild = signatureStale || refreshDue;
1285
1319
 
1286
1320
  if (needsImageBuild) {
1287
- p.log.step(imageExists ? 'Rebuilding stale image...' : 'Building image for first use...');
1288
- buildImage(
1289
- effectiveConfig,
1290
- tools,
1291
- preparedDockerfile.path,
1292
- expectedImageSignature,
1293
- { engine }
1321
+ const buildRefresh = !imageExists || refreshDue;
1322
+ const buildLastRefresh = buildRefresh ? now : currentLastRefresh || 0;
1323
+
1324
+ p.log.step(
1325
+ refreshDue
1326
+ ? 'Refreshing stale image...'
1327
+ : imageExists
1328
+ ? 'Rebuilding stale image...'
1329
+ : 'Building image for first use...'
1294
1330
  );
1295
- p.log.success(imageExists ? 'Image rebuilt' : 'Image built');
1331
+ try {
1332
+ buildImage(
1333
+ effectiveConfig,
1334
+ tools,
1335
+ preparedDockerfile.path,
1336
+ expectedImageSignature,
1337
+ { engine, refresh: buildRefresh, lastRefresh: buildLastRefresh }
1338
+ );
1339
+ p.log.success(
1340
+ refreshDue
1341
+ ? 'Image refreshed'
1342
+ : imageExists
1343
+ ? 'Image rebuilt'
1344
+ : 'Image built'
1345
+ );
1346
+ } catch (error) {
1347
+ if (refreshDue && !signatureStale && imageExists) {
1348
+ p.log.warn(
1349
+ 'Scheduled sandbox image refresh failed; continuing with the existing image. ' +
1350
+ commandErrorMessage(error)
1351
+ );
1352
+ } else {
1353
+ throw error;
1354
+ }
1355
+ }
1296
1356
  } else {
1297
1357
  p.log.step(`Using existing image ${effectiveConfig.imageName}`);
1298
1358
  }
@@ -1541,8 +1601,6 @@ export async function create(args: string[]): Promise<void> {
1541
1601
  path.join(effectiveConfig.repoRoot, '.git'),
1542
1602
  `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`
1543
1603
  ),
1544
- '-v',
1545
- volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
1546
1604
  ...dotfilesMount,
1547
1605
  ...toolVolumes,
1548
1606
  ...tmpfsArgs,
@@ -1,40 +1,27 @@
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
4
  import { loadConfig } from '../config.ts';
6
5
  import type { SandboxConfig } from '../config.ts';
7
6
  import { prepareDockerfile } from '../dockerfile.ts';
8
- import { sandboxImageConfigLabel, sandboxLabel } from '../constants.ts';
7
+ import { sandboxImageRefreshLabel } from '../constants.ts';
9
8
  import { detectEngine, ensureDocker } from '../engine.ts';
10
- import { runEngine, runSafeEngine, runVerboseEngine } from '../shell.ts';
9
+ import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from '../shell.ts';
11
10
  import { pruneSandboxDanglingImages } from '../image-prune.ts';
12
- import {
13
- imageSignatureFields,
14
- resolveTools,
15
- toolNpmPackagesArg,
16
- toolShellInstallScriptBase64
17
- } from '../tools.ts';
11
+ import { resolveTools } from '../tools.ts';
18
12
  import type { SandboxTool } from '../tools.ts';
19
- import { toEnginePath } from '../engines/wsl2-paths.ts';
20
- import { resolveBuildUid } from '../engines/native.ts';
13
+ import {
14
+ buildImageSignature,
15
+ buildSandboxImageArgs,
16
+ parseImageLabels,
17
+ parseRefreshTimestamp
18
+ } from '../image-build.ts';
21
19
 
22
20
  const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
23
21
 
24
- type PreparedDockerfile = ReturnType<typeof prepareDockerfile>;
25
22
  type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => string;
26
23
  type EngineRunSafeFn = EngineRunFn;
27
24
 
28
- function buildSignature(preparedDockerfile: PreparedDockerfile, tools: SandboxTool[]): string {
29
- return createHash('sha256')
30
- .update(JSON.stringify({
31
- dockerfile: preparedDockerfile.signature,
32
- tools: imageSignatureFields(tools)
33
- }))
34
- .digest('hex')
35
- .slice(0, 12);
36
- }
37
-
38
25
  export function buildArgs(
39
26
  config: SandboxConfig,
40
27
  tools: SandboxTool[],
@@ -45,49 +32,41 @@ export function buildArgs(
45
32
  runFn = runEngine,
46
33
  runSafeFn = runSafeEngine,
47
34
  env = process.env,
48
- refresh = false
35
+ refresh = false,
36
+ lastRefresh
49
37
  }: {
50
38
  engine?: string;
51
39
  runFn?: EngineRunFn;
52
40
  runSafeFn?: EngineRunSafeFn;
53
41
  env?: NodeJS.ProcessEnv;
54
42
  refresh?: boolean;
43
+ lastRefresh?: number;
55
44
  } = {}
56
45
  ): string[] {
57
- const selectedEngine = engine ?? detectEngine(config);
58
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
59
- engine: selectedEngine,
46
+ return buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
47
+ engine: engine ?? detectEngine(config),
60
48
  runFn,
61
49
  runSafeFn,
62
- env
50
+ env,
51
+ refresh,
52
+ lastRefresh
63
53
  });
54
+ }
64
55
 
65
- const args = [
66
- 'build',
67
- '-t',
68
- config.imageName,
69
- '--build-arg',
70
- `HOST_UID=${hostUid}`,
71
- '--build-arg',
72
- `HOST_GID=${hostGid}`,
73
- '--build-arg',
74
- `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
75
- '--build-arg',
76
- `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
77
- '--label',
78
- sandboxLabel(config),
79
- '--label',
80
- `${sandboxImageConfigLabel(config)}=${imageSignature}`,
81
- '-f',
82
- toEnginePath(selectedEngine, dockerfilePath),
83
- toEnginePath(selectedEngine, config.repoRoot)
84
- ];
85
-
86
- if (refresh) {
87
- args.splice(1, 0, '--no-cache', '--pull');
56
+ function readExistingLastRefresh(config: SandboxConfig, engine: string): number {
57
+ if (!runOkEngine(engine, 'docker', ['image', 'inspect', config.imageName])) {
58
+ return 0;
88
59
  }
89
60
 
90
- return args;
61
+ const labels = parseImageLabels(runSafeEngine(engine, 'docker', [
62
+ 'image',
63
+ 'inspect',
64
+ '--format',
65
+ '{{ json .Config.Labels }}',
66
+ config.imageName
67
+ ]));
68
+
69
+ return parseRefreshTimestamp(labels[sandboxImageRefreshLabel(config)] ?? '');
91
70
  }
92
71
 
93
72
  export async function rebuild(args: string[]): Promise<void> {
@@ -110,19 +89,24 @@ export async function rebuild(args: string[]): Promise<void> {
110
89
  const config = loadConfig();
111
90
  const tools = resolveTools(config);
112
91
  const preparedDockerfile = prepareDockerfile(config);
113
- const imageSignature = buildSignature(preparedDockerfile, tools);
92
+ const imageSignature = buildImageSignature(preparedDockerfile, tools);
114
93
  const quiet = values.quiet ?? false;
115
94
  const refresh = values.refresh ?? false;
116
95
  const engine = detectEngine(config);
117
96
 
118
97
  await ensureDocker(config, undefined);
98
+ const lastRefresh = refresh ? Date.now() : readExistingLastRefresh(config, engine);
119
99
  p.intro(pc.cyan('Rebuilding sandbox image'));
120
100
 
121
101
  try {
122
102
  if (quiet) {
123
103
  const spinner = p.spinner();
124
104
  spinner.start('Building image...');
125
- runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
105
+ runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
106
+ engine,
107
+ refresh,
108
+ lastRefresh
109
+ }), {
126
110
  cwd: config.repoRoot
127
111
  });
128
112
  spinner.stop(pc.green('Sandbox image rebuilt'));
@@ -131,7 +115,11 @@ export async function rebuild(args: string[]): Promise<void> {
131
115
  runVerboseEngine(
132
116
  engine,
133
117
  'docker',
134
- buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }),
118
+ buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
119
+ engine,
120
+ refresh,
121
+ lastRefresh
122
+ }),
135
123
  { cwd: config.repoRoot }
136
124
  );
137
125
  p.log.success(pc.green('Sandbox image rebuilt'));
@@ -12,7 +12,8 @@ import type { SandboxTool } from './tools.ts';
12
12
  const DEFAULTS = Object.freeze({
13
13
  engine: null,
14
14
  runtimes: ['node22'],
15
- tools: ['claude-code', 'codex', 'gemini-cli', 'opencode'],
15
+ tools: ['agent-infra', 'claude-code', 'codex', 'gemini-cli', 'opencode'],
16
+ refreshIntervalDays: 7,
16
17
  dockerfile: null,
17
18
  vm: {
18
19
  cpu: null,
@@ -29,6 +30,7 @@ type SandboxConfigInput = {
29
30
  runtimes?: string[];
30
31
  tools?: string[];
31
32
  customTools?: unknown;
33
+ refreshIntervalDays?: unknown;
32
34
  dockerfile?: string | null;
33
35
  vm?: Record<string, unknown>;
34
36
  };
@@ -55,6 +57,7 @@ export type SandboxConfig = {
55
57
  runtimes: string[];
56
58
  tools: string[];
57
59
  customTools: SandboxTool[];
60
+ refreshIntervalDays: number;
58
61
  dockerfile: string | null;
59
62
  vm: SandboxVmConfig;
60
63
  };
@@ -80,11 +83,16 @@ function asPositiveNumberOrNull(value: unknown): number | null {
80
83
  return typeof value === 'number' ? value : null;
81
84
  }
82
85
 
83
- function cloneDefaults(): SandboxConfigInput & { vm: SandboxVmConfig; runtimes: string[]; tools: string[] } {
86
+ function asNonNegativeIntegerOrDefault(value: unknown, fallback: number): number {
87
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
88
+ }
89
+
90
+ function cloneDefaults(): SandboxConfigInput & { vm: SandboxVmConfig; runtimes: string[]; tools: string[]; refreshIntervalDays: number } {
84
91
  return {
85
92
  engine: DEFAULTS.engine,
86
93
  runtimes: [...DEFAULTS.runtimes],
87
94
  tools: [...DEFAULTS.tools],
95
+ refreshIntervalDays: DEFAULTS.refreshIntervalDays,
88
96
  dockerfile: DEFAULTS.dockerfile,
89
97
  vm: { ...DEFAULTS.vm }
90
98
  };
@@ -160,6 +168,10 @@ export function loadConfig({
160
168
  ? [...sandbox.tools]
161
169
  : defaults.tools,
162
170
  customTools,
171
+ refreshIntervalDays: asNonNegativeIntegerOrDefault(
172
+ sandbox.refreshIntervalDays,
173
+ defaults.refreshIntervalDays
174
+ ),
163
175
  dockerfile,
164
176
  vm: {
165
177
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
@@ -107,6 +107,10 @@ export function sandboxImageConfigLabel(config: Pick<SandboxPathConfig, 'project
107
107
  return `${sandboxLabel(config)}.image-config`;
108
108
  }
109
109
 
110
+ export function sandboxImageRefreshLabel(config: Pick<SandboxPathConfig, 'project'>): string {
111
+ return `${sandboxLabel(config)}.last-refresh`;
112
+ }
113
+
110
114
  export function parsePositiveIntegerOption(value: unknown, optionName: string): number | undefined {
111
115
  if (value === undefined || value === null) {
112
116
  return undefined;
@@ -0,0 +1,134 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { detectEngine } from './engine.ts';
3
+ import { resolveBuildUid } from './engines/native.ts';
4
+ import { toEnginePath } from './engines/wsl2-paths.ts';
5
+ import { sandboxImageConfigLabel, sandboxImageRefreshLabel, sandboxLabel } from './constants.ts';
6
+ import { runEngine, runSafeEngine } from './shell.ts';
7
+ import {
8
+ imageSignatureFields,
9
+ toolNpmPackagesArg,
10
+ toolShellInstallScriptBase64
11
+ } from './tools.ts';
12
+ import type { SandboxTool } from './tools.ts';
13
+
14
+ type PreparedDockerfileSignature = {
15
+ signature: unknown;
16
+ };
17
+ type BuildImageConfig = {
18
+ project: string;
19
+ imageName: string;
20
+ repoRoot: string;
21
+ engine?: string | null;
22
+ };
23
+ type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => string;
24
+ type EngineRunSafeFn = EngineRunFn;
25
+
26
+ export function buildImageSignature(preparedDockerfile: PreparedDockerfileSignature, tools: SandboxTool[]): string {
27
+ return createHash('sha256')
28
+ .update(JSON.stringify({
29
+ dockerfile: preparedDockerfile.signature,
30
+ tools: imageSignatureFields(tools)
31
+ }))
32
+ .digest('hex')
33
+ .slice(0, 12);
34
+ }
35
+
36
+ export function buildSandboxImageArgs(
37
+ config: BuildImageConfig,
38
+ tools: SandboxTool[],
39
+ dockerfilePath: string,
40
+ imageSignature: string,
41
+ {
42
+ engine,
43
+ runFn = runEngine,
44
+ runSafeFn = runSafeEngine,
45
+ env = process.env,
46
+ refresh = false,
47
+ lastRefresh
48
+ }: {
49
+ engine?: string;
50
+ runFn?: EngineRunFn;
51
+ runSafeFn?: EngineRunSafeFn;
52
+ env?: NodeJS.ProcessEnv;
53
+ refresh?: boolean;
54
+ lastRefresh?: number;
55
+ } = {}
56
+ ): string[] {
57
+ const selectedEngine = engine ?? detectEngine({ engine: config.engine });
58
+ const { uid: hostUid, gid: hostGid } = resolveBuildUid({
59
+ engine: selectedEngine,
60
+ runFn,
61
+ runSafeFn,
62
+ env
63
+ });
64
+
65
+ const args = [
66
+ 'build',
67
+ '-t',
68
+ config.imageName,
69
+ '--build-arg',
70
+ `HOST_UID=${hostUid}`,
71
+ '--build-arg',
72
+ `HOST_GID=${hostGid}`,
73
+ '--build-arg',
74
+ `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
75
+ '--build-arg',
76
+ `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
77
+ '--label',
78
+ sandboxLabel(config),
79
+ '--label',
80
+ `${sandboxImageConfigLabel(config)}=${imageSignature}`
81
+ ];
82
+
83
+ if (lastRefresh !== undefined) {
84
+ args.push('--label', `${sandboxImageRefreshLabel(config)}=${lastRefresh}`);
85
+ }
86
+
87
+ args.push(
88
+ '-f',
89
+ toEnginePath(selectedEngine, dockerfilePath),
90
+ toEnginePath(selectedEngine, config.repoRoot)
91
+ );
92
+
93
+ if (refresh) {
94
+ args.splice(1, 0, '--no-cache', '--pull');
95
+ }
96
+
97
+ return args;
98
+ }
99
+
100
+ export function parseRefreshTimestamp(value: string): number {
101
+ const parsed = Number(value);
102
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
103
+ }
104
+
105
+ export function parseImageLabels(raw: string): Record<string, string> {
106
+ try {
107
+ const parsed = JSON.parse(raw) as unknown;
108
+ if (!parsed || typeof parsed !== 'object') {
109
+ return {};
110
+ }
111
+ return Object.fromEntries(
112
+ Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
113
+ );
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
119
+ export function isRefreshDisabled(env: NodeJS.ProcessEnv, noRefreshFlag: boolean): boolean {
120
+ if (noRefreshFlag) {
121
+ return true;
122
+ }
123
+
124
+ const value = env.AI_SANDBOX_NO_REFRESH?.trim().toLowerCase();
125
+ return value === '1' || value === 'true' || value === 'yes';
126
+ }
127
+
128
+ export function isRefreshDue(lastRefresh: number, now: number, intervalDays: number): boolean {
129
+ if (intervalDays <= 0 || lastRefresh > now) {
130
+ return false;
131
+ }
132
+
133
+ return now - lastRefresh >= intervalDays * 24 * 60 * 60 * 1000;
134
+ }
@@ -1,3 +1,3 @@
1
- RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
1
+ RUN bash -o pipefail -c 'curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors https://deb.nodesource.com/setup_20.x | bash -' && \
2
2
  apt-get install -y nodejs && \
3
3
  rm -rf /var/lib/apt/lists/*
@@ -1,3 +1,3 @@
1
- RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
1
+ RUN bash -o pipefail -c 'curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors https://deb.nodesource.com/setup_22.x | bash -' && \
2
2
  apt-get install -y nodejs && \
3
3
  rm -rf /var/lib/apt/lists/*
@@ -124,6 +124,15 @@ function createBuiltinTools(home: string, project: string): Record<string, Sandb
124
124
  { hostPath: hostJoin(home, '.gemini', 'settings.json'), sandboxName: 'settings.json' },
125
125
  { hostPath: hostJoin(home, '.gemini', 'google_accounts.json'), sandboxName: 'google_accounts.json' }
126
126
  ]
127
+ },
128
+ 'agent-infra': {
129
+ id: 'agent-infra',
130
+ name: 'agent-infra CLI',
131
+ install: { type: 'npm', cmd: '@fitlab-ai/agent-infra@latest' },
132
+ sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'agent-infra'),
133
+ containerMount: '/home/devuser/.agent-infra-cli',
134
+ versionCmd: 'ai version --raw',
135
+ setupHint: 'Provides the ai and agent-infra CLI commands inside the sandbox.'
127
136
  }
128
137
  };
129
138
  }