@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.
- package/dist/bin/cli.js +1 -1
- package/dist/lib/cp.js +1 -1
- package/dist/lib/decide.js +2 -2
- package/dist/lib/defaults.json +1 -0
- package/dist/lib/init.js +7 -7
- package/dist/lib/merge.js +1 -1
- package/dist/lib/prompt.js +1 -1
- package/dist/lib/run/index.js +7 -7
- package/dist/lib/run/prompt.js +1 -1
- package/dist/lib/sandbox/capture.js +5 -5
- package/dist/lib/sandbox/clipboard/bridge.js +48 -13
- package/dist/lib/sandbox/clipboard/index.js +5 -6
- package/dist/lib/sandbox/clipboard/linux.js +91 -0
- package/dist/lib/sandbox/clipboard/paths.js +1 -1
- package/dist/lib/sandbox/clipboard/win32.js +144 -0
- package/dist/lib/sandbox/commands/create.js +67 -65
- package/dist/lib/sandbox/commands/enter.js +9 -9
- package/dist/lib/sandbox/commands/list-running.js +2 -2
- package/dist/lib/sandbox/commands/ls.js +7 -7
- package/dist/lib/sandbox/commands/prune.js +8 -8
- package/dist/lib/sandbox/commands/rebuild.js +38 -50
- package/dist/lib/sandbox/commands/refresh.js +2 -2
- package/dist/lib/sandbox/commands/rm.js +11 -11
- package/dist/lib/sandbox/commands/show.js +4 -4
- package/dist/lib/sandbox/commands/start.js +4 -4
- package/dist/lib/sandbox/commands/vm.js +4 -4
- package/dist/lib/sandbox/config.js +10 -4
- package/dist/lib/sandbox/constants.js +4 -1
- package/dist/lib/sandbox/credentials.js +1 -1
- package/dist/lib/sandbox/dotfiles.js +1 -1
- package/dist/lib/sandbox/engine.js +3 -3
- package/dist/lib/sandbox/engines/index.js +5 -5
- package/dist/lib/sandbox/engines/wsl2-paths.js +1 -1
- package/dist/lib/sandbox/image-build.js +80 -0
- package/dist/lib/sandbox/image-prune.js +2 -2
- package/dist/lib/sandbox/index.js +10 -10
- package/dist/lib/sandbox/managed-fs.js +1 -1
- package/dist/lib/sandbox/readme-scaffold.js +1 -1
- package/dist/lib/sandbox/tools.js +2 -2
- package/dist/lib/server/adapters/feishu/index.js +10 -3
- package/dist/lib/server/adapters/feishu/renderer.js +88 -0
- package/dist/lib/server/adapters/feishu/transport.js +2 -13
- package/dist/lib/server/daemon.js +64 -43
- package/dist/lib/server/display.js +83 -0
- package/dist/lib/server/index.js +2 -2
- package/dist/lib/server/process-control.js +2 -2
- package/dist/lib/server/protocol.js +2 -2
- package/dist/lib/server/streamer.js +5 -4
- package/dist/lib/task/commands/cat.js +2 -2
- package/dist/lib/task/commands/decisions.js +4 -4
- package/dist/lib/task/commands/files.js +3 -3
- package/dist/lib/task/commands/grep.js +3 -3
- package/dist/lib/task/commands/issue-body.js +4 -4
- package/dist/lib/task/commands/log.js +5 -5
- package/dist/lib/task/commands/ls.js +3 -3
- package/dist/lib/task/commands/show.js +1 -1
- package/dist/lib/task/commands/status.js +71 -25
- package/dist/lib/task/index.js +9 -9
- package/dist/lib/task/resolve-ref.js +1 -1
- package/dist/lib/task/workflow-warnings.js +94 -0
- package/dist/lib/update.js +4 -4
- package/lib/defaults.json +1 -0
- package/lib/sandbox/clipboard/bridge.ts +50 -8
- package/lib/sandbox/clipboard/index.ts +5 -6
- package/lib/sandbox/clipboard/linux.ts +124 -0
- package/lib/sandbox/clipboard/win32.ts +173 -0
- package/lib/sandbox/commands/create.ts +84 -64
- package/lib/sandbox/commands/rebuild.ts +42 -54
- package/lib/sandbox/config.ts +13 -1
- package/lib/sandbox/constants.ts +4 -0
- package/lib/sandbox/image-build.ts +134 -0
- package/lib/server/adapters/_contract.ts +3 -0
- package/lib/server/adapters/feishu/index.ts +11 -3
- package/lib/server/adapters/feishu/renderer.ts +99 -0
- package/lib/server/adapters/feishu/transport.ts +5 -18
- package/lib/server/daemon.ts +74 -38
- package/lib/server/display.ts +136 -0
- package/lib/server/streamer.ts +5 -4
- package/lib/task/commands/log.ts +3 -3
- package/lib/task/commands/status.ts +102 -21
- package/lib/task/workflow-warnings.ts +121 -0
- package/package.json +2 -2
- package/templates/.agents/rules/create-issue.github.en.md +3 -3
- package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
- package/templates/.agents/rules/issue-pr-commands.github.en.md +7 -2
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +7 -2
- package/templates/.agents/rules/issue-sync.github.en.md +13 -0
- package/templates/.agents/rules/issue-sync.github.zh-CN.md +13 -0
- package/templates/.agents/rules/next-step-output.en.md +15 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +15 -1
- package/templates/.agents/rules/pr-sync.github.en.md +17 -2
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +17 -2
- package/templates/.agents/rules/task-management.en.md +1 -1
- package/templates/.agents/rules/task-management.zh-CN.md +1 -1
- package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +57 -0
- package/templates/.agents/scripts/validate-artifact.js +120 -0
- package/templates/.agents/scripts/workflow-warnings.js +290 -0
- package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +118 -0
- package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +118 -0
- package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +49 -0
- package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +49 -0
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +48 -0
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +48 -0
- package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +60 -0
- package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +87 -0
- package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +4 -1
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +7 -0
- package/templates/.agents/templates/task.en.md +7 -0
- package/templates/.agents/templates/task.zh-CN.md +7 -0
- package/templates/.claude/commands/complete-manual-validation.en.md +9 -0
- package/templates/.claude/commands/complete-manual-validation.zh-CN.md +9 -0
- package/templates/.gemini/commands/_project_/complete-manual-validation.en.toml +8 -0
- package/templates/.gemini/commands/_project_/complete-manual-validation.zh-CN.toml +8 -0
- package/templates/.github/workflows/metadata-sync.yml +5 -0
- package/templates/.opencode/commands/complete-manual-validation.en.md +11 -0
- 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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
'
|
|
1214
|
-
|
|
1215
|
-
'--
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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 =
|
|
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
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
])
|
|
1321
|
-
|
|
1322
|
-
const needsImageBuild =
|
|
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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
20
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 =
|
|
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, {
|
|
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, {
|
|
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'));
|
package/lib/sandbox/config.ts
CHANGED
|
@@ -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
|
|
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,
|
package/lib/sandbox/constants.ts
CHANGED
|
@@ -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;
|