@fitlab-ai/agent-infra 0.8.2 → 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 (119) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/dist/lib/cp.js +1 -1
  3. package/dist/lib/decide.js +2 -2
  4. package/dist/lib/defaults.json +1 -0
  5. package/dist/lib/init.js +7 -7
  6. package/dist/lib/merge.js +1 -1
  7. package/dist/lib/prompt.js +1 -1
  8. package/dist/lib/run/index.js +7 -7
  9. package/dist/lib/run/prompt.js +1 -1
  10. package/dist/lib/sandbox/capture.js +5 -5
  11. package/dist/lib/sandbox/clipboard/bridge.js +48 -13
  12. package/dist/lib/sandbox/clipboard/index.js +5 -6
  13. package/dist/lib/sandbox/clipboard/linux.js +91 -0
  14. package/dist/lib/sandbox/clipboard/paths.js +1 -1
  15. package/dist/lib/sandbox/clipboard/win32.js +144 -0
  16. package/dist/lib/sandbox/commands/create.js +67 -65
  17. package/dist/lib/sandbox/commands/enter.js +9 -9
  18. package/dist/lib/sandbox/commands/list-running.js +2 -2
  19. package/dist/lib/sandbox/commands/ls.js +7 -7
  20. package/dist/lib/sandbox/commands/prune.js +8 -8
  21. package/dist/lib/sandbox/commands/rebuild.js +38 -50
  22. package/dist/lib/sandbox/commands/refresh.js +2 -2
  23. package/dist/lib/sandbox/commands/rm.js +11 -11
  24. package/dist/lib/sandbox/commands/show.js +4 -4
  25. package/dist/lib/sandbox/commands/start.js +4 -4
  26. package/dist/lib/sandbox/commands/vm.js +4 -4
  27. package/dist/lib/sandbox/config.js +10 -4
  28. package/dist/lib/sandbox/constants.js +4 -1
  29. package/dist/lib/sandbox/credentials.js +1 -1
  30. package/dist/lib/sandbox/dotfiles.js +1 -1
  31. package/dist/lib/sandbox/engine.js +3 -3
  32. package/dist/lib/sandbox/engines/index.js +5 -5
  33. package/dist/lib/sandbox/engines/wsl2-paths.js +1 -1
  34. package/dist/lib/sandbox/image-build.js +80 -0
  35. package/dist/lib/sandbox/image-prune.js +2 -2
  36. package/dist/lib/sandbox/index.js +10 -10
  37. package/dist/lib/sandbox/managed-fs.js +1 -1
  38. package/dist/lib/sandbox/readme-scaffold.js +1 -1
  39. package/dist/lib/sandbox/tools.js +2 -2
  40. package/dist/lib/server/adapters/feishu/index.js +10 -3
  41. package/dist/lib/server/adapters/feishu/renderer.js +88 -0
  42. package/dist/lib/server/adapters/feishu/transport.js +2 -13
  43. package/dist/lib/server/daemon.js +64 -43
  44. package/dist/lib/server/display.js +83 -0
  45. package/dist/lib/server/index.js +2 -2
  46. package/dist/lib/server/process-control.js +2 -2
  47. package/dist/lib/server/protocol.js +2 -2
  48. package/dist/lib/server/streamer.js +5 -4
  49. package/dist/lib/task/commands/cat.js +2 -2
  50. package/dist/lib/task/commands/decisions.js +4 -4
  51. package/dist/lib/task/commands/files.js +3 -3
  52. package/dist/lib/task/commands/grep.js +3 -3
  53. package/dist/lib/task/commands/issue-body.js +4 -4
  54. package/dist/lib/task/commands/log.js +5 -5
  55. package/dist/lib/task/commands/ls.js +3 -3
  56. package/dist/lib/task/commands/show.js +1 -1
  57. package/dist/lib/task/commands/status.js +71 -25
  58. package/dist/lib/task/index.js +9 -9
  59. package/dist/lib/task/resolve-ref.js +1 -1
  60. package/dist/lib/task/workflow-warnings.js +94 -0
  61. package/dist/lib/update.js +4 -4
  62. package/lib/defaults.json +1 -0
  63. package/lib/sandbox/clipboard/bridge.ts +50 -8
  64. package/lib/sandbox/clipboard/index.ts +5 -6
  65. package/lib/sandbox/clipboard/linux.ts +124 -0
  66. package/lib/sandbox/clipboard/win32.ts +173 -0
  67. package/lib/sandbox/commands/create.ts +84 -64
  68. package/lib/sandbox/commands/rebuild.ts +42 -54
  69. package/lib/sandbox/config.ts +13 -1
  70. package/lib/sandbox/constants.ts +4 -0
  71. package/lib/sandbox/image-build.ts +134 -0
  72. package/lib/server/adapters/_contract.ts +3 -0
  73. package/lib/server/adapters/feishu/index.ts +11 -3
  74. package/lib/server/adapters/feishu/renderer.ts +99 -0
  75. package/lib/server/adapters/feishu/transport.ts +5 -18
  76. package/lib/server/daemon.ts +74 -38
  77. package/lib/server/display.ts +136 -0
  78. package/lib/server/streamer.ts +5 -4
  79. package/lib/task/commands/log.ts +3 -3
  80. package/lib/task/commands/status.ts +102 -21
  81. package/lib/task/workflow-warnings.ts +121 -0
  82. package/package.json +2 -2
  83. package/templates/.agents/rules/create-issue.github.en.md +3 -3
  84. package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
  85. package/templates/.agents/rules/issue-pr-commands.github.en.md +7 -2
  86. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +7 -2
  87. package/templates/.agents/rules/issue-sync.github.en.md +13 -0
  88. package/templates/.agents/rules/issue-sync.github.zh-CN.md +13 -0
  89. package/templates/.agents/rules/next-step-output.en.md +15 -1
  90. package/templates/.agents/rules/next-step-output.zh-CN.md +15 -1
  91. package/templates/.agents/rules/pr-sync.github.en.md +17 -2
  92. package/templates/.agents/rules/pr-sync.github.zh-CN.md +17 -2
  93. package/templates/.agents/rules/task-management.en.md +1 -1
  94. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  95. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +57 -0
  96. package/templates/.agents/scripts/validate-artifact.js +120 -0
  97. package/templates/.agents/scripts/workflow-warnings.js +290 -0
  98. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +118 -0
  99. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +118 -0
  100. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +49 -0
  101. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +49 -0
  102. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +48 -0
  103. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +48 -0
  104. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +60 -0
  105. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +87 -0
  106. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  107. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -0
  108. package/templates/.agents/skills/create-task/SKILL.en.md +4 -1
  109. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -1
  110. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +7 -0
  111. package/templates/.agents/templates/task.en.md +7 -0
  112. package/templates/.agents/templates/task.zh-CN.md +7 -0
  113. package/templates/.claude/commands/complete-manual-validation.en.md +9 -0
  114. package/templates/.claude/commands/complete-manual-validation.zh-CN.md +9 -0
  115. package/templates/.gemini/commands/_project_/complete-manual-validation.en.toml +8 -0
  116. package/templates/.gemini/commands/_project_/complete-manual-validation.zh-CN.toml +8 -0
  117. package/templates/.github/workflows/metadata-sync.yml +5 -0
  118. package/templates/.opencode/commands/complete-manual-validation.en.md +11 -0
  119. package/templates/.opencode/commands/complete-manual-validation.zh-CN.md +11 -0
@@ -0,0 +1,173 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import type { ExecFileSyncOptions } from 'node:child_process';
6
+
7
+ const PROBE_TIMEOUT_MS = 2_000;
8
+ const READ_IMAGE_TIMEOUT_MS = 5_000;
9
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10
+ const POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-STA', '-Command'];
11
+
12
+ type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Buffer | string;
13
+ type MkdtempFn = (prefix: string) => string;
14
+ type ReadFileFn = (filePath: fs.PathOrFileDescriptor) => Buffer | string;
15
+ type RmFn = (filePath: fs.PathLike, options: { recursive?: boolean; force?: boolean }) => void;
16
+ type ExistsFn = (filePath: fs.PathLike) => boolean;
17
+
18
+ export type Win32ClipboardAdapter = {
19
+ available(): { ok: true } | { ok: false; reason: string };
20
+ readImagePng(): Buffer | null;
21
+ readImageFromPath(imagePath: string): Buffer | null;
22
+ readImageFromText(text: string): Buffer | null;
23
+ };
24
+
25
+ export function createWin32ClipboardAdapter({
26
+ execFn = execFileSync,
27
+ mkdtempFn = fs.mkdtempSync,
28
+ readFileFn = fs.readFileSync,
29
+ rmFn = fs.rmSync,
30
+ existsFn = fs.existsSync
31
+ }: {
32
+ execFn?: ExecFn;
33
+ mkdtempFn?: MkdtempFn;
34
+ readFileFn?: ReadFileFn;
35
+ rmFn?: RmFn;
36
+ existsFn?: ExistsFn;
37
+ } = {}): Win32ClipboardAdapter {
38
+ return {
39
+ available() {
40
+ try {
41
+ execFn('powershell.exe', [...POWERSHELL_ARGS, clipboardProbeScript()], {
42
+ encoding: 'utf8',
43
+ timeout: PROBE_TIMEOUT_MS
44
+ });
45
+ return { ok: true };
46
+ } catch {
47
+ return {
48
+ ok: false,
49
+ reason: 'Windows PowerShell Get-Clipboard is unavailable; install or enable powershell.exe to use image paste'
50
+ };
51
+ }
52
+ },
53
+ readImagePng() {
54
+ const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
55
+ const outputPath = path.join(tmpDir, 'clipboard.png');
56
+ try {
57
+ execFn('powershell.exe', [...POWERSHELL_ARGS, pngWriteScript(outputPath)], {
58
+ encoding: 'utf8',
59
+ timeout: READ_IMAGE_TIMEOUT_MS
60
+ });
61
+ const png = Buffer.from(readFileFn(outputPath));
62
+ return isPng(png) ? png : null;
63
+ } catch {
64
+ return null;
65
+ } finally {
66
+ rmFn(tmpDir, { recursive: true, force: true });
67
+ }
68
+ },
69
+ readImageFromPath(imagePath) {
70
+ const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
71
+ const outputPath = path.join(tmpDir, 'clipboard.png');
72
+ try {
73
+ execFn('powershell.exe', [...POWERSHELL_ARGS, pngFromPathScript(imagePath, outputPath)], {
74
+ encoding: 'utf8',
75
+ timeout: READ_IMAGE_TIMEOUT_MS
76
+ });
77
+ const png = Buffer.from(readFileFn(outputPath));
78
+ return isPng(png) ? png : null;
79
+ } catch {
80
+ return null;
81
+ } finally {
82
+ rmFn(tmpDir, { recursive: true, force: true });
83
+ }
84
+ },
85
+ readImageFromText(text) {
86
+ const trimmed = text.trim();
87
+ if (!isWindowsImagePath(trimmed)) {
88
+ return null;
89
+ }
90
+ if (!existsFn(trimmed)) {
91
+ return null;
92
+ }
93
+ return this.readImageFromPath(trimmed);
94
+ }
95
+ };
96
+ }
97
+
98
+ function clipboardProbeScript(): string {
99
+ return 'if ($null -eq (Get-Command Get-Clipboard -ErrorAction SilentlyContinue)) { exit 1 }';
100
+ }
101
+
102
+ // Embed a filesystem path in a PowerShell single-quoted string literal.
103
+ // Single-quoted strings in PowerShell are verbatim (no variable expansion,
104
+ // no escape sequences). The only special character is a single quote itself,
105
+ // which is escaped by doubling it.
106
+ function psLiteral(value: string): string {
107
+ return `'${value.replace(/'/g, "''")}'`;
108
+ }
109
+
110
+ function pngWriteScript(outputPath: string): string {
111
+ const psOutputPath = psLiteral(outputPath);
112
+ return [
113
+ '$ErrorActionPreference = "Stop"',
114
+ `$outputPath = ${psOutputPath}`,
115
+ '$image = $null',
116
+ 'try {',
117
+ ' Add-Type -AssemblyName System.Drawing',
118
+ ' $image = Get-Clipboard -Format Image -ErrorAction SilentlyContinue',
119
+ ' if ($null -ne $image) {',
120
+ ' $image.Save($outputPath, [System.Drawing.Imaging.ImageFormat]::Png)',
121
+ ' return',
122
+ ' }',
123
+ ' $files = Get-Clipboard -Format FileDropList -ErrorAction SilentlyContinue',
124
+ ' if ($null -ne $files) {',
125
+ ' $allowed = @(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".ico")',
126
+ ' foreach ($file in $files) {',
127
+ ' if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { continue }',
128
+ ' $ext = [System.IO.Path]::GetExtension($file)',
129
+ ' if ($allowed -notcontains $ext) { continue }',
130
+ ' try {',
131
+ ' $image = [System.Drawing.Image]::FromFile($file)',
132
+ ' $image.Save($outputPath, [System.Drawing.Imaging.ImageFormat]::Png)',
133
+ ' return',
134
+ ' } catch {}',
135
+ ' }',
136
+ ' }',
137
+ ' throw "clipboard has no image"',
138
+ '} finally {',
139
+ ' if ($null -ne $image -and $image -is [System.IDisposable]) { $image.Dispose() }',
140
+ '}'
141
+ ].join('\n');
142
+ }
143
+
144
+ function pngFromPathScript(imagePath: string, outputPath: string): string {
145
+ const psImagePath = psLiteral(imagePath);
146
+ const psOutputPath = psLiteral(outputPath);
147
+ return [
148
+ '$ErrorActionPreference = "Stop"',
149
+ `$imagePath = ${psImagePath}`,
150
+ `$outputPath = ${psOutputPath}`,
151
+ '$image = $null',
152
+ 'try {',
153
+ ' Add-Type -AssemblyName System.Drawing',
154
+ ' if (-not (Test-Path -LiteralPath $imagePath -PathType Leaf)) { throw "file not found: $imagePath" }',
155
+ ' $image = [System.Drawing.Image]::FromFile($imagePath)',
156
+ ' $image.Save($outputPath, [System.Drawing.Imaging.ImageFormat]::Png)',
157
+ '} finally {',
158
+ ' if ($null -ne $image -and $image -is [System.IDisposable]) { $image.Dispose() }',
159
+ '}'
160
+ ].join('\n');
161
+ }
162
+
163
+ const IMAGE_EXTENSIONS_RE = /\.(png|jpg|jpeg|gif|bmp|webp|tiff?|ico)$/i;
164
+
165
+ function isWindowsImagePath(text: string): boolean {
166
+ // Must be a Windows absolute path: drive letter + colon + backslash/slash
167
+ // followed by a path ending with a known image extension.
168
+ return /^[A-Za-z]:[\\/].+$/.test(text) && IMAGE_EXTENSIONS_RE.test(text);
169
+ }
170
+
171
+ function isPng(buffer: Buffer): boolean {
172
+ return buffer.length >= PNG_MAGIC.length && PNG_MAGIC.every((byte, index) => buffer[index] === byte);
173
+ }
@@ -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);
@@ -1191,43 +1186,43 @@ export function buildImage(
1191
1186
  runFn = runEngine,
1192
1187
  runSafeFn = runSafeEngine,
1193
1188
  runVerboseFn = runVerboseEngine,
1194
- env = process.env
1189
+ env = process.env,
1190
+ refresh = false,
1191
+ lastRefresh
1195
1192
  }: {
1196
1193
  engine?: string;
1197
1194
  runFn?: EngineRunFn;
1198
1195
  runSafeFn?: EngineRunSafeFn;
1199
1196
  runVerboseFn?: EngineRunVerboseFn;
1200
1197
  env?: NodeJS.ProcessEnv;
1198
+ refresh?: boolean;
1199
+ lastRefresh?: number;
1201
1200
  } = {}
1202
1201
  ): void {
1203
1202
  const selectedEngine = engine ?? detectEngine({ engine: config.engine });
1204
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
1205
- engine: selectedEngine,
1206
- runFn,
1207
- runSafeFn,
1208
- env
1209
- });
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
+ }
1210
1217
 
1211
- runVerboseFn(selectedEngine, 'docker', [
1212
- 'build',
1213
- '-t',
1214
- config.imageName,
1215
- '--build-arg',
1216
- `HOST_UID=${hostUid}`,
1217
- '--build-arg',
1218
- `HOST_GID=${hostGid}`,
1219
- '--build-arg',
1220
- `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
1221
- '--build-arg',
1222
- `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
1223
- '--label',
1224
- sandboxLabel(config),
1225
- '--label',
1226
- `${sandboxImageConfigLabel(config)}=${imageSignature}`,
1227
- '-f',
1228
- toEnginePath(selectedEngine, dockerfilePath),
1229
- toEnginePath(selectedEngine, config.repoRoot)
1230
- ], { 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
+ ]));
1231
1226
  }
1232
1227
 
1233
1228
  export async function create(args: string[]): Promise<void> {
@@ -1238,6 +1233,7 @@ export async function create(args: string[]): Promise<void> {
1238
1233
  options: {
1239
1234
  cpu: { type: 'string' },
1240
1235
  memory: { type: 'string' },
1236
+ 'no-refresh': { type: 'boolean' },
1241
1237
  help: { type: 'boolean', short: 'h' }
1242
1238
  }
1243
1239
  });
@@ -1287,7 +1283,7 @@ export async function create(args: string[]): Promise<void> {
1287
1283
  const shareBranch = shareBranchDir(effectiveConfig, branch);
1288
1284
  const preparedDockerfile = prepareDockerfile(effectiveConfig);
1289
1285
  const baseBranch = base ?? runSafe('git', ['-C', effectiveConfig.repoRoot, 'branch', '--show-current']);
1290
- const expectedImageSignature = buildSignature(preparedDockerfile, tools);
1286
+ const expectedImageSignature = buildImageSignature(preparedDockerfile, tools);
1291
1287
  const engine = detectEngine(effectiveConfig);
1292
1288
 
1293
1289
  p.intro(pc.cyan('AI Sandbox'));
@@ -1310,27 +1306,53 @@ export async function create(args: string[]): Promise<void> {
1310
1306
  p.log.success('Docker is ready');
1311
1307
 
1312
1308
  const imageExists = runOkEngine(engine, 'docker', ['image', 'inspect', effectiveConfig.imageName]);
1313
- const currentImageSignature = imageExists
1314
- ? runSafeEngine(engine, 'docker', [
1315
- 'image',
1316
- 'inspect',
1317
- '--format',
1318
- `{{ index .Config.Labels "${sandboxImageConfigLabel(effectiveConfig)}" }}`,
1319
- effectiveConfig.imageName
1320
- ])
1321
- : '';
1322
- 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;
1323
1319
 
1324
1320
  if (needsImageBuild) {
1325
- p.log.step(imageExists ? 'Rebuilding stale image...' : 'Building image for first use...');
1326
- buildImage(
1327
- effectiveConfig,
1328
- tools,
1329
- preparedDockerfile.path,
1330
- expectedImageSignature,
1331
- { 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...'
1332
1330
  );
1333
- 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
+ }
1334
1356
  } else {
1335
1357
  p.log.step(`Using existing image ${effectiveConfig.imageName}`);
1336
1358
  }
@@ -1579,8 +1601,6 @@ export async function create(args: string[]): Promise<void> {
1579
1601
  path.join(effectiveConfig.repoRoot, '.git'),
1580
1602
  `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`
1581
1603
  ),
1582
- '-v',
1583
- volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
1584
1604
  ...dotfilesMount,
1585
1605
  ...toolVolumes,
1586
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'));
@@ -13,6 +13,7 @@ const DEFAULTS = Object.freeze({
13
13
  engine: null,
14
14
  runtimes: ['node22'],
15
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;