@fitlab-ai/agent-infra 0.8.2 → 0.8.4

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 (198) hide show
  1. package/README.zh-CN.md +10 -0
  2. package/bin/cli.ts +1 -1
  3. package/dist/bin/cli.js +2 -2
  4. package/dist/lib/cp.js +57 -16
  5. package/dist/lib/decide.js +2 -2
  6. package/dist/lib/defaults.json +2 -0
  7. package/dist/lib/init.js +7 -7
  8. package/dist/lib/merge.js +1 -1
  9. package/dist/lib/prompt.js +1 -1
  10. package/dist/lib/run/index.js +7 -7
  11. package/dist/lib/run/prompt.js +1 -1
  12. package/dist/lib/sandbox/capture.js +10 -8
  13. package/dist/lib/sandbox/clipboard/bridge.js +59 -21
  14. package/dist/lib/sandbox/clipboard/inbox.js +82 -0
  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 +106 -88
  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 +10 -4
  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/base.dockerfile +3 -0
  43. package/dist/lib/sandbox/shell.js +9 -1
  44. package/dist/lib/sandbox/tools.js +4 -4
  45. package/dist/lib/server/adapters/feishu/index.js +10 -3
  46. package/dist/lib/server/adapters/feishu/renderer.js +88 -0
  47. package/dist/lib/server/adapters/feishu/transport.js +2 -13
  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 +2 -2
  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 +4 -4
  67. package/lib/cp.ts +56 -15
  68. package/lib/defaults.json +2 -0
  69. package/lib/sandbox/capture.ts +5 -3
  70. package/lib/sandbox/clipboard/bridge.ts +62 -18
  71. package/lib/sandbox/clipboard/inbox.ts +83 -0
  72. package/lib/sandbox/clipboard/index.ts +5 -6
  73. package/lib/sandbox/clipboard/linux.ts +124 -0
  74. package/lib/sandbox/clipboard/win32.ts +173 -0
  75. package/lib/sandbox/commands/create.ts +132 -88
  76. package/lib/sandbox/commands/rebuild.ts +42 -54
  77. package/lib/sandbox/config.ts +13 -1
  78. package/lib/sandbox/constants.ts +4 -0
  79. package/lib/sandbox/image-build.ts +134 -0
  80. package/lib/sandbox/runtimes/base.dockerfile +3 -0
  81. package/lib/sandbox/shell.ts +11 -2
  82. package/lib/sandbox/tools.ts +5 -5
  83. package/lib/server/adapters/_contract.ts +3 -0
  84. package/lib/server/adapters/feishu/index.ts +11 -3
  85. package/lib/server/adapters/feishu/renderer.ts +99 -0
  86. package/lib/server/adapters/feishu/transport.ts +5 -18
  87. package/lib/server/daemon.ts +74 -38
  88. package/lib/server/display.ts +136 -0
  89. package/lib/server/streamer.ts +5 -4
  90. package/lib/task/commands/log.ts +3 -3
  91. package/lib/task/commands/status.ts +102 -21
  92. package/lib/task/workflow-warnings.ts +121 -0
  93. package/package.json +2 -2
  94. package/templates/.agents/README.en.md +3 -0
  95. package/templates/.agents/README.zh-CN.md +3 -0
  96. package/templates/.agents/rules/create-issue.github.en.md +4 -4
  97. package/templates/.agents/rules/create-issue.github.zh-CN.md +4 -4
  98. package/templates/.agents/rules/issue-pr-commands.github.en.md +7 -2
  99. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +7 -2
  100. package/templates/.agents/rules/issue-sync.github.en.md +13 -0
  101. package/templates/.agents/rules/issue-sync.github.zh-CN.md +13 -0
  102. package/templates/.agents/rules/next-step-output.en.md +15 -1
  103. package/templates/.agents/rules/next-step-output.zh-CN.md +15 -1
  104. package/templates/.agents/rules/pr-sync.github.en.md +17 -2
  105. package/templates/.agents/rules/pr-sync.github.zh-CN.md +17 -2
  106. package/templates/.agents/rules/review-handshake.en.md +2 -2
  107. package/templates/.agents/rules/review-handshake.zh-CN.md +2 -2
  108. package/templates/.agents/rules/task-management.en.md +1 -1
  109. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  110. package/templates/.agents/rules/testing-discipline.en.md +3 -41
  111. package/templates/.agents/rules/testing-discipline.zh-CN.md +3 -41
  112. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +57 -0
  113. package/templates/.agents/scripts/validate-artifact.js +213 -0
  114. package/templates/.agents/scripts/workflow-warnings.js +290 -0
  115. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  116. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  117. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +1 -1
  118. package/templates/.agents/skills/block-task/SKILL.en.md +1 -1
  119. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  120. package/templates/.agents/skills/cancel-task/SKILL.en.md +1 -1
  121. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +2 -2
  122. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +9 -9
  123. package/templates/.agents/skills/close-codescan/SKILL.en.md +1 -1
  124. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
  125. package/templates/.agents/skills/close-dependabot/SKILL.en.md +1 -1
  126. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
  127. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  128. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  129. package/templates/.agents/skills/code-task/reference/branch-management.zh-CN.md +2 -2
  130. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +2 -2
  131. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +2 -2
  132. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +1 -1
  133. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  134. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  135. package/templates/.agents/skills/commit/reference/task-status-update.en.md +6 -6
  136. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +6 -6
  137. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +142 -0
  138. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +142 -0
  139. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +49 -0
  140. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +49 -0
  141. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +48 -0
  142. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +48 -0
  143. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +60 -0
  144. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +87 -0
  145. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -1
  146. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -1
  147. package/templates/.agents/skills/create-pr/SKILL.en.md +6 -4
  148. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +6 -4
  149. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  150. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  151. package/templates/.agents/skills/create-task/SKILL.en.md +6 -3
  152. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +9 -6
  153. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -2
  154. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -3
  155. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -2
  156. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -3
  157. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -2
  158. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +3 -3
  159. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +1 -1
  160. package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -2
  161. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +4 -3
  162. package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +165 -41
  163. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  164. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  165. package/templates/.agents/skills/restore-task/SKILL.en.md +1 -1
  166. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +1 -1
  167. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  168. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  169. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +5 -5
  170. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +5 -5
  171. package/templates/.agents/skills/review-code/SKILL.en.md +8 -4
  172. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +6 -3
  173. package/templates/.agents/skills/review-code/config/verify.en.json +1 -0
  174. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -0
  175. package/templates/.agents/skills/review-code/reference/output-templates.en.md +5 -5
  176. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +5 -5
  177. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -2
  178. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -2
  179. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  180. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  181. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +5 -5
  182. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +5 -5
  183. package/templates/.agents/skills/test/SKILL.zh-CN.md +26 -8
  184. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -1
  185. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +36 -6
  186. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  187. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  188. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +8 -1
  189. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +8 -1
  190. package/templates/.agents/templates/task.en.md +7 -0
  191. package/templates/.agents/templates/task.zh-CN.md +7 -0
  192. package/templates/.claude/commands/complete-manual-validation.en.md +9 -0
  193. package/templates/.claude/commands/complete-manual-validation.zh-CN.md +9 -0
  194. package/templates/.gemini/commands/_project_/complete-manual-validation.en.toml +8 -0
  195. package/templates/.gemini/commands/_project_/complete-manual-validation.zh-CN.toml +8 -0
  196. package/templates/.github/workflows/metadata-sync.yml +5 -0
  197. package/templates/.opencode/commands/complete-manual-validation.en.md +11 -0
  198. package/templates/.opencode/commands/complete-manual-validation.zh-CN.md +11 -0
@@ -0,0 +1,124 @@
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
+
11
+ type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Buffer | string;
12
+ type ExecToFileFn = (cmd: string, args: string[], outputPath: string, timeout: number) => void;
13
+
14
+ type LinuxBackend =
15
+ | { kind: 'wayland'; command: 'wl-paste' }
16
+ | { kind: 'x11'; command: 'xclip' };
17
+
18
+ export type LinuxClipboardAdapter = {
19
+ available(): { ok: true } | { ok: false; reason: string };
20
+ readImagePng(): Buffer | null;
21
+ };
22
+
23
+ export function createLinuxClipboardAdapter({
24
+ env = process.env,
25
+ execFn = execFileSync,
26
+ execToFileFn = execToFile,
27
+ mkdtempFn = fs.mkdtempSync,
28
+ readFileFn = fs.readFileSync,
29
+ rmFn = fs.rmSync
30
+ }: {
31
+ env?: NodeJS.ProcessEnv;
32
+ execFn?: ExecFn;
33
+ execToFileFn?: ExecToFileFn;
34
+ mkdtempFn?: typeof fs.mkdtempSync;
35
+ readFileFn?: typeof fs.readFileSync;
36
+ rmFn?: typeof fs.rmSync;
37
+ } = {}): LinuxClipboardAdapter {
38
+ return {
39
+ available() {
40
+ const backend = selectBackend(env);
41
+ try {
42
+ execFn(backend.command, versionArgs(backend), {
43
+ encoding: 'utf8',
44
+ timeout: PROBE_TIMEOUT_MS
45
+ });
46
+ return { ok: true };
47
+ } catch {
48
+ return { ok: false, reason: unavailableReason(backend) };
49
+ }
50
+ },
51
+ readImagePng() {
52
+ const backend = selectBackend(env);
53
+ try {
54
+ const mimeTypes = String(execFn(backend.command, mimeArgs(backend), {
55
+ encoding: 'utf8',
56
+ timeout: PROBE_TIMEOUT_MS
57
+ }));
58
+ if (!hasPngMime(mimeTypes)) {
59
+ return null;
60
+ }
61
+
62
+ const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
63
+ const outputPath = path.join(tmpDir, 'clipboard.png');
64
+ try {
65
+ execToFileFn(backend.command, imageArgs(backend), outputPath, READ_IMAGE_TIMEOUT_MS);
66
+ const png = Buffer.from(readFileFn(outputPath));
67
+ return isPng(png) ? png : null;
68
+ } finally {
69
+ rmFn(tmpDir, { recursive: true, force: true });
70
+ }
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ };
76
+ }
77
+
78
+ function selectBackend(env: NodeJS.ProcessEnv): LinuxBackend {
79
+ return env.WAYLAND_DISPLAY?.trim()
80
+ ? { kind: 'wayland', command: 'wl-paste' }
81
+ : { kind: 'x11', command: 'xclip' };
82
+ }
83
+
84
+ function versionArgs(backend: LinuxBackend): string[] {
85
+ return backend.kind === 'wayland' ? ['--version'] : ['-version'];
86
+ }
87
+
88
+ function mimeArgs(backend: LinuxBackend): string[] {
89
+ return backend.kind === 'wayland'
90
+ ? ['--list-types']
91
+ : ['-selection', 'clipboard', '-t', 'TARGETS', '-o'];
92
+ }
93
+
94
+ function imageArgs(backend: LinuxBackend): string[] {
95
+ return backend.kind === 'wayland'
96
+ ? ['-t', 'image/png']
97
+ : ['-selection', 'clipboard', '-t', 'image/png', '-o'];
98
+ }
99
+
100
+ function unavailableReason(backend: LinuxBackend): string {
101
+ return backend.kind === 'wayland'
102
+ ? 'Wayland clipboard tool wl-paste is unavailable; install wl-clipboard to enable image paste'
103
+ : 'X11 clipboard tool xclip is unavailable; install xclip to enable image paste';
104
+ }
105
+
106
+ function hasPngMime(output: string): boolean {
107
+ return output.split(/\s+/u).some((type) => type === 'image/png');
108
+ }
109
+
110
+ function execToFile(cmd: string, args: string[], outputPath: string, timeout: number): void {
111
+ const fd = fs.openSync(outputPath, 'w', 0o600);
112
+ try {
113
+ execFileSync(cmd, args, {
114
+ timeout,
115
+ stdio: ['ignore', fd, 'pipe']
116
+ });
117
+ } finally {
118
+ fs.closeSync(fd);
119
+ }
120
+ }
121
+
122
+ function isPng(buffer: Buffer): boolean {
123
+ return buffer.length >= PNG_MAGIC.length && PNG_MAGIC.every((byte, index) => buffer[index] === byte);
124
+ }
@@ -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
@@ -93,6 +98,11 @@ Host aliases:
93
98
  type SandboxCreateConfig = ReturnType<typeof loadConfig>;
94
99
  type PreparedDockerfile = ReturnType<typeof prepareDockerfile>;
95
100
  type ResolvedTool = { tool: SandboxTool; dir: string };
101
+ type TmpfsSeedPlanEntry = {
102
+ stagingPath: string;
103
+ targetPath: string;
104
+ volumeArgs: string[];
105
+ };
96
106
  type RuntimeCheck = { name: string; cmd: string[] };
97
107
  type JsonObject = Record<string, unknown>;
98
108
  type GpgCache = { pub: Buffer; sec: Buffer } | null;
@@ -115,16 +125,6 @@ type HostShellConfig = {
115
125
  mounts: Array<{ hostPath: string; containerPath: string }>;
116
126
  };
117
127
 
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
128
  function resolveToolDirs(config: Pick<SandboxCreateConfig, 'project'>, tools: SandboxTool[], branch: string): ResolvedTool[] {
129
129
  return tools.map((tool) => {
130
130
  const candidates = toolConfigDirCandidates(tool, config.project, branch);
@@ -327,12 +327,14 @@ export function writeSanitizedGitconfig({
327
327
  const SHELL_CONFIG_SYMLINKS = ['.gitconfig', '.gitignore_global', '.stCommitMsg', '.bash_aliases'];
328
328
 
329
329
  export function ensureShellConfigSymlinks(engine: string, container: string, execFn: EngineExecFn = execEngine): void {
330
- // Idempotent symlink setup. Runs against a started container so it also
331
- // covers custom Dockerfiles that don't bake the symlinks into the image.
332
- const script = SHELL_CONFIG_SYMLINKS
333
- .map((file) => `ln -sf .host-shell-config/${file} ${CONTAINER_HOME}/${file}`)
334
- .join(' && ');
335
- execFn(engine, 'docker', ['exec', container, 'bash', '-lc', script], { stdio: 'ignore' });
330
+ // Idempotent symlink setup. Avoid a shell command here because Windows .cmd
331
+ // engine shims would interpret metacharacters in a `bash -lc` script before
332
+ // forwarding it to Docker.
333
+ for (const file of SHELL_CONFIG_SYMLINKS) {
334
+ execFn(engine, 'docker', [
335
+ 'exec', container, 'ln', '-sf', `.host-shell-config/${file}`, `${CONTAINER_HOME}/${file}`
336
+ ], { stdio: 'ignore' });
337
+ }
336
338
  }
337
339
 
338
340
  export function prepareHostShellConfig({
@@ -634,6 +636,7 @@ export function buildContainerEnvFile(
634
636
  chmodFn?: typeof fs.chmodSync;
635
637
  rmFn?: typeof fs.rmSync;
636
638
  tmpDir?: string;
639
+ runSafeFn?: DirectRunSafeFn;
637
640
  } = {}
638
641
  ): { dockerArgs: string[]; cleanup: () => void } {
639
642
  const {
@@ -641,11 +644,15 @@ export function buildContainerEnvFile(
641
644
  writeFileFn = fs.writeFileSync,
642
645
  chmodFn = fs.chmodSync,
643
646
  rmFn = fs.rmSync,
644
- tmpDir = os.tmpdir()
647
+ tmpDir = os.tmpdir(),
648
+ runSafeFn = runSafe
645
649
  } = options;
646
650
 
647
651
  const entries: Array<[string, string]> = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
648
- const ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
652
+ let ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
653
+ if (!ghToken && engine === 'wsl2') {
654
+ ghToken = runSafeFn('gh', ['auth', 'token']);
655
+ }
649
656
  if (ghToken) {
650
657
  entries.push(['GH_TOKEN', ghToken]);
651
658
  }
@@ -1191,43 +1198,43 @@ export function buildImage(
1191
1198
  runFn = runEngine,
1192
1199
  runSafeFn = runSafeEngine,
1193
1200
  runVerboseFn = runVerboseEngine,
1194
- env = process.env
1201
+ env = process.env,
1202
+ refresh = false,
1203
+ lastRefresh
1195
1204
  }: {
1196
1205
  engine?: string;
1197
1206
  runFn?: EngineRunFn;
1198
1207
  runSafeFn?: EngineRunSafeFn;
1199
1208
  runVerboseFn?: EngineRunVerboseFn;
1200
1209
  env?: NodeJS.ProcessEnv;
1210
+ refresh?: boolean;
1211
+ lastRefresh?: number;
1201
1212
  } = {}
1202
1213
  ): void {
1203
1214
  const selectedEngine = engine ?? detectEngine({ engine: config.engine });
1204
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
1205
- engine: selectedEngine,
1206
- runFn,
1207
- runSafeFn,
1208
- env
1209
- });
1215
+ runVerboseFn(
1216
+ selectedEngine,
1217
+ 'docker',
1218
+ buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
1219
+ engine: selectedEngine,
1220
+ runFn,
1221
+ runSafeFn,
1222
+ env,
1223
+ refresh,
1224
+ lastRefresh
1225
+ }),
1226
+ { cwd: config.repoRoot }
1227
+ );
1228
+ }
1210
1229
 
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 });
1230
+ function readImageLabels(config: Pick<SandboxCreateConfig, 'imageName'> & Pick<SandboxCreateConfig, 'project'>, engine: string): Record<string, string> {
1231
+ return parseImageLabels(runSafeEngine(engine, 'docker', [
1232
+ 'image',
1233
+ 'inspect',
1234
+ '--format',
1235
+ '{{ json .Config.Labels }}',
1236
+ config.imageName
1237
+ ]));
1231
1238
  }
1232
1239
 
1233
1240
  export async function create(args: string[]): Promise<void> {
@@ -1238,6 +1245,7 @@ export async function create(args: string[]): Promise<void> {
1238
1245
  options: {
1239
1246
  cpu: { type: 'string' },
1240
1247
  memory: { type: 'string' },
1248
+ 'no-refresh': { type: 'boolean' },
1241
1249
  help: { type: 'boolean', short: 'h' }
1242
1250
  }
1243
1251
  });
@@ -1287,7 +1295,7 @@ export async function create(args: string[]): Promise<void> {
1287
1295
  const shareBranch = shareBranchDir(effectiveConfig, branch);
1288
1296
  const preparedDockerfile = prepareDockerfile(effectiveConfig);
1289
1297
  const baseBranch = base ?? runSafe('git', ['-C', effectiveConfig.repoRoot, 'branch', '--show-current']);
1290
- const expectedImageSignature = buildSignature(preparedDockerfile, tools);
1298
+ const expectedImageSignature = buildImageSignature(preparedDockerfile, tools);
1291
1299
  const engine = detectEngine(effectiveConfig);
1292
1300
 
1293
1301
  p.intro(pc.cyan('AI Sandbox'));
@@ -1310,27 +1318,53 @@ export async function create(args: string[]): Promise<void> {
1310
1318
  p.log.success('Docker is ready');
1311
1319
 
1312
1320
  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;
1321
+ const imageLabels = imageExists ? readImageLabels(effectiveConfig, engine) : {};
1322
+ const currentImageSignature = imageLabels[sandboxImageConfigLabel(effectiveConfig)] ?? '';
1323
+ const currentLastRefresh = parseRefreshTimestamp(imageLabels[sandboxImageRefreshLabel(effectiveConfig)] ?? '');
1324
+ const signatureStale = !imageExists || currentImageSignature !== expectedImageSignature;
1325
+ const now = Date.now();
1326
+ const refreshDue = imageExists
1327
+ && !signatureStale
1328
+ && !isRefreshDisabled(process.env, values['no-refresh'] ?? false)
1329
+ && isRefreshDue(currentLastRefresh, now, effectiveConfig.refreshIntervalDays);
1330
+ const needsImageBuild = signatureStale || refreshDue;
1323
1331
 
1324
1332
  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 }
1333
+ const buildRefresh = !imageExists || refreshDue;
1334
+ const buildLastRefresh = buildRefresh ? now : currentLastRefresh || 0;
1335
+
1336
+ p.log.step(
1337
+ refreshDue
1338
+ ? 'Refreshing stale image...'
1339
+ : imageExists
1340
+ ? 'Rebuilding stale image...'
1341
+ : 'Building image for first use...'
1332
1342
  );
1333
- p.log.success(imageExists ? 'Image rebuilt' : 'Image built');
1343
+ try {
1344
+ buildImage(
1345
+ effectiveConfig,
1346
+ tools,
1347
+ preparedDockerfile.path,
1348
+ expectedImageSignature,
1349
+ { engine, refresh: buildRefresh, lastRefresh: buildLastRefresh }
1350
+ );
1351
+ p.log.success(
1352
+ refreshDue
1353
+ ? 'Image refreshed'
1354
+ : imageExists
1355
+ ? 'Image rebuilt'
1356
+ : 'Image built'
1357
+ );
1358
+ } catch (error) {
1359
+ if (refreshDue && !signatureStale && imageExists) {
1360
+ p.log.warn(
1361
+ 'Scheduled sandbox image refresh failed; continuing with the existing image. ' +
1362
+ commandErrorMessage(error)
1363
+ );
1364
+ } else {
1365
+ throw error;
1366
+ }
1367
+ }
1334
1368
  } else {
1335
1369
  p.log.step(`Using existing image ${effectiveConfig.imageName}`);
1336
1370
  }
@@ -1471,6 +1505,7 @@ export async function create(args: string[]): Promise<void> {
1471
1505
  : null;
1472
1506
  const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1473
1507
  let hostShellConfig: HostShellConfig;
1508
+ let tmpfsSeedPlan: TmpfsSeedPlanEntry[] = [];
1474
1509
  try {
1475
1510
  const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
1476
1511
  if (claudeCodeEntry) {
@@ -1511,22 +1546,24 @@ export async function create(args: string[]): Promise<void> {
1511
1546
  '-v',
1512
1547
  volumeArg(engine, hostPath, containerPath, ':ro')
1513
1548
  ]);
1514
- // A tmpfs containerMount starts empty, so the config seeded into the
1515
- // host dir before launch would be invisible in-container. Bind only
1516
- // the explicitly declared seed entries (config.toml, model-catalogs)
1517
- // back over the tmpfs as nested mounts the same proven mechanism as
1518
- // hostLiveMounts/auth.json, established at `docker run` time (no
1519
- // post-start `docker cp`, which can land under a freshly-mounted
1520
- // tmpfs instead of inside it). The allowlist is deliberate: any
1521
- // runtime files left in the host dir (e.g. a stale logs_2.sqlite or
1522
- // sessions/ from a previous bind-mount era) must NOT be re-mounted,
1523
- // or the high-churn writes would land on the host SSD again.
1524
- const tmpfsSeedVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) =>
1525
- (tool.tmpfs?.seed ?? []).flatMap((entry) => {
1549
+ // Mount each declared seed read-only at an isolated staging path.
1550
+ // After docker run mounts the empty tmpfs, the container's default
1551
+ // user copies these entries into it so the runtime targets are normal,
1552
+ // writable files rather than nested mount points. The allowlist keeps
1553
+ // stale runtime files (logs_2.sqlite, sessions, etc.) on the host from
1554
+ // being exposed or written through.
1555
+ tmpfsSeedPlan = effectiveResolvedTools.flatMap(({ tool, dir }) =>
1556
+ (tool.tmpfs?.seed ?? []).flatMap((entry, index) => {
1526
1557
  const hostPath = path.join(dir, entry);
1527
- return fs.existsSync(hostPath)
1528
- ? ['-v', volumeArg(engine, hostPath, path.posix.join(tool.containerMount, entry))]
1529
- : [];
1558
+ if (!fs.existsSync(hostPath)) {
1559
+ return [];
1560
+ }
1561
+ const stagingPath = path.posix.join('/run/agent-infra/tmpfs-seeds', tool.id, String(index));
1562
+ return [{
1563
+ stagingPath,
1564
+ targetPath: path.posix.join(tool.containerMount, entry),
1565
+ volumeArgs: ['-v', volumeArg(engine, hostPath, stagingPath, ':ro')]
1566
+ }];
1530
1567
  })
1531
1568
  );
1532
1569
  const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) =>
@@ -1579,12 +1616,10 @@ export async function create(args: string[]): Promise<void> {
1579
1616
  path.join(effectiveConfig.repoRoot, '.git'),
1580
1617
  `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`
1581
1618
  ),
1582
- '-v',
1583
- volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
1584
1619
  ...dotfilesMount,
1585
1620
  ...toolVolumes,
1586
1621
  ...tmpfsArgs,
1587
- ...tmpfsSeedVolumes,
1622
+ ...tmpfsSeedPlan.flatMap(({ volumeArgs }) => volumeArgs),
1588
1623
  ...liveMountVolumes,
1589
1624
  ...shellConfigVolumes,
1590
1625
  ...envFile.dockerArgs,
@@ -1597,6 +1632,15 @@ export async function create(args: string[]): Promise<void> {
1597
1632
  envFile.cleanup();
1598
1633
  }
1599
1634
 
1635
+ for (const { stagingPath, targetPath } of tmpfsSeedPlan) {
1636
+ runEngineTaskCommand(engine, 'docker', [
1637
+ 'exec', container, 'mkdir', '-p', path.posix.dirname(targetPath)
1638
+ ]);
1639
+ runEngineTaskCommand(engine, 'docker', [
1640
+ 'exec', container, 'cp', '-R', '--', stagingPath, targetPath
1641
+ ]);
1642
+ }
1643
+
1600
1644
  // Belt-and-suspenders: re-create the four shell-config symlinks at
1601
1645
  // runtime so users with a custom `sandbox.dockerfile` (which won't
1602
1646
  // include the ai-tools.dockerfile symlink fragment) still get