@fitlab-ai/agent-infra 0.5.10 → 0.6.0
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/{cli.js → cli.ts} +21 -17
- package/dist/bin/cli.js +116 -0
- package/dist/lib/defaults.json +61 -0
- package/dist/lib/init.js +238 -0
- package/dist/lib/log.js +18 -0
- package/dist/lib/merge.js +747 -0
- package/dist/lib/paths.js +18 -0
- package/dist/lib/prompt.js +85 -0
- package/dist/lib/render.js +139 -0
- package/dist/lib/sandbox/commands/create.js +1173 -0
- package/dist/lib/sandbox/commands/enter.js +98 -0
- package/dist/lib/sandbox/commands/ls.js +93 -0
- package/dist/lib/sandbox/commands/rebuild.js +101 -0
- package/dist/lib/sandbox/commands/refresh.js +85 -0
- package/dist/lib/sandbox/commands/rm.js +226 -0
- package/dist/lib/sandbox/commands/vm.js +144 -0
- package/dist/lib/sandbox/config.js +85 -0
- package/dist/lib/sandbox/constants.js +104 -0
- package/dist/lib/sandbox/credentials.js +437 -0
- package/dist/lib/sandbox/dockerfile.js +76 -0
- package/dist/lib/sandbox/dotfiles.js +170 -0
- package/dist/lib/sandbox/engine.js +155 -0
- package/dist/lib/sandbox/engines/colima.js +64 -0
- package/dist/lib/sandbox/engines/docker-desktop.js +27 -0
- package/dist/lib/sandbox/engines/index.js +25 -0
- package/dist/lib/sandbox/engines/native.js +96 -0
- package/dist/lib/sandbox/engines/orbstack.js +63 -0
- package/dist/lib/sandbox/engines/selinux.js +48 -0
- package/dist/lib/sandbox/engines/wsl2-paths.js +47 -0
- package/dist/lib/sandbox/engines/wsl2.js +57 -0
- package/dist/lib/sandbox/index.js +70 -0
- package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +39 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +178 -0
- package/dist/lib/sandbox/runtimes/java17.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/java21.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/node20.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/node22.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/python3.dockerfile +3 -0
- package/dist/lib/sandbox/shell.js +148 -0
- package/dist/lib/sandbox/task-resolver.js +35 -0
- package/dist/lib/sandbox/tools.js +115 -0
- package/dist/lib/update.js +186 -0
- package/dist/lib/version.js +5 -0
- package/dist/package.json +5 -0
- package/lib/{init.js → init.ts} +48 -18
- package/lib/{log.js → log.ts} +4 -4
- package/lib/{merge.js → merge.ts} +129 -63
- package/lib/paths.ts +18 -0
- package/lib/{prompt.js → prompt.ts} +12 -12
- package/lib/{render.js → render.ts} +30 -17
- package/lib/sandbox/commands/{create.js → create.ts} +224 -118
- package/lib/sandbox/commands/{enter.js → enter.ts} +17 -14
- package/lib/sandbox/commands/{ls.js → ls.ts} +10 -10
- package/lib/sandbox/commands/{rebuild.js → rebuild.ts} +38 -21
- package/lib/sandbox/commands/{refresh.js → refresh.ts} +16 -7
- package/lib/sandbox/commands/{rm.js → rm.ts} +15 -13
- package/lib/sandbox/commands/{vm.js → vm.ts} +14 -11
- package/lib/sandbox/{config.js → config.ts} +55 -10
- package/lib/sandbox/{constants.js → constants.ts} +30 -18
- package/lib/sandbox/{credentials.js → credentials.ts} +160 -46
- package/lib/sandbox/{dockerfile.js → dockerfile.ts} +13 -6
- package/lib/sandbox/{dotfiles.js → dotfiles.ts} +66 -19
- package/lib/sandbox/{engine.js → engine.ts} +57 -25
- package/lib/sandbox/engines/{colima.js → colima.ts} +9 -7
- package/lib/sandbox/engines/{docker-desktop.js → docker-desktop.ts} +5 -3
- package/lib/sandbox/engines/index.ts +74 -0
- package/lib/sandbox/engines/{native.js → native.ts} +25 -6
- package/lib/sandbox/engines/{orbstack.js → orbstack.ts} +7 -5
- package/lib/sandbox/engines/{selinux.js → selinux.ts} +11 -5
- package/lib/sandbox/engines/{wsl2-paths.js → wsl2-paths.ts} +15 -9
- package/lib/sandbox/engines/{wsl2.js → wsl2.ts} +9 -7
- package/lib/sandbox/{index.js → index.ts} +8 -8
- package/lib/sandbox/{shell.js → shell.ts} +30 -17
- package/lib/sandbox/{task-resolver.js → task-resolver.ts} +6 -6
- package/lib/sandbox/{tools.js → tools.ts} +30 -26
- package/lib/{update.js → update.ts} +33 -10
- package/package.json +17 -9
- package/lib/paths.js +0 -9
- package/lib/sandbox/engines/index.js +0 -27
- /package/lib/{version.js → version.ts} +0 -0
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import * as p from '@clack/prompts';
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import * as toml from 'smol-toml';
|
|
10
|
+
import { loadConfig } from "../config.js";
|
|
11
|
+
import { assertValidBranchName, containerName, containerNameCandidates, parsePositiveIntegerOption, sandboxBranchLabel, sandboxImageConfigLabel, sandboxLabel, sanitizeBranchName, shareBranchDir, shareCommonDir, worktreeDirCandidates } from "../constants.js";
|
|
12
|
+
import { prepareDockerfile } from "../dockerfile.js";
|
|
13
|
+
import { detectEngine, ensureDocker } from "../engine.js";
|
|
14
|
+
import { commandForEngine, execEngine, run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine, runVerboseEngine } from "../shell.js";
|
|
15
|
+
import { resolveTaskBranch } from "../task-resolver.js";
|
|
16
|
+
import { resolveTools, toolConfigDirCandidates, toolNpmPackagesArg } from "../tools.js";
|
|
17
|
+
import { hostJoin, toEnginePath, volumeArg } from "../engines/wsl2-paths.js";
|
|
18
|
+
import { validateSelinuxDisableEnv } from "../engines/selinux.js";
|
|
19
|
+
import { resolveBuildUid } from "../engines/native.js";
|
|
20
|
+
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
21
|
+
import { assertClaudeCredentialsAvailable, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
|
|
22
|
+
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
23
|
+
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
24
|
+
const SANDBOX_ALIAS_BLOCK_END = '# <<< agent-infra managed aliases <<<';
|
|
25
|
+
const SANDBOX_ALIAS_NAMES = [
|
|
26
|
+
'claude-yolo',
|
|
27
|
+
'opencode-yolo',
|
|
28
|
+
'codex-yolo',
|
|
29
|
+
'gemini-yolo',
|
|
30
|
+
'cy',
|
|
31
|
+
'oy',
|
|
32
|
+
'xy',
|
|
33
|
+
'gy'
|
|
34
|
+
];
|
|
35
|
+
const DEFAULT_SANDBOX_ALIASES = `alias claude-yolo='claude --dangerously-skip-permissions; tput ed'
|
|
36
|
+
alias opencode-yolo='OPENCODE_PERMISSION='\\''${OPENCODE_YOLO_PERMISSION}'\\'' opencode; tput ed'
|
|
37
|
+
alias codex-yolo='codex --yolo; tput ed'
|
|
38
|
+
alias gemini-yolo='gemini --yolo; tput ed'
|
|
39
|
+
|
|
40
|
+
alias cy='claude --dangerously-skip-permissions; tput ed'
|
|
41
|
+
alias oy='OPENCODE_PERMISSION='\\''${OPENCODE_YOLO_PERMISSION}'\\'' opencode; tput ed'
|
|
42
|
+
alias xy='codex --yolo; tput ed'
|
|
43
|
+
alias gy='gemini --yolo; tput ed'
|
|
44
|
+
`;
|
|
45
|
+
const CONTAINER_HOME = '/home/devuser';
|
|
46
|
+
const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
|
|
47
|
+
const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>]
|
|
48
|
+
|
|
49
|
+
Host aliases:
|
|
50
|
+
${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
|
|
51
|
+
as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
|
|
52
|
+
shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
|
|
53
|
+
symlinked into $HOME).`;
|
|
54
|
+
function buildSignature(preparedDockerfile, tools) {
|
|
55
|
+
return createHash('sha256')
|
|
56
|
+
.update(JSON.stringify({
|
|
57
|
+
dockerfile: preparedDockerfile.signature,
|
|
58
|
+
tools: tools.map((tool) => tool.npmPackage)
|
|
59
|
+
}))
|
|
60
|
+
.digest('hex')
|
|
61
|
+
.slice(0, 12);
|
|
62
|
+
}
|
|
63
|
+
function resolveToolDirs(config, tools, branch) {
|
|
64
|
+
return tools.map((tool) => {
|
|
65
|
+
const candidates = toolConfigDirCandidates(tool, config.project, branch);
|
|
66
|
+
return {
|
|
67
|
+
tool,
|
|
68
|
+
dir: candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0] ?? ''
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function hostShellConfigDir(home, project, branch) {
|
|
73
|
+
return hostJoin(home, '.agent-infra', 'config', project, sanitizeBranchName(branch));
|
|
74
|
+
}
|
|
75
|
+
function runtimeChecks(runtimes) {
|
|
76
|
+
const checks = [];
|
|
77
|
+
if (runtimes.some((runtime) => runtime.startsWith('node'))) {
|
|
78
|
+
checks.push({ name: 'Node.js', cmd: ['node', '--version'] });
|
|
79
|
+
}
|
|
80
|
+
if (runtimes.some((runtime) => runtime.startsWith('java'))) {
|
|
81
|
+
checks.push({ name: 'Java', cmd: ['java', '-version'] });
|
|
82
|
+
checks.push({ name: 'Maven', cmd: ['mvn', '--version'] });
|
|
83
|
+
}
|
|
84
|
+
if (runtimes.includes('python3')) {
|
|
85
|
+
checks.push({ name: 'Python', cmd: ['python3', '--version'] });
|
|
86
|
+
}
|
|
87
|
+
return checks;
|
|
88
|
+
}
|
|
89
|
+
export function detectGpgConfig(gitconfig) {
|
|
90
|
+
return /\bgpgsign\s*=\s*true\b/i.test(gitconfig) || /^\s*\[gpg(?:\s|"|\])/im.test(gitconfig);
|
|
91
|
+
}
|
|
92
|
+
function appendSafeDirectories(lines, repoRoot) {
|
|
93
|
+
if (!repoRoot) {
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
const requiredDirectories = ['/workspace', repoRoot];
|
|
97
|
+
const existingDirectories = new Set();
|
|
98
|
+
let firstSafeSectionIndex = -1;
|
|
99
|
+
let inSafeSection = false;
|
|
100
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
101
|
+
const line = lines[index] ?? '';
|
|
102
|
+
const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
103
|
+
if (sectionMatch) {
|
|
104
|
+
inSafeSection = (sectionMatch[1] ?? '').trim().toLowerCase() === 'safe';
|
|
105
|
+
if (inSafeSection && firstSafeSectionIndex === -1) {
|
|
106
|
+
firstSafeSectionIndex = index;
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!inSafeSection) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const directoryMatch = line.match(/^\s*directory\s*=\s*(.+?)\s*$/i);
|
|
114
|
+
if (directoryMatch) {
|
|
115
|
+
existingDirectories.add((directoryMatch[1] ?? '').trim());
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const missingDirectories = requiredDirectories
|
|
119
|
+
.filter((directory) => !existingDirectories.has(directory));
|
|
120
|
+
if (missingDirectories.length === 0) {
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
if (firstSafeSectionIndex === -1) {
|
|
124
|
+
return [
|
|
125
|
+
...lines,
|
|
126
|
+
'[safe]',
|
|
127
|
+
...missingDirectories.map((directory) => `\tdirectory = ${directory}`)
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
const updatedLines = [...lines];
|
|
131
|
+
let insertIndex = updatedLines.length;
|
|
132
|
+
for (let index = firstSafeSectionIndex + 1; index < updatedLines.length; index += 1) {
|
|
133
|
+
if (/^\s*\[([^\]]+)\]\s*$/.test(updatedLines[index] ?? '')) {
|
|
134
|
+
insertIndex = index;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
updatedLines.splice(insertIndex, 0, ...missingDirectories.map((directory) => `\tdirectory = ${directory}`));
|
|
139
|
+
return updatedLines;
|
|
140
|
+
}
|
|
141
|
+
function normalizeContainerHomeSeparators(content) {
|
|
142
|
+
const containerHomePattern = new RegExp(`${escapeRegExp(CONTAINER_HOME)}\\S*`, 'g');
|
|
143
|
+
return content.replace(containerHomePattern, (value) => value.replaceAll('\\', '/'));
|
|
144
|
+
}
|
|
145
|
+
export function sanitizeGitConfig(gitconfig, home, { stripGpg = false, repoRoot = '' } = {}) {
|
|
146
|
+
const posixHome = home.replaceAll('\\', '/');
|
|
147
|
+
const normalizedGitconfig = gitconfig
|
|
148
|
+
.replaceAll(home, CONTAINER_HOME)
|
|
149
|
+
.replaceAll(posixHome, CONTAINER_HOME);
|
|
150
|
+
const lines = normalizeContainerHomeSeparators(normalizedGitconfig)
|
|
151
|
+
.replace(/\[difftool "sourcetree"\][^\[]*/gs, '')
|
|
152
|
+
.replace(/\[mergetool "sourcetree"\][^\[]*/gs, '')
|
|
153
|
+
.split(/\r?\n/);
|
|
154
|
+
const sanitized = [];
|
|
155
|
+
let inGpgSection = false;
|
|
156
|
+
let currentSection = '';
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
159
|
+
if (sectionMatch) {
|
|
160
|
+
const sectionName = (sectionMatch[1] ?? '').trim();
|
|
161
|
+
currentSection = ((sectionName.match(/^([^\s"]+)/)?.[1]) ?? '').toLowerCase();
|
|
162
|
+
inGpgSection = /^gpg(?:\s+"[^"]+")?$/i.test(sectionName);
|
|
163
|
+
if (stripGpg && inGpgSection) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
sanitized.push(line);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (inGpgSection) {
|
|
170
|
+
if (stripGpg) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (/^\s*program\s*=.*$/i.test(line)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (stripGpg && currentSection === 'commit' && /^\s*gpgsign\s*=.*$/i.test(line)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (stripGpg && currentSection === 'tag' && /^\s*gpgsign\s*=.*$/i.test(line)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (stripGpg && currentSection === 'user' && /^\s*signingKey\s*=.*$/i.test(line)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
sanitized.push(line);
|
|
187
|
+
}
|
|
188
|
+
return appendSafeDirectories(sanitized, repoRoot).join('\n');
|
|
189
|
+
}
|
|
190
|
+
export function hostHasGpgKeys(home, execFn = execFileSync) {
|
|
191
|
+
return currentKeyringFingerprint(home, execFn) !== null;
|
|
192
|
+
}
|
|
193
|
+
export function writeSanitizedGitconfig({ home, hostConfigDir, stripGpg, repoRoot }) {
|
|
194
|
+
const gitconfigPath = hostJoin(home, '.gitconfig');
|
|
195
|
+
// Always emit a sanitized .gitconfig, even when the host has none. The
|
|
196
|
+
// container ~/.gitconfig is a symlink into the bound shell-config directory;
|
|
197
|
+
// a missing file would leave the symlink dangling and drop the default
|
|
198
|
+
// safe.directory entries the image relies on.
|
|
199
|
+
const sourceContent = fs.existsSync(gitconfigPath)
|
|
200
|
+
? fs.readFileSync(gitconfigPath, 'utf8')
|
|
201
|
+
: '';
|
|
202
|
+
fs.mkdirSync(hostConfigDir, { recursive: true });
|
|
203
|
+
const targetPath = path.join(hostConfigDir, '.gitconfig');
|
|
204
|
+
const gitconfig = sanitizeGitConfig(sourceContent, home, { stripGpg, repoRoot });
|
|
205
|
+
fs.writeFileSync(targetPath, gitconfig, 'utf8');
|
|
206
|
+
return targetPath;
|
|
207
|
+
}
|
|
208
|
+
// Files inside the host shell-config bind that need to be exposed in $HOME.
|
|
209
|
+
// Keep in sync with the symlink block in lib/sandbox/runtimes/ai-tools.dockerfile.
|
|
210
|
+
const SHELL_CONFIG_SYMLINKS = ['.gitconfig', '.gitignore_global', '.stCommitMsg', '.bash_aliases'];
|
|
211
|
+
export function ensureShellConfigSymlinks(engine, container, execFn = execEngine) {
|
|
212
|
+
// Idempotent symlink setup. Runs against a started container so it also
|
|
213
|
+
// covers custom Dockerfiles that don't bake the symlinks into the image.
|
|
214
|
+
const script = SHELL_CONFIG_SYMLINKS
|
|
215
|
+
.map((file) => `ln -sf .host-shell-config/${file} ${CONTAINER_HOME}/${file}`)
|
|
216
|
+
.join(' && ');
|
|
217
|
+
execFn(engine, 'docker', ['exec', container, 'bash', '-lc', script], { stdio: 'ignore' });
|
|
218
|
+
}
|
|
219
|
+
export function prepareHostShellConfig({ home, project, branch, repoRoot }) {
|
|
220
|
+
const hostDir = hostShellConfigDir(home, project, branch);
|
|
221
|
+
fs.rmSync(hostDir, { recursive: true, force: true });
|
|
222
|
+
fs.mkdirSync(hostDir, { recursive: true });
|
|
223
|
+
writeSanitizedGitconfig({
|
|
224
|
+
home,
|
|
225
|
+
hostConfigDir: hostDir,
|
|
226
|
+
stripGpg: true,
|
|
227
|
+
repoRoot
|
|
228
|
+
});
|
|
229
|
+
for (const file of ['.gitignore_global', '.stCommitMsg']) {
|
|
230
|
+
const hostPath = hostJoin(home, file);
|
|
231
|
+
if (!fs.existsSync(hostPath)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
fs.copyFileSync(hostPath, path.join(hostDir, file));
|
|
235
|
+
}
|
|
236
|
+
const aliasesPath = sandboxAliasesPath(home);
|
|
237
|
+
if (fs.existsSync(aliasesPath)) {
|
|
238
|
+
fs.copyFileSync(aliasesPath, path.join(hostDir, '.bash_aliases'));
|
|
239
|
+
}
|
|
240
|
+
// Single directory bind keeps virtiofs happy: per-file rewrites inside no
|
|
241
|
+
// longer race the bind layer like individual single-file binds do.
|
|
242
|
+
const mounts = [{ hostPath: hostDir, containerPath: CONTAINER_SHELL_CONFIG_MOUNT }];
|
|
243
|
+
return { hostDir, mounts };
|
|
244
|
+
}
|
|
245
|
+
function gpgCacheDir(home, project) {
|
|
246
|
+
return hostJoin(home, '.agent-infra', 'gpg-cache', project);
|
|
247
|
+
}
|
|
248
|
+
function normalizeSigningKey(signingKey) {
|
|
249
|
+
if (typeof signingKey !== 'string') {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const trimmed = signingKey.trim();
|
|
253
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
254
|
+
}
|
|
255
|
+
function normalizeWorktreePath(worktreePath) {
|
|
256
|
+
if (!worktreePath) {
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
return fs.existsSync(worktreePath) ? fs.realpathSync(worktreePath) : path.resolve(worktreePath);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return path.resolve(worktreePath);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export function getGitSigningKey({ home, repoPath = null, execFn = execFileSync } = {}) {
|
|
267
|
+
if (!home) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const output = execFn('git', [
|
|
272
|
+
...(repoPath ? ['-C', repoPath] : []),
|
|
273
|
+
'config',
|
|
274
|
+
...(repoPath ? [] : ['--global']),
|
|
275
|
+
'user.signingKey'
|
|
276
|
+
], {
|
|
277
|
+
encoding: 'utf8',
|
|
278
|
+
env: { ...process.env, HOME: home },
|
|
279
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
280
|
+
});
|
|
281
|
+
return normalizeSigningKey(output.toString());
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export function currentKeyringFingerprint(home, execFn = execFileSync) {
|
|
288
|
+
const hostEnv = { ...process.env, HOME: home };
|
|
289
|
+
try {
|
|
290
|
+
const keyring = execFn('gpg', ['--list-secret-keys', '--with-colons'], {
|
|
291
|
+
encoding: 'utf8',
|
|
292
|
+
env: hostEnv,
|
|
293
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
294
|
+
});
|
|
295
|
+
const keyringText = keyring.toString();
|
|
296
|
+
if (!keyringText || keyringText.trim().length === 0) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
return createHash('sha256').update(keyringText).digest('hex');
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
export function readGpgCache(home, project, execFn = execFileSync, signingKey = null) {
|
|
306
|
+
const cacheDir = gpgCacheDir(home, project);
|
|
307
|
+
const pubPath = path.join(cacheDir, 'public.asc');
|
|
308
|
+
const secPath = path.join(cacheDir, 'secret.asc');
|
|
309
|
+
const statePath = path.join(cacheDir, 'state.json');
|
|
310
|
+
try {
|
|
311
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
312
|
+
if (typeof state?.fingerprint !== 'string' || state.fingerprint.length === 0) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (normalizeSigningKey(state?.signingKey) !== normalizeSigningKey(signingKey)) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const currentFingerprint = currentKeyringFingerprint(home, execFn);
|
|
319
|
+
if (!currentFingerprint || currentFingerprint !== state.fingerprint) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const pub = fs.readFileSync(pubPath);
|
|
323
|
+
const sec = fs.readFileSync(secPath);
|
|
324
|
+
if (pub.length === 0 || sec.length === 0) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
return { pub, sec };
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
export function writeGpgCache(home, project, pub, sec, fingerprint, signingKey = null) {
|
|
334
|
+
if (!fingerprint) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
const cacheDir = gpgCacheDir(home, project);
|
|
338
|
+
const pubPath = path.join(cacheDir, 'public.asc');
|
|
339
|
+
const secPath = path.join(cacheDir, 'secret.asc');
|
|
340
|
+
const statePath = path.join(cacheDir, 'state.json');
|
|
341
|
+
try {
|
|
342
|
+
const state = { fingerprint };
|
|
343
|
+
const normalizedSigningKey = normalizeSigningKey(signingKey);
|
|
344
|
+
if (normalizedSigningKey) {
|
|
345
|
+
state.signingKey = normalizedSigningKey;
|
|
346
|
+
}
|
|
347
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
348
|
+
fs.chmodSync(cacheDir, 0o700);
|
|
349
|
+
fs.writeFileSync(pubPath, pub, { mode: 0o600 });
|
|
350
|
+
fs.chmodSync(pubPath, 0o600);
|
|
351
|
+
fs.writeFileSync(secPath, sec, { mode: 0o600 });
|
|
352
|
+
fs.chmodSync(secPath, 0o600);
|
|
353
|
+
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
354
|
+
fs.chmodSync(statePath, 0o600);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
export function syncGpgKeys(container, home, project, execFn = execFileSync, runSafeFn = runSafe, options = {}) {
|
|
362
|
+
const { cachedOverride = null, repoPath = null, signingKey: signingKeyOverride, dockerExecFn = execFn, dockerRunSafeFn = runSafeFn } = options;
|
|
363
|
+
const hostEnv = { ...process.env, HOME: home };
|
|
364
|
+
let signingKey = normalizeSigningKey(signingKeyOverride);
|
|
365
|
+
let resolvedSigningKey = Object.hasOwn(options, 'signingKey');
|
|
366
|
+
// Allow callers to supply a pre-computed cache read so we don't re-invoke
|
|
367
|
+
// `gpg --list-secret-keys` just to decide the progress message.
|
|
368
|
+
if (cachedOverride === null && !resolvedSigningKey) {
|
|
369
|
+
signingKey = getGitSigningKey({ repoPath, home, execFn });
|
|
370
|
+
resolvedSigningKey = true;
|
|
371
|
+
}
|
|
372
|
+
const cached = cachedOverride ?? readGpgCache(home, project, execFn, signingKey);
|
|
373
|
+
let pubKeys = cached?.pub ?? null;
|
|
374
|
+
let secKeys = cached?.sec ?? null;
|
|
375
|
+
if (!cached && !resolvedSigningKey) {
|
|
376
|
+
signingKey = getGitSigningKey({ repoPath, home, execFn });
|
|
377
|
+
resolvedSigningKey = true;
|
|
378
|
+
}
|
|
379
|
+
if (!cached) {
|
|
380
|
+
const exportArgs = signingKey ? ['--export', signingKey] : ['--export'];
|
|
381
|
+
const exportSecretArgs = signingKey
|
|
382
|
+
? ['--export-secret-keys', signingKey]
|
|
383
|
+
: ['--export-secret-keys'];
|
|
384
|
+
pubKeys = Buffer.from(execFn('gpg', exportArgs, {
|
|
385
|
+
env: hostEnv,
|
|
386
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
387
|
+
}));
|
|
388
|
+
if (!pubKeys || pubKeys.length === 0) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
secKeys = Buffer.from(execFn('gpg', exportSecretArgs, {
|
|
392
|
+
env: hostEnv,
|
|
393
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
394
|
+
}));
|
|
395
|
+
if (!secKeys || secKeys.length === 0) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
const fingerprint = currentKeyringFingerprint(home, execFn);
|
|
399
|
+
if (fingerprint) {
|
|
400
|
+
const written = writeGpgCache(home, project, pubKeys, secKeys, fingerprint, signingKey);
|
|
401
|
+
if (!written) {
|
|
402
|
+
process.stderr.write('Warning: failed to cache GPG keys; next sandbox create may prompt again.\n');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
dockerExecFn('docker', ['exec', '-i', container, 'gpg', '--import'], {
|
|
407
|
+
input: pubKeys ?? undefined,
|
|
408
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
409
|
+
});
|
|
410
|
+
dockerExecFn('docker', ['exec', '-i', container, 'gpg', '--batch', '--import'], {
|
|
411
|
+
input: secKeys ?? undefined,
|
|
412
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
413
|
+
});
|
|
414
|
+
dockerRunSafeFn('docker', ['exec', container, 'gpgconf', '--launch', 'gpg-agent']);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
// Docker `--env-file` parsing has no quoting/escaping support and treats
|
|
418
|
+
// leading '#' as a comment. Newlines split entries, so reject them outright.
|
|
419
|
+
// Other shell metacharacters are safe because the values are not expanded.
|
|
420
|
+
function formatEnvFileEntry(key, value) {
|
|
421
|
+
if (String(key).includes('\n') || String(value).includes('\n')) {
|
|
422
|
+
throw new Error(`Container environment variable ${key} must not contain newlines`);
|
|
423
|
+
}
|
|
424
|
+
return `${key}=${value}`;
|
|
425
|
+
}
|
|
426
|
+
export function buildContainerEnvFile(resolvedTools, engine, runSafeEngineFn = runSafeEngine, options = {}) {
|
|
427
|
+
const { mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, chmodFn = fs.chmodSync, rmFn = fs.rmSync, tmpDir = os.tmpdir() } = options;
|
|
428
|
+
const entries = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
|
|
429
|
+
const ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
|
|
430
|
+
if (ghToken) {
|
|
431
|
+
entries.push(['GH_TOKEN', ghToken]);
|
|
432
|
+
}
|
|
433
|
+
if (entries.length === 0) {
|
|
434
|
+
return { dockerArgs: [], cleanup: () => { } };
|
|
435
|
+
}
|
|
436
|
+
const dir = mkdtempFn(path.join(tmpDir, 'agent-infra-env-'));
|
|
437
|
+
try {
|
|
438
|
+
chmodFn(dir, 0o700);
|
|
439
|
+
const envPath = path.join(dir, 'env');
|
|
440
|
+
const content = `${entries.map(([key, value]) => formatEnvFileEntry(key, value)).join('\n')}\n`;
|
|
441
|
+
writeFileFn(envPath, content, { mode: 0o600 });
|
|
442
|
+
chmodFn(envPath, 0o600);
|
|
443
|
+
return {
|
|
444
|
+
dockerArgs: ['--env-file', toEnginePath(engine, envPath)],
|
|
445
|
+
cleanup: () => {
|
|
446
|
+
try {
|
|
447
|
+
rmFn(dir, { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// Best-effort cleanup only.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
try {
|
|
457
|
+
rmFn(dir, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// Best-effort cleanup only.
|
|
461
|
+
}
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
export function buildDotfilesVolumeArgs(engine, snapshotDir, existsFn = fs.existsSync) {
|
|
466
|
+
if (!snapshotDir || !existsFn(snapshotDir)) {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
return ['-v', volumeArg(engine, snapshotDir, '/dotfiles', ':ro')];
|
|
470
|
+
}
|
|
471
|
+
export function assertBranchAvailable(repoRoot, branch, { allowedWorktrees = [], runFn = runSafe } = {}) {
|
|
472
|
+
const normalizedAllowedWorktrees = new Set(allowedWorktrees.map((worktree) => normalizeWorktreePath(worktree)));
|
|
473
|
+
const output = runFn('git', ['-C', repoRoot, 'worktree', 'list', '--porcelain']);
|
|
474
|
+
if (!output) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
let currentWorktree = '';
|
|
478
|
+
for (const line of output.split('\n')) {
|
|
479
|
+
if (line.startsWith('worktree ')) {
|
|
480
|
+
currentWorktree = line.slice('worktree '.length).trim();
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (!line.startsWith('branch refs/heads/')) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const usedBranch = line.slice('branch refs/heads/'.length).trim();
|
|
487
|
+
if (usedBranch === branch) {
|
|
488
|
+
if (normalizedAllowedWorktrees.has(normalizeWorktreePath(currentWorktree))) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
throw new Error(`Branch '${branch}' is already checked out at '${currentWorktree}'.\n`
|
|
492
|
+
+ `Use a different branch name, or run 'git switch <other>' in that worktree first.`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function readHostJsonSafe(filePath) {
|
|
497
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
502
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
export function ensureClaudeOnboarding(toolDir, hostHomeDir) {
|
|
509
|
+
const claudeJsonPath = path.join(toolDir, '.claude.json');
|
|
510
|
+
let data = {};
|
|
511
|
+
if (fs.existsSync(claudeJsonPath)) {
|
|
512
|
+
try {
|
|
513
|
+
data = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// malformed JSON, start fresh
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
let changed = false;
|
|
520
|
+
if (!data.hasCompletedOnboarding) {
|
|
521
|
+
data.hasCompletedOnboarding = true;
|
|
522
|
+
changed = true;
|
|
523
|
+
}
|
|
524
|
+
if (!data.projects) {
|
|
525
|
+
data.projects = {};
|
|
526
|
+
changed = true;
|
|
527
|
+
}
|
|
528
|
+
if (!data.projects['/workspace']) {
|
|
529
|
+
data.projects['/workspace'] = {};
|
|
530
|
+
changed = true;
|
|
531
|
+
}
|
|
532
|
+
if (!data.projects['/workspace'].hasTrustDialogAccepted) {
|
|
533
|
+
data.projects['/workspace'].hasTrustDialogAccepted = true;
|
|
534
|
+
changed = true;
|
|
535
|
+
}
|
|
536
|
+
if (hostHomeDir) {
|
|
537
|
+
const hostClaudeJson = readHostJsonSafe(path.join(hostHomeDir, '.claude.json'));
|
|
538
|
+
if (hostClaudeJson
|
|
539
|
+
&& typeof hostClaudeJson.model === 'string'
|
|
540
|
+
&& hostClaudeJson.model !== ''
|
|
541
|
+
&& !Object.hasOwn(data, 'model')) {
|
|
542
|
+
data.model = hostClaudeJson.model;
|
|
543
|
+
changed = true;
|
|
544
|
+
}
|
|
545
|
+
// Claude Code launch-pins a default effort per model generation (for
|
|
546
|
+
// example xhigh for Opus 4.7). The saved effortLevel is honored only after
|
|
547
|
+
// a top-level boolean `unpin*LaunchEffort: true` flag unlocks it, so mirror
|
|
548
|
+
// those flags here with the existing first-write semantics.
|
|
549
|
+
//
|
|
550
|
+
// Pattern matching avoids one patch per future model generation. If
|
|
551
|
+
// Anthropic changes the naming convention, this block will no-op and should
|
|
552
|
+
// be revisited.
|
|
553
|
+
if (hostClaudeJson) {
|
|
554
|
+
for (const key of Object.keys(hostClaudeJson)) {
|
|
555
|
+
if (/^unpin.*LaunchEffort$/.test(key)
|
|
556
|
+
&& hostClaudeJson[key] === true
|
|
557
|
+
&& !Object.hasOwn(data, key)) {
|
|
558
|
+
data[key] = true;
|
|
559
|
+
changed = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (changed) {
|
|
565
|
+
fs.writeFileSync(claudeJsonPath, JSON.stringify(data, null, 4), 'utf8');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
export function ensureClaudeSettings(toolDir, hostHomeDir) {
|
|
569
|
+
const settingsPath = path.join(toolDir, 'settings.json');
|
|
570
|
+
let data = {};
|
|
571
|
+
if (fs.existsSync(settingsPath)) {
|
|
572
|
+
try {
|
|
573
|
+
data = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
// malformed JSON, start fresh
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
let changed = false;
|
|
580
|
+
if (data.skipDangerousModePermissionPrompt !== true) {
|
|
581
|
+
data.skipDangerousModePermissionPrompt = true;
|
|
582
|
+
changed = true;
|
|
583
|
+
}
|
|
584
|
+
if (hostHomeDir) {
|
|
585
|
+
const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
|
|
586
|
+
if (hostSettings
|
|
587
|
+
&& typeof hostSettings.effortLevel === 'string'
|
|
588
|
+
&& hostSettings.effortLevel !== ''
|
|
589
|
+
&& !Object.hasOwn(data, 'effortLevel')) {
|
|
590
|
+
data.effortLevel = hostSettings.effortLevel;
|
|
591
|
+
changed = true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (changed) {
|
|
595
|
+
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 4), 'utf8');
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
export function ensureCodexModelInheritance(toolDir, hostHomeDir) {
|
|
599
|
+
if (!hostHomeDir) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const hostConfigPath = path.join(hostHomeDir, '.codex', 'config.toml');
|
|
603
|
+
if (!fs.existsSync(hostConfigPath)) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
let hostParsed;
|
|
607
|
+
try {
|
|
608
|
+
hostParsed = toml.parse(fs.readFileSync(hostConfigPath, 'utf8'));
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const sandboxConfigPath = path.join(toolDir, 'config.toml');
|
|
614
|
+
// This rewrites sandbox-side TOML and drops comments; the host config stays untouched.
|
|
615
|
+
let sandboxParsed = {};
|
|
616
|
+
if (fs.existsSync(sandboxConfigPath)) {
|
|
617
|
+
try {
|
|
618
|
+
sandboxParsed = toml.parse(fs.readFileSync(sandboxConfigPath, 'utf8'));
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
let changed = false;
|
|
625
|
+
for (const key of ['model', 'model_reasoning_effort']) {
|
|
626
|
+
if (Object.hasOwn(sandboxParsed, key)) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const value = hostParsed[key];
|
|
630
|
+
if (typeof value !== 'string' || value === '') {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
sandboxParsed[key] = value;
|
|
634
|
+
changed = true;
|
|
635
|
+
}
|
|
636
|
+
if (changed) {
|
|
637
|
+
fs.writeFileSync(sandboxConfigPath, `${toml.stringify(sandboxParsed)}\n`, 'utf8');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
export function ensureCodexWorkspaceTrust(toolDir) {
|
|
641
|
+
const configPath = path.join(toolDir, 'config.toml');
|
|
642
|
+
let content = '';
|
|
643
|
+
if (fs.existsSync(configPath)) {
|
|
644
|
+
content = fs.readFileSync(configPath, 'utf8');
|
|
645
|
+
}
|
|
646
|
+
if (!content.includes('[projects."/workspace"]')) {
|
|
647
|
+
const entry = '\n[projects."/workspace"]\ntrust_level = "trusted"\n';
|
|
648
|
+
fs.writeFileSync(configPath, content + entry, 'utf8');
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
export function ensureOpenCodeModelInheritance(toolDir, hostHomeDir) {
|
|
652
|
+
if (!hostHomeDir) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const hostConfigPath = path.join(hostHomeDir, '.config', 'opencode', 'opencode.json');
|
|
656
|
+
const hostJson = readHostJsonSafe(hostConfigPath);
|
|
657
|
+
if (!hostJson) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const sandboxConfigPath = path.join(toolDir, 'opencode.json');
|
|
661
|
+
let sandboxJson = {};
|
|
662
|
+
if (fs.existsSync(sandboxConfigPath)) {
|
|
663
|
+
const existing = readHostJsonSafe(sandboxConfigPath);
|
|
664
|
+
if (!existing) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
sandboxJson = existing;
|
|
668
|
+
}
|
|
669
|
+
let changed = false;
|
|
670
|
+
for (const key of ['model', 'small_model']) {
|
|
671
|
+
if (Object.hasOwn(sandboxJson, key)) {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const value = hostJson[key];
|
|
675
|
+
if (typeof value !== 'string' || value === '') {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
sandboxJson[key] = value;
|
|
679
|
+
changed = true;
|
|
680
|
+
}
|
|
681
|
+
if (changed) {
|
|
682
|
+
fs.writeFileSync(sandboxConfigPath, JSON.stringify(sandboxJson, null, 2), 'utf8');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
export function ensureGeminiWorkspaceTrust(toolDir) {
|
|
686
|
+
const trustPath = path.join(toolDir, 'trustedFolders.json');
|
|
687
|
+
let data = {};
|
|
688
|
+
if (fs.existsSync(trustPath)) {
|
|
689
|
+
try {
|
|
690
|
+
data = JSON.parse(fs.readFileSync(trustPath, 'utf8'));
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// malformed JSON, start fresh
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (data['/workspace'] !== 'TRUST_FOLDER') {
|
|
697
|
+
data['/workspace'] = 'TRUST_FOLDER';
|
|
698
|
+
fs.writeFileSync(trustPath, JSON.stringify(data, null, 2), 'utf8');
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
export function sandboxAliasesPath(home) {
|
|
702
|
+
return hostJoin(home, '.agent-infra', 'aliases', 'sandbox.sh');
|
|
703
|
+
}
|
|
704
|
+
function escapeRegExp(value) {
|
|
705
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
706
|
+
}
|
|
707
|
+
function stripManagedSandboxAliasBlocks(content) {
|
|
708
|
+
const blockPattern = new RegExp(`${escapeRegExp(SANDBOX_ALIAS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRegExp(SANDBOX_ALIAS_BLOCK_END)}\\n?`, 'g');
|
|
709
|
+
return content.replace(blockPattern, '').trimEnd();
|
|
710
|
+
}
|
|
711
|
+
function isLegacyManagedSandboxAliasFile(content) {
|
|
712
|
+
const lines = content
|
|
713
|
+
.split(/\r?\n/)
|
|
714
|
+
.map((line) => line.trim())
|
|
715
|
+
.filter(Boolean);
|
|
716
|
+
if (lines.length === 0) {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
const aliasPattern = new RegExp(`^alias (${SANDBOX_ALIAS_NAMES.map(escapeRegExp).join('|')})=`);
|
|
720
|
+
return lines.every((line) => aliasPattern.test(line));
|
|
721
|
+
}
|
|
722
|
+
export function ensureSandboxAliasesFile(home) {
|
|
723
|
+
const aliasesPath = sandboxAliasesPath(home);
|
|
724
|
+
const managedBlock = `${SANDBOX_ALIAS_BLOCK_BEGIN}\n${DEFAULT_SANDBOX_ALIASES}${SANDBOX_ALIAS_BLOCK_END}\n`;
|
|
725
|
+
fs.mkdirSync(path.dirname(aliasesPath), { recursive: true });
|
|
726
|
+
const created = !fs.existsSync(aliasesPath);
|
|
727
|
+
let existing = '';
|
|
728
|
+
if (!created) {
|
|
729
|
+
existing = fs.readFileSync(aliasesPath, 'utf8');
|
|
730
|
+
}
|
|
731
|
+
const userContent = isLegacyManagedSandboxAliasFile(existing)
|
|
732
|
+
? ''
|
|
733
|
+
: stripManagedSandboxAliasBlocks(existing);
|
|
734
|
+
const nextContent = userContent
|
|
735
|
+
? `${userContent}\n\n${managedBlock}`
|
|
736
|
+
: managedBlock;
|
|
737
|
+
if (created || nextContent !== existing) {
|
|
738
|
+
fs.writeFileSync(aliasesPath, nextContent, 'utf8');
|
|
739
|
+
}
|
|
740
|
+
return { created, path: aliasesPath };
|
|
741
|
+
}
|
|
742
|
+
export function commandErrorMessage(error) {
|
|
743
|
+
const stderr = typeof error === 'object' && error !== null && 'stderr' in error
|
|
744
|
+
? String(error.stderr).trim()
|
|
745
|
+
: '';
|
|
746
|
+
const message = error instanceof Error
|
|
747
|
+
? error.message
|
|
748
|
+
: typeof error === 'object' && error !== null && 'message' in error
|
|
749
|
+
? String(error.message)
|
|
750
|
+
: 'Command failed';
|
|
751
|
+
return redactCommandError(stderr || message);
|
|
752
|
+
}
|
|
753
|
+
function runTaskCommand(cmd, args, opts = {}) {
|
|
754
|
+
try {
|
|
755
|
+
return run(cmd, args, opts);
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
throw new Error(commandErrorMessage(error));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function runEngineTaskCommand(engine, cmd, args, opts = {}) {
|
|
762
|
+
const command = commandForEngine(engine, cmd, args);
|
|
763
|
+
return runTaskCommand(command.cmd, command.args, opts);
|
|
764
|
+
}
|
|
765
|
+
export function buildImage(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, runVerboseFn = runVerboseEngine, env = process.env } = {}) {
|
|
766
|
+
const selectedEngine = engine ?? detectEngine(config);
|
|
767
|
+
const { uid: hostUid, gid: hostGid } = resolveBuildUid({
|
|
768
|
+
engine: selectedEngine,
|
|
769
|
+
runFn,
|
|
770
|
+
runSafeFn,
|
|
771
|
+
env
|
|
772
|
+
});
|
|
773
|
+
runVerboseFn(selectedEngine, 'docker', [
|
|
774
|
+
'build',
|
|
775
|
+
'-t',
|
|
776
|
+
config.imageName,
|
|
777
|
+
'--build-arg',
|
|
778
|
+
`HOST_UID=${hostUid}`,
|
|
779
|
+
'--build-arg',
|
|
780
|
+
`HOST_GID=${hostGid}`,
|
|
781
|
+
'--build-arg',
|
|
782
|
+
`AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
|
|
783
|
+
'--label',
|
|
784
|
+
sandboxLabel(config),
|
|
785
|
+
'--label',
|
|
786
|
+
`${sandboxImageConfigLabel(config)}=${imageSignature}`,
|
|
787
|
+
'-f',
|
|
788
|
+
toEnginePath(selectedEngine, dockerfilePath),
|
|
789
|
+
toEnginePath(selectedEngine, config.repoRoot)
|
|
790
|
+
], { cwd: config.repoRoot });
|
|
791
|
+
}
|
|
792
|
+
export async function create(args) {
|
|
793
|
+
const { values, positionals } = parseArgs({
|
|
794
|
+
args,
|
|
795
|
+
allowPositionals: true,
|
|
796
|
+
strict: true,
|
|
797
|
+
options: {
|
|
798
|
+
cpu: { type: 'string' },
|
|
799
|
+
memory: { type: 'string' },
|
|
800
|
+
help: { type: 'boolean', short: 'h' }
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
if (values.help) {
|
|
804
|
+
process.stdout.write(`${USAGE}\n`);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (positionals.length < 1 || positionals.length > 2) {
|
|
808
|
+
throw new Error(USAGE);
|
|
809
|
+
}
|
|
810
|
+
validateSelinuxDisableEnv();
|
|
811
|
+
validateClaudeCredentialsEnvOverride();
|
|
812
|
+
const config = loadConfig();
|
|
813
|
+
const [branchOrTaskId = '', base] = positionals;
|
|
814
|
+
const branch = resolveTaskBranch(branchOrTaskId, config.repoRoot);
|
|
815
|
+
assertValidBranchName(branch);
|
|
816
|
+
const effectiveConfig = {
|
|
817
|
+
...config,
|
|
818
|
+
vm: {
|
|
819
|
+
...config.vm,
|
|
820
|
+
cpu: parsePositiveIntegerOption(values.cpu, '--cpu') ?? config.vm.cpu,
|
|
821
|
+
memory: parsePositiveIntegerOption(values.memory, '--memory') ?? config.vm.memory
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
const worktreeCandidates = worktreeDirCandidates(effectiveConfig, branch);
|
|
825
|
+
assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
|
|
826
|
+
const tools = resolveTools(effectiveConfig);
|
|
827
|
+
const resolvedTools = resolveToolDirs(effectiveConfig, tools, branch);
|
|
828
|
+
// Fail fast before any filesystem/docker side effects so a missing
|
|
829
|
+
// Claude Code credential blob doesn't leave the user with a stale
|
|
830
|
+
// worktree, docker image, or temporary Dockerfile they need to manually
|
|
831
|
+
// clean up.
|
|
832
|
+
assertClaudeCredentialsAvailable(effectiveConfig.home, effectiveConfig.project, resolvedTools);
|
|
833
|
+
const container = containerName(effectiveConfig, branch);
|
|
834
|
+
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
|
|
835
|
+
const shareCommon = shareCommonDir(effectiveConfig);
|
|
836
|
+
const shareBranch = shareBranchDir(effectiveConfig, branch);
|
|
837
|
+
const preparedDockerfile = prepareDockerfile(effectiveConfig);
|
|
838
|
+
const baseBranch = base ?? runSafe('git', ['-C', effectiveConfig.repoRoot, 'branch', '--show-current']);
|
|
839
|
+
const expectedImageSignature = buildSignature(preparedDockerfile, tools);
|
|
840
|
+
const engine = detectEngine(effectiveConfig);
|
|
841
|
+
p.intro(pc.cyan('AI Sandbox'));
|
|
842
|
+
p.log.info(`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`);
|
|
843
|
+
try {
|
|
844
|
+
p.log.step('Checking container engine...');
|
|
845
|
+
await ensureDocker(effectiveConfig, (detail) => {
|
|
846
|
+
p.log.info(` ${detail}`);
|
|
847
|
+
});
|
|
848
|
+
p.log.success('Docker is ready');
|
|
849
|
+
const imageExists = runOkEngine(engine, 'docker', ['image', 'inspect', effectiveConfig.imageName]);
|
|
850
|
+
const currentImageSignature = imageExists
|
|
851
|
+
? runSafeEngine(engine, 'docker', [
|
|
852
|
+
'image',
|
|
853
|
+
'inspect',
|
|
854
|
+
'--format',
|
|
855
|
+
`{{ index .Config.Labels "${sandboxImageConfigLabel(effectiveConfig)}" }}`,
|
|
856
|
+
effectiveConfig.imageName
|
|
857
|
+
])
|
|
858
|
+
: '';
|
|
859
|
+
const needsImageBuild = !imageExists || currentImageSignature !== expectedImageSignature;
|
|
860
|
+
if (needsImageBuild) {
|
|
861
|
+
p.log.step(imageExists ? 'Rebuilding stale image...' : 'Building image for first use...');
|
|
862
|
+
buildImage(effectiveConfig, tools, preparedDockerfile.path, expectedImageSignature, { engine });
|
|
863
|
+
p.log.success(imageExists ? 'Image rebuilt' : 'Image built');
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
p.log.step(`Using existing image ${effectiveConfig.imageName}`);
|
|
867
|
+
}
|
|
868
|
+
await p.tasks([
|
|
869
|
+
{
|
|
870
|
+
title: 'Setting up git worktree',
|
|
871
|
+
task: async (message) => {
|
|
872
|
+
if (fs.existsSync(worktree)) {
|
|
873
|
+
if (fs.readdirSync(worktree).length > 0) {
|
|
874
|
+
return `Worktree exists at ${worktree}`;
|
|
875
|
+
}
|
|
876
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
877
|
+
}
|
|
878
|
+
const branchExists = runOk('git', [
|
|
879
|
+
'-C',
|
|
880
|
+
effectiveConfig.repoRoot,
|
|
881
|
+
'show-ref',
|
|
882
|
+
'--verify',
|
|
883
|
+
'--quiet',
|
|
884
|
+
`refs/heads/${branch}`
|
|
885
|
+
]);
|
|
886
|
+
if (branchExists) {
|
|
887
|
+
message(`Using existing branch '${branch}'...`);
|
|
888
|
+
runEngineTaskCommand(engine, 'git', [
|
|
889
|
+
'-C',
|
|
890
|
+
toEnginePath(engine, effectiveConfig.repoRoot),
|
|
891
|
+
'worktree',
|
|
892
|
+
'add',
|
|
893
|
+
toEnginePath(engine, worktree),
|
|
894
|
+
branch
|
|
895
|
+
]);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
message(`Creating branch '${branch}' from '${baseBranch}'...`);
|
|
899
|
+
runEngineTaskCommand(engine, 'git', [
|
|
900
|
+
'-C',
|
|
901
|
+
toEnginePath(engine, effectiveConfig.repoRoot),
|
|
902
|
+
'worktree',
|
|
903
|
+
'add',
|
|
904
|
+
'-b',
|
|
905
|
+
branch,
|
|
906
|
+
toEnginePath(engine, worktree),
|
|
907
|
+
baseBranch
|
|
908
|
+
]);
|
|
909
|
+
}
|
|
910
|
+
return `Worktree ready at ${worktree}`;
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
title: 'Preparing tool state',
|
|
915
|
+
task: async () => {
|
|
916
|
+
for (const { tool, dir } of resolvedTools) {
|
|
917
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
918
|
+
for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
|
|
919
|
+
const destination = path.join(dir, sandboxName);
|
|
920
|
+
if (fs.existsSync(hostPath) && !fs.existsSync(destination)) {
|
|
921
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
922
|
+
fs.copyFileSync(hostPath, destination);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const { hostDir, sandboxSubdir } of tool.hostPreSeedDirs ?? []) {
|
|
926
|
+
const destination = path.join(dir, sandboxSubdir);
|
|
927
|
+
if (fs.existsSync(hostDir) && !fs.existsSync(destination)) {
|
|
928
|
+
fs.cpSync(hostDir, destination, { recursive: true });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
for (const relativePath of tool.pathRewriteFiles ?? []) {
|
|
932
|
+
const filePath = path.join(dir, relativePath);
|
|
933
|
+
if (!fs.existsSync(filePath)) {
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
937
|
+
const containerHome = path.posix.dirname(tool.containerMount);
|
|
938
|
+
for (const hostPath of [effectiveConfig.repoRoot, effectiveConfig.home]) {
|
|
939
|
+
const replacement = hostPath === effectiveConfig.repoRoot ? '/workspace' : containerHome;
|
|
940
|
+
content = content.replaceAll(hostPath, replacement);
|
|
941
|
+
const posixHostPath = hostPath.replaceAll('\\', '/');
|
|
942
|
+
if (posixHostPath !== hostPath) {
|
|
943
|
+
content = content.replaceAll(posixHostPath, replacement);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return `${resolvedTools.length} tool config directories ready`;
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
title: `Starting container '${container}'`,
|
|
954
|
+
task: async (message) => {
|
|
955
|
+
const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
956
|
+
const matchedContainers = containerNameCandidates(effectiveConfig, branch)
|
|
957
|
+
.filter((name) => existing.includes(name));
|
|
958
|
+
if (matchedContainers.length > 0) {
|
|
959
|
+
message('Removing old container instance...');
|
|
960
|
+
for (const name of matchedContainers) {
|
|
961
|
+
runSafeEngine(engine, 'docker', ['stop', name]);
|
|
962
|
+
runSafeEngine(engine, 'docker', ['rm', name]);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const aliasesFile = ensureSandboxAliasesFile(effectiveConfig.home);
|
|
966
|
+
if (aliasesFile.created) {
|
|
967
|
+
message(`Created default sandbox aliases at ${aliasesFile.path}`);
|
|
968
|
+
}
|
|
969
|
+
const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
|
|
970
|
+
const gitconfigContent = fs.existsSync(gitconfigPath)
|
|
971
|
+
? fs.readFileSync(gitconfigPath, 'utf8')
|
|
972
|
+
: '';
|
|
973
|
+
const needsGpg = detectGpgConfig(gitconfigContent);
|
|
974
|
+
const hasHostGpgKeys = needsGpg && hostHasGpgKeys(effectiveConfig.home);
|
|
975
|
+
const signingKey = needsGpg
|
|
976
|
+
? getGitSigningKey({ repoPath: worktree, home: effectiveConfig.home })
|
|
977
|
+
: null;
|
|
978
|
+
const cachedGpg = needsGpg
|
|
979
|
+
? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
|
|
980
|
+
: null;
|
|
981
|
+
const envFile = buildContainerEnvFile(resolvedTools, engine);
|
|
982
|
+
let hostShellConfig;
|
|
983
|
+
try {
|
|
984
|
+
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
985
|
+
if (claudeCodeEntry) {
|
|
986
|
+
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
987
|
+
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
988
|
+
// Credential availability is asserted up-front in create() so we
|
|
989
|
+
// know the shared credentials file already exists at this point.
|
|
990
|
+
}
|
|
991
|
+
const codexEntry = resolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
992
|
+
if (codexEntry) {
|
|
993
|
+
ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
|
|
994
|
+
ensureCodexWorkspaceTrust(codexEntry.dir);
|
|
995
|
+
}
|
|
996
|
+
const geminiEntry = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
|
|
997
|
+
if (geminiEntry) {
|
|
998
|
+
ensureGeminiWorkspaceTrust(geminiEntry.dir);
|
|
999
|
+
}
|
|
1000
|
+
const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
|
|
1001
|
+
if (opencodeEntry) {
|
|
1002
|
+
// The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
|
|
1003
|
+
ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
|
|
1004
|
+
}
|
|
1005
|
+
const toolVolumes = resolvedTools.flatMap(({ tool, dir }) => [
|
|
1006
|
+
'-v',
|
|
1007
|
+
volumeArg(engine, dir, tool.containerMount)
|
|
1008
|
+
]);
|
|
1009
|
+
const workspaceDir = path.join(effectiveConfig.repoRoot, '.agents', 'workspace');
|
|
1010
|
+
hostShellConfig = prepareHostShellConfig({
|
|
1011
|
+
home: effectiveConfig.home,
|
|
1012
|
+
project: effectiveConfig.project,
|
|
1013
|
+
branch,
|
|
1014
|
+
repoRoot: effectiveConfig.repoRoot
|
|
1015
|
+
});
|
|
1016
|
+
const shellConfigVolumes = hostShellConfig.mounts.flatMap(({ hostPath, containerPath }) => [
|
|
1017
|
+
'-v',
|
|
1018
|
+
volumeArg(engine, hostPath, containerPath, ':ro')
|
|
1019
|
+
]);
|
|
1020
|
+
const liveMountVolumes = resolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
|
|
1021
|
+
.filter(({ hostPath }) => fs.existsSync(hostPath))
|
|
1022
|
+
.flatMap(({ hostPath, containerSubpath }) => [
|
|
1023
|
+
'-v',
|
|
1024
|
+
volumeArg(engine, hostPath, path.posix.join(tool.containerMount, containerSubpath))
|
|
1025
|
+
]));
|
|
1026
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
1027
|
+
fs.mkdirSync(shareCommon, { recursive: true });
|
|
1028
|
+
fs.mkdirSync(shareBranch, { recursive: true });
|
|
1029
|
+
const dotfilesSnapshot = materializeDotfiles(effectiveConfig.dotfilesDir, dotfilesCacheDir(effectiveConfig.home, effectiveConfig.project));
|
|
1030
|
+
const dotfilesMount = dotfilesSnapshot
|
|
1031
|
+
? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
|
|
1032
|
+
: [];
|
|
1033
|
+
runEngineTaskCommand(engine, 'docker', [
|
|
1034
|
+
'run',
|
|
1035
|
+
'-d',
|
|
1036
|
+
'--name',
|
|
1037
|
+
container,
|
|
1038
|
+
'--hostname',
|
|
1039
|
+
`${effectiveConfig.project}-sandbox`,
|
|
1040
|
+
'--label',
|
|
1041
|
+
sandboxLabel(effectiveConfig),
|
|
1042
|
+
'--label',
|
|
1043
|
+
`${sandboxBranchLabel(effectiveConfig)}=${branch}`,
|
|
1044
|
+
'-v',
|
|
1045
|
+
volumeArg(engine, worktree, '/workspace'),
|
|
1046
|
+
'-v',
|
|
1047
|
+
volumeArg(engine, workspaceDir, '/workspace/.agents/workspace'),
|
|
1048
|
+
'-v',
|
|
1049
|
+
volumeArg(engine, shareCommon, '/share/common'),
|
|
1050
|
+
'-v',
|
|
1051
|
+
volumeArg(engine, shareBranch, '/share/branch'),
|
|
1052
|
+
'-v',
|
|
1053
|
+
volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
|
|
1054
|
+
'-v',
|
|
1055
|
+
volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
|
|
1056
|
+
...dotfilesMount,
|
|
1057
|
+
...toolVolumes,
|
|
1058
|
+
...liveMountVolumes,
|
|
1059
|
+
...shellConfigVolumes,
|
|
1060
|
+
...envFile.dockerArgs,
|
|
1061
|
+
'-w',
|
|
1062
|
+
'/workspace',
|
|
1063
|
+
effectiveConfig.imageName
|
|
1064
|
+
]);
|
|
1065
|
+
}
|
|
1066
|
+
finally {
|
|
1067
|
+
envFile.cleanup();
|
|
1068
|
+
}
|
|
1069
|
+
// Belt-and-suspenders: re-create the four shell-config symlinks at
|
|
1070
|
+
// runtime so users with a custom `sandbox.dockerfile` (which won't
|
|
1071
|
+
// include the ai-tools.dockerfile symlink fragment) still get
|
|
1072
|
+
// ~/.gitconfig and friends pointing into the host bind-mount.
|
|
1073
|
+
// `ln -sf` is idempotent for the default image.
|
|
1074
|
+
ensureShellConfigSymlinks(engine, container);
|
|
1075
|
+
if (needsGpg) {
|
|
1076
|
+
message(cachedGpg
|
|
1077
|
+
? 'Syncing GPG keys from cache...'
|
|
1078
|
+
: hasHostGpgKeys
|
|
1079
|
+
? 'Syncing GPG keys (you may be prompted for your passphrase)...'
|
|
1080
|
+
: 'Checking GPG cache before falling back to stripped git config...');
|
|
1081
|
+
try {
|
|
1082
|
+
if (syncGpgKeys(container, effectiveConfig.home, effectiveConfig.project, undefined, undefined, {
|
|
1083
|
+
cachedOverride: cachedGpg,
|
|
1084
|
+
repoPath: worktree,
|
|
1085
|
+
signingKey,
|
|
1086
|
+
dockerExecFn: (cmd, args, opts) => execEngine(engine, cmd, args, opts),
|
|
1087
|
+
dockerRunSafeFn: (cmd, args, opts) => runSafeEngine(engine, cmd, args, opts)
|
|
1088
|
+
})) {
|
|
1089
|
+
writeSanitizedGitconfig({
|
|
1090
|
+
home: effectiveConfig.home,
|
|
1091
|
+
hostConfigDir: hostShellConfig.hostDir,
|
|
1092
|
+
stripGpg: false,
|
|
1093
|
+
repoRoot: effectiveConfig.repoRoot
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
message(hasHostGpgKeys
|
|
1098
|
+
? 'GPG key sync failed; using stripped git config fallback...'
|
|
1099
|
+
: 'Host GPG keys unavailable; using stripped git config fallback...');
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
message(hasHostGpgKeys
|
|
1104
|
+
? 'GPG key sync failed; using stripped git config fallback...'
|
|
1105
|
+
: 'Host GPG keys unavailable; using stripped git config fallback...');
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
for (const { tool } of resolvedTools) {
|
|
1109
|
+
for (const command of tool.postSetupCmds ?? []) {
|
|
1110
|
+
runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return 'Container started';
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
]);
|
|
1117
|
+
}
|
|
1118
|
+
finally {
|
|
1119
|
+
preparedDockerfile.cleanup();
|
|
1120
|
+
}
|
|
1121
|
+
p.log.step('Verifying setup...');
|
|
1122
|
+
const runningContainers = runSafeEngine(engine, 'docker', ['ps', '--format', '{{.Names}}']).split('\n');
|
|
1123
|
+
const checks = [
|
|
1124
|
+
{ name: 'Container running', ok: runningContainers.includes(container) },
|
|
1125
|
+
...runtimeChecks(effectiveConfig.runtimes).map((check) => ({
|
|
1126
|
+
name: check.name,
|
|
1127
|
+
ok: runOkEngine(engine, 'docker', ['exec', container, ...check.cmd])
|
|
1128
|
+
})),
|
|
1129
|
+
{ name: 'GitHub CLI', ok: runOkEngine(engine, 'docker', ['exec', container, 'gh', '--version']) }
|
|
1130
|
+
];
|
|
1131
|
+
const toolChecks = tools.map((tool) => ({
|
|
1132
|
+
name: tool.name,
|
|
1133
|
+
ok: runOkEngine(engine, 'docker', ['exec', container, 'bash', '-lc', tool.versionCmd]),
|
|
1134
|
+
hint: tool.setupHint
|
|
1135
|
+
}));
|
|
1136
|
+
for (const check of checks) {
|
|
1137
|
+
p.log.info(` ${check.ok ? pc.green('✓') : pc.yellow('?')} ${check.name}`);
|
|
1138
|
+
}
|
|
1139
|
+
for (const check of toolChecks) {
|
|
1140
|
+
p.log.info(` ${check.ok ? pc.green('✓') : pc.yellow('?')} ${check.name}`);
|
|
1141
|
+
if (!check.ok) {
|
|
1142
|
+
p.log.warn(` ${check.hint}`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
p.outro(pc.green('Sandbox ready'));
|
|
1146
|
+
const toolHints = resolvedTools.map(({ tool, dir }) => {
|
|
1147
|
+
const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
|
|
1148
|
+
const hint = hasLiveMount
|
|
1149
|
+
? 'Live-mounted auth/config files stay in sync with the host.'
|
|
1150
|
+
: tool.setupHint;
|
|
1151
|
+
return `${tool.name}: ${hint} Config dir: ${dir}`;
|
|
1152
|
+
}).join('\n');
|
|
1153
|
+
process.stdout.write(`
|
|
1154
|
+
Container: ${container}
|
|
1155
|
+
Image: ${effectiveConfig.imageName}
|
|
1156
|
+
Worktree: ${worktree}
|
|
1157
|
+
Host aliases: ${sandboxAliasesPath(effectiveConfig.home)}
|
|
1158
|
+
Share (common): ${shareCommon} -> /share/common
|
|
1159
|
+
Share (branch): ${shareBranch} -> /share/branch
|
|
1160
|
+
|
|
1161
|
+
Management:
|
|
1162
|
+
ai sandbox ls
|
|
1163
|
+
ai sandbox exec ${branch}
|
|
1164
|
+
ai sandbox rm ${branch}
|
|
1165
|
+
|
|
1166
|
+
Sandbox aliases:
|
|
1167
|
+
Edit the host aliases file to customize shortcuts exposed at ${CONTAINER_HOME}/.bash_aliases inside the sandbox container.
|
|
1168
|
+
|
|
1169
|
+
Tool notes:
|
|
1170
|
+
${toolHints}
|
|
1171
|
+
`);
|
|
1172
|
+
}
|
|
1173
|
+
//# sourceMappingURL=create.js.map
|