@fitlab-ai/agent-infra 0.5.9 → 0.5.10
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 +200 -8
- package/README.zh-CN.md +176 -8
- package/bin/cli.js +2 -2
- package/lib/init.js +18 -4
- package/lib/sandbox/commands/create.js +467 -240
- package/lib/sandbox/commands/enter.js +59 -26
- package/lib/sandbox/commands/ls.js +37 -6
- package/lib/sandbox/commands/rebuild.js +31 -15
- package/lib/sandbox/commands/refresh.js +119 -0
- package/lib/sandbox/commands/rm.js +59 -11
- package/lib/sandbox/commands/vm.js +50 -6
- package/lib/sandbox/config.js +9 -5
- package/lib/sandbox/constants.js +15 -3
- package/lib/sandbox/credentials.js +520 -0
- package/lib/sandbox/dotfiles.js +189 -0
- package/lib/sandbox/engine.js +135 -192
- package/lib/sandbox/engines/colima.js +79 -0
- package/lib/sandbox/engines/docker-desktop.js +34 -0
- package/lib/sandbox/engines/index.js +27 -0
- package/lib/sandbox/engines/native.js +112 -0
- package/lib/sandbox/engines/orbstack.js +76 -0
- package/lib/sandbox/engines/selinux.js +60 -0
- package/lib/sandbox/engines/wsl2-paths.js +59 -0
- package/lib/sandbox/engines/wsl2.js +72 -0
- package/lib/sandbox/index.js +10 -1
- package/lib/sandbox/runtimes/ai-tools.dockerfile +14 -1
- package/lib/sandbox/runtimes/base.dockerfile +116 -1
- package/lib/sandbox/shell.js +53 -2
- package/lib/sandbox/tools.js +5 -5
- package/package.json +6 -4
- package/templates/.agents/rules/create-issue.github.en.md +2 -4
- package/templates/.agents/rules/create-issue.github.zh-CN.md +2 -4
- package/templates/.agents/rules/issue-pr-commands.github.en.md +29 -0
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +29 -0
- package/templates/.agents/scripts/{platform-adapters/find-existing-task.github.js → find-existing-task.js} +22 -79
- package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +26 -41
- package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +6 -8
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +6 -8
- package/templates/.agents/scripts/platform-adapters/find-existing-task.js +0 -5
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
4
5
|
import { execFileSync } from 'node:child_process';
|
|
5
6
|
import { parseArgs } from 'node:util';
|
|
6
7
|
import * as p from '@clack/prompts';
|
|
7
8
|
import pc from 'picocolors';
|
|
9
|
+
import * as toml from 'smol-toml';
|
|
8
10
|
import { loadConfig } from '../config.js';
|
|
9
11
|
import {
|
|
10
12
|
assertValidBranchName,
|
|
@@ -15,13 +17,34 @@ import {
|
|
|
15
17
|
sandboxImageConfigLabel,
|
|
16
18
|
sandboxLabel,
|
|
17
19
|
sanitizeBranchName,
|
|
20
|
+
shareBranchDir,
|
|
21
|
+
shareCommonDir,
|
|
18
22
|
worktreeDirCandidates
|
|
19
23
|
} from '../constants.js';
|
|
20
24
|
import { prepareDockerfile } from '../dockerfile.js';
|
|
21
|
-
import { ensureDocker } from '../engine.js';
|
|
22
|
-
import {
|
|
25
|
+
import { detectEngine, ensureDocker } from '../engine.js';
|
|
26
|
+
import {
|
|
27
|
+
commandForEngine,
|
|
28
|
+
execEngine,
|
|
29
|
+
run,
|
|
30
|
+
runEngine,
|
|
31
|
+
runOk,
|
|
32
|
+
runOkEngine,
|
|
33
|
+
runSafe,
|
|
34
|
+
runSafeEngine,
|
|
35
|
+
runVerboseEngine
|
|
36
|
+
} from '../shell.js';
|
|
23
37
|
import { resolveTaskBranch } from '../task-resolver.js';
|
|
24
38
|
import { resolveTools, toolConfigDirCandidates, toolNpmPackagesArg } from '../tools.js';
|
|
39
|
+
import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
|
|
40
|
+
import { validateSelinuxDisableEnv } from '../engines/selinux.js';
|
|
41
|
+
import { resolveBuildUid } from '../engines/native.js';
|
|
42
|
+
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
|
|
43
|
+
import {
|
|
44
|
+
assertClaudeCredentialsAvailable,
|
|
45
|
+
redactCommandError,
|
|
46
|
+
validateClaudeCredentialsEnvOverride
|
|
47
|
+
} from '../credentials.js';
|
|
25
48
|
|
|
26
49
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
27
50
|
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
@@ -47,11 +70,14 @@ alias xy='codex --yolo; tput ed'
|
|
|
47
70
|
alias gy='gemini --yolo; tput ed'
|
|
48
71
|
`;
|
|
49
72
|
const CONTAINER_HOME = '/home/devuser';
|
|
73
|
+
const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
|
|
50
74
|
const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>]
|
|
51
75
|
|
|
52
76
|
Host aliases:
|
|
53
|
-
${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and
|
|
54
|
-
${CONTAINER_HOME}/.bash_aliases inside the sandbox container
|
|
77
|
+
${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
|
|
78
|
+
as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
|
|
79
|
+
shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
|
|
80
|
+
symlinked into $HOME).`;
|
|
55
81
|
|
|
56
82
|
function buildSignature(preparedDockerfile, tools) {
|
|
57
83
|
return createHash('sha256')
|
|
@@ -63,10 +89,6 @@ function buildSignature(preparedDockerfile, tools) {
|
|
|
63
89
|
.slice(0, 12);
|
|
64
90
|
}
|
|
65
91
|
|
|
66
|
-
function hostJoin(basePath, ...segments) {
|
|
67
|
-
return basePath.startsWith('/') ? path.posix.join(basePath, ...segments) : path.join(basePath, ...segments);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
92
|
function resolveToolDirs(config, tools, branch) {
|
|
71
93
|
return tools.map((tool) => {
|
|
72
94
|
const candidates = toolConfigDirCandidates(tool, config.project, branch);
|
|
@@ -78,7 +100,7 @@ function resolveToolDirs(config, tools, branch) {
|
|
|
78
100
|
}
|
|
79
101
|
|
|
80
102
|
export function hostShellConfigDir(home, project, branch) {
|
|
81
|
-
return
|
|
103
|
+
return hostJoin(home, '.agent-infra', 'config', project, sanitizeBranchName(branch));
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
function runtimeChecks(runtimes) {
|
|
@@ -162,9 +184,17 @@ function appendSafeDirectories(lines, repoRoot) {
|
|
|
162
184
|
return updatedLines;
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
function normalizeContainerHomeSeparators(content) {
|
|
188
|
+
const containerHomePattern = new RegExp(`${escapeRegExp(CONTAINER_HOME)}\\S*`, 'g');
|
|
189
|
+
return content.replace(containerHomePattern, (value) => value.replaceAll('\\', '/'));
|
|
190
|
+
}
|
|
191
|
+
|
|
165
192
|
export function sanitizeGitConfig(gitconfig, home, { stripGpg = false, repoRoot = '' } = {}) {
|
|
166
|
-
const
|
|
193
|
+
const posixHome = home.replaceAll('\\', '/');
|
|
194
|
+
const normalizedGitconfig = gitconfig
|
|
167
195
|
.replaceAll(home, CONTAINER_HOME)
|
|
196
|
+
.replaceAll(posixHome, CONTAINER_HOME);
|
|
197
|
+
const lines = normalizeContainerHomeSeparators(normalizedGitconfig)
|
|
168
198
|
.replace(/\[difftool "sourcetree"\][^\[]*/gs, '')
|
|
169
199
|
.replace(/\[mergetool "sourcetree"\][^\[]*/gs, '')
|
|
170
200
|
.split(/\r?\n/);
|
|
@@ -216,56 +246,65 @@ export function hostHasGpgKeys(home, execFn = execFileSync) {
|
|
|
216
246
|
}
|
|
217
247
|
|
|
218
248
|
export function writeSanitizedGitconfig({ home, hostConfigDir, stripGpg, repoRoot }) {
|
|
219
|
-
const gitconfigPath =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
249
|
+
const gitconfigPath = hostJoin(home, '.gitconfig');
|
|
250
|
+
// Always emit a sanitized .gitconfig, even when the host has none. The
|
|
251
|
+
// container ~/.gitconfig is a symlink into the bound shell-config directory;
|
|
252
|
+
// a missing file would leave the symlink dangling and drop the default
|
|
253
|
+
// safe.directory entries the image relies on.
|
|
254
|
+
const sourceContent = fs.existsSync(gitconfigPath)
|
|
255
|
+
? fs.readFileSync(gitconfigPath, 'utf8')
|
|
256
|
+
: '';
|
|
223
257
|
|
|
224
258
|
fs.mkdirSync(hostConfigDir, { recursive: true });
|
|
225
259
|
const targetPath = path.join(hostConfigDir, '.gitconfig');
|
|
226
|
-
const gitconfig = sanitizeGitConfig(
|
|
227
|
-
stripGpg,
|
|
228
|
-
repoRoot
|
|
229
|
-
});
|
|
260
|
+
const gitconfig = sanitizeGitConfig(sourceContent, home, { stripGpg, repoRoot });
|
|
230
261
|
fs.writeFileSync(targetPath, gitconfig, 'utf8');
|
|
231
262
|
return targetPath;
|
|
232
263
|
}
|
|
233
264
|
|
|
265
|
+
// Files inside the host shell-config bind that need to be exposed in $HOME.
|
|
266
|
+
// Keep in sync with the symlink block in lib/sandbox/runtimes/ai-tools.dockerfile.
|
|
267
|
+
const SHELL_CONFIG_SYMLINKS = ['.gitconfig', '.gitignore_global', '.stCommitMsg', '.bash_aliases'];
|
|
268
|
+
|
|
269
|
+
export function ensureShellConfigSymlinks(engine, container, execFn = execEngine) {
|
|
270
|
+
// Idempotent symlink setup. Runs against a started container so it also
|
|
271
|
+
// covers custom Dockerfiles that don't bake the symlinks into the image.
|
|
272
|
+
const script = SHELL_CONFIG_SYMLINKS
|
|
273
|
+
.map((file) => `ln -sf .host-shell-config/${file} ${CONTAINER_HOME}/${file}`)
|
|
274
|
+
.join(' && ');
|
|
275
|
+
execFn(engine, 'docker', ['exec', container, 'bash', '-lc', script], { stdio: 'ignore' });
|
|
276
|
+
}
|
|
277
|
+
|
|
234
278
|
export function prepareHostShellConfig({ home, project, branch, repoRoot }) {
|
|
235
279
|
const hostDir = hostShellConfigDir(home, project, branch);
|
|
236
280
|
fs.rmSync(hostDir, { recursive: true, force: true });
|
|
237
281
|
fs.mkdirSync(hostDir, { recursive: true });
|
|
238
282
|
|
|
239
|
-
|
|
240
|
-
const mounts = [];
|
|
241
|
-
const gitconfigPath = writeSanitizedGitconfig({
|
|
283
|
+
writeSanitizedGitconfig({
|
|
242
284
|
home,
|
|
243
285
|
hostConfigDir: hostDir,
|
|
244
286
|
stripGpg: true,
|
|
245
287
|
repoRoot
|
|
246
288
|
});
|
|
247
|
-
if (gitconfigPath) {
|
|
248
|
-
mounts.push({ hostPath: gitconfigPath, containerPath: `${CONTAINER_HOME}/.gitconfig` });
|
|
249
|
-
}
|
|
250
289
|
|
|
251
290
|
for (const file of ['.gitignore_global', '.stCommitMsg']) {
|
|
252
|
-
const hostPath =
|
|
291
|
+
const hostPath = hostJoin(home, file);
|
|
253
292
|
if (!fs.existsSync(hostPath)) {
|
|
254
293
|
continue;
|
|
255
294
|
}
|
|
256
295
|
|
|
257
|
-
|
|
258
|
-
fs.copyFileSync(hostPath, targetPath);
|
|
259
|
-
mounts.push({ hostPath: targetPath, containerPath: `${CONTAINER_HOME}/${file}` });
|
|
296
|
+
fs.copyFileSync(hostPath, path.join(hostDir, file));
|
|
260
297
|
}
|
|
261
298
|
|
|
262
299
|
const aliasesPath = sandboxAliasesPath(home);
|
|
263
300
|
if (fs.existsSync(aliasesPath)) {
|
|
264
|
-
|
|
265
|
-
fs.copyFileSync(aliasesPath, targetPath);
|
|
266
|
-
mounts.push({ hostPath: targetPath, containerPath: `${CONTAINER_HOME}/.bash_aliases` });
|
|
301
|
+
fs.copyFileSync(aliasesPath, path.join(hostDir, '.bash_aliases'));
|
|
267
302
|
}
|
|
268
303
|
|
|
304
|
+
// Single directory bind keeps virtiofs happy: per-file rewrites inside no
|
|
305
|
+
// longer race the bind layer like individual single-file binds do.
|
|
306
|
+
const mounts = [{ hostPath: hostDir, containerPath: CONTAINER_SHELL_CONFIG_MOUNT }];
|
|
307
|
+
|
|
269
308
|
return { hostDir, mounts };
|
|
270
309
|
}
|
|
271
310
|
|
|
@@ -410,7 +449,9 @@ export function syncGpgKeys(
|
|
|
410
449
|
const {
|
|
411
450
|
cachedOverride = null,
|
|
412
451
|
repoPath = null,
|
|
413
|
-
signingKey: signingKeyOverride
|
|
452
|
+
signingKey: signingKeyOverride,
|
|
453
|
+
dockerExecFn = execFn,
|
|
454
|
+
dockerRunSafeFn = runSafeFn
|
|
414
455
|
} = options;
|
|
415
456
|
const hostEnv = { ...process.env, HOME: home };
|
|
416
457
|
let signingKey = normalizeSigningKey(signingKeyOverride);
|
|
@@ -463,28 +504,86 @@ export function syncGpgKeys(
|
|
|
463
504
|
}
|
|
464
505
|
}
|
|
465
506
|
|
|
466
|
-
|
|
507
|
+
dockerExecFn('docker', ['exec', '-i', container, 'gpg', '--import'], {
|
|
467
508
|
input: pubKeys,
|
|
468
509
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
469
510
|
});
|
|
470
|
-
|
|
511
|
+
dockerExecFn('docker', ['exec', '-i', container, 'gpg', '--batch', '--import'], {
|
|
471
512
|
input: secKeys,
|
|
472
513
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
473
514
|
});
|
|
474
515
|
|
|
475
|
-
|
|
516
|
+
dockerRunSafeFn('docker', ['exec', container, 'gpgconf', '--launch', 'gpg-agent']);
|
|
476
517
|
return true;
|
|
477
518
|
}
|
|
478
519
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
520
|
+
// Docker `--env-file` parsing has no quoting/escaping support and treats
|
|
521
|
+
// leading '#' as a comment. Newlines split entries, so reject them outright.
|
|
522
|
+
// Other shell metacharacters are safe because the values are not expanded.
|
|
523
|
+
function formatEnvFileEntry(key, value) {
|
|
524
|
+
if (String(key).includes('\n') || String(value).includes('\n')) {
|
|
525
|
+
throw new Error(`Container environment variable ${key} must not contain newlines`);
|
|
526
|
+
}
|
|
527
|
+
return `${key}=${value}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function buildContainerEnvFile(
|
|
531
|
+
resolvedTools,
|
|
532
|
+
engine,
|
|
533
|
+
runSafeEngineFn = runSafeEngine,
|
|
534
|
+
options = {}
|
|
535
|
+
) {
|
|
536
|
+
const {
|
|
537
|
+
mkdtempFn = fs.mkdtempSync,
|
|
538
|
+
writeFileFn = fs.writeFileSync,
|
|
539
|
+
chmodFn = fs.chmodSync,
|
|
540
|
+
rmFn = fs.rmSync,
|
|
541
|
+
tmpDir = os.tmpdir()
|
|
542
|
+
} = options;
|
|
543
|
+
|
|
544
|
+
const entries = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
|
|
545
|
+
const ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
|
|
484
546
|
if (ghToken) {
|
|
485
|
-
|
|
547
|
+
entries.push(['GH_TOKEN', ghToken]);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (entries.length === 0) {
|
|
551
|
+
return { dockerArgs: [], cleanup: () => {} };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const dir = mkdtempFn(path.join(tmpDir, 'agent-infra-env-'));
|
|
555
|
+
try {
|
|
556
|
+
chmodFn(dir, 0o700);
|
|
557
|
+
const envPath = path.join(dir, 'env');
|
|
558
|
+
const content = `${entries.map(([key, value]) => formatEnvFileEntry(key, value)).join('\n')}\n`;
|
|
559
|
+
writeFileFn(envPath, content, { mode: 0o600 });
|
|
560
|
+
chmodFn(envPath, 0o600);
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
dockerArgs: ['--env-file', toEnginePath(engine, envPath)],
|
|
564
|
+
cleanup: () => {
|
|
565
|
+
try {
|
|
566
|
+
rmFn(dir, { recursive: true, force: true });
|
|
567
|
+
} catch {
|
|
568
|
+
// Best-effort cleanup only.
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
} catch (error) {
|
|
573
|
+
try {
|
|
574
|
+
rmFn(dir, { recursive: true, force: true });
|
|
575
|
+
} catch {
|
|
576
|
+
// Best-effort cleanup only.
|
|
577
|
+
}
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function buildDotfilesVolumeArgs(engine, snapshotDir, existsFn = fs.existsSync) {
|
|
583
|
+
if (!snapshotDir || !existsFn(snapshotDir)) {
|
|
584
|
+
return [];
|
|
486
585
|
}
|
|
487
|
-
return
|
|
586
|
+
return ['-v', volumeArg(engine, snapshotDir, '/dotfiles', ':ro')];
|
|
488
587
|
}
|
|
489
588
|
|
|
490
589
|
export function assertBranchAvailable(
|
|
@@ -521,7 +620,20 @@ export function assertBranchAvailable(
|
|
|
521
620
|
}
|
|
522
621
|
}
|
|
523
622
|
|
|
524
|
-
|
|
623
|
+
function readHostJsonSafe(filePath) {
|
|
624
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
630
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
631
|
+
} catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function ensureClaudeOnboarding(toolDir, hostHomeDir) {
|
|
525
637
|
const claudeJsonPath = path.join(toolDir, '.claude.json');
|
|
526
638
|
let data = {};
|
|
527
639
|
if (fs.existsSync(claudeJsonPath)) {
|
|
@@ -548,12 +660,44 @@ export function ensureClaudeOnboarding(toolDir) {
|
|
|
548
660
|
data.projects['/workspace'].hasTrustDialogAccepted = true;
|
|
549
661
|
changed = true;
|
|
550
662
|
}
|
|
663
|
+
if (hostHomeDir) {
|
|
664
|
+
const hostClaudeJson = readHostJsonSafe(path.join(hostHomeDir, '.claude.json'));
|
|
665
|
+
if (
|
|
666
|
+
hostClaudeJson
|
|
667
|
+
&& typeof hostClaudeJson.model === 'string'
|
|
668
|
+
&& hostClaudeJson.model !== ''
|
|
669
|
+
&& !Object.hasOwn(data, 'model')
|
|
670
|
+
) {
|
|
671
|
+
data.model = hostClaudeJson.model;
|
|
672
|
+
changed = true;
|
|
673
|
+
}
|
|
674
|
+
// Claude Code launch-pins a default effort per model generation (for
|
|
675
|
+
// example xhigh for Opus 4.7). The saved effortLevel is honored only after
|
|
676
|
+
// a top-level boolean `unpin*LaunchEffort: true` flag unlocks it, so mirror
|
|
677
|
+
// those flags here with the existing first-write semantics.
|
|
678
|
+
//
|
|
679
|
+
// Pattern matching avoids one patch per future model generation. If
|
|
680
|
+
// Anthropic changes the naming convention, this block will no-op and should
|
|
681
|
+
// be revisited.
|
|
682
|
+
if (hostClaudeJson) {
|
|
683
|
+
for (const key of Object.keys(hostClaudeJson)) {
|
|
684
|
+
if (
|
|
685
|
+
/^unpin.*LaunchEffort$/.test(key)
|
|
686
|
+
&& hostClaudeJson[key] === true
|
|
687
|
+
&& !Object.hasOwn(data, key)
|
|
688
|
+
) {
|
|
689
|
+
data[key] = true;
|
|
690
|
+
changed = true;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
551
695
|
if (changed) {
|
|
552
696
|
fs.writeFileSync(claudeJsonPath, JSON.stringify(data, null, 4), 'utf8');
|
|
553
697
|
}
|
|
554
698
|
}
|
|
555
699
|
|
|
556
|
-
export function ensureClaudeSettings(toolDir) {
|
|
700
|
+
export function ensureClaudeSettings(toolDir, hostHomeDir) {
|
|
557
701
|
const settingsPath = path.join(toolDir, 'settings.json');
|
|
558
702
|
let data = {};
|
|
559
703
|
if (fs.existsSync(settingsPath)) {
|
|
@@ -563,12 +707,74 @@ export function ensureClaudeSettings(toolDir) {
|
|
|
563
707
|
// malformed JSON, start fresh
|
|
564
708
|
}
|
|
565
709
|
}
|
|
710
|
+
let changed = false;
|
|
566
711
|
if (data.skipDangerousModePermissionPrompt !== true) {
|
|
567
712
|
data.skipDangerousModePermissionPrompt = true;
|
|
713
|
+
changed = true;
|
|
714
|
+
}
|
|
715
|
+
if (hostHomeDir) {
|
|
716
|
+
const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
|
|
717
|
+
if (
|
|
718
|
+
hostSettings
|
|
719
|
+
&& typeof hostSettings.effortLevel === 'string'
|
|
720
|
+
&& hostSettings.effortLevel !== ''
|
|
721
|
+
&& !Object.hasOwn(data, 'effortLevel')
|
|
722
|
+
) {
|
|
723
|
+
data.effortLevel = hostSettings.effortLevel;
|
|
724
|
+
changed = true;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (changed) {
|
|
568
728
|
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 4), 'utf8');
|
|
569
729
|
}
|
|
570
730
|
}
|
|
571
731
|
|
|
732
|
+
export function ensureCodexModelInheritance(toolDir, hostHomeDir) {
|
|
733
|
+
if (!hostHomeDir) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const hostConfigPath = path.join(hostHomeDir, '.codex', 'config.toml');
|
|
738
|
+
if (!fs.existsSync(hostConfigPath)) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let hostParsed;
|
|
743
|
+
try {
|
|
744
|
+
hostParsed = toml.parse(fs.readFileSync(hostConfigPath, 'utf8'));
|
|
745
|
+
} catch {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const sandboxConfigPath = path.join(toolDir, 'config.toml');
|
|
750
|
+
// This rewrites sandbox-side TOML and drops comments; the host config stays untouched.
|
|
751
|
+
let sandboxParsed = {};
|
|
752
|
+
if (fs.existsSync(sandboxConfigPath)) {
|
|
753
|
+
try {
|
|
754
|
+
sandboxParsed = toml.parse(fs.readFileSync(sandboxConfigPath, 'utf8'));
|
|
755
|
+
} catch {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
let changed = false;
|
|
761
|
+
for (const key of ['model', 'model_reasoning_effort']) {
|
|
762
|
+
if (Object.hasOwn(sandboxParsed, key)) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const value = hostParsed[key];
|
|
766
|
+
if (typeof value !== 'string' || value === '') {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
sandboxParsed[key] = value;
|
|
770
|
+
changed = true;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (changed) {
|
|
774
|
+
fs.writeFileSync(sandboxConfigPath, `${toml.stringify(sandboxParsed)}\n`, 'utf8');
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
572
778
|
export function ensureCodexWorkspaceTrust(toolDir) {
|
|
573
779
|
const configPath = path.join(toolDir, 'config.toml');
|
|
574
780
|
let content = '';
|
|
@@ -581,6 +787,44 @@ export function ensureCodexWorkspaceTrust(toolDir) {
|
|
|
581
787
|
}
|
|
582
788
|
}
|
|
583
789
|
|
|
790
|
+
export function ensureOpenCodeModelInheritance(toolDir, hostHomeDir) {
|
|
791
|
+
if (!hostHomeDir) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const hostConfigPath = path.join(hostHomeDir, '.config', 'opencode', 'opencode.json');
|
|
796
|
+
const hostJson = readHostJsonSafe(hostConfigPath);
|
|
797
|
+
if (!hostJson) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const sandboxConfigPath = path.join(toolDir, 'opencode.json');
|
|
802
|
+
let sandboxJson = {};
|
|
803
|
+
if (fs.existsSync(sandboxConfigPath)) {
|
|
804
|
+
const existing = readHostJsonSafe(sandboxConfigPath);
|
|
805
|
+
if (!existing) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
sandboxJson = existing;
|
|
809
|
+
}
|
|
810
|
+
let changed = false;
|
|
811
|
+
for (const key of ['model', 'small_model']) {
|
|
812
|
+
if (Object.hasOwn(sandboxJson, key)) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
const value = hostJson[key];
|
|
816
|
+
if (typeof value !== 'string' || value === '') {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
sandboxJson[key] = value;
|
|
820
|
+
changed = true;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (changed) {
|
|
824
|
+
fs.writeFileSync(sandboxConfigPath, JSON.stringify(sandboxJson, null, 2), 'utf8');
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
584
828
|
export function ensureGeminiWorkspaceTrust(toolDir) {
|
|
585
829
|
const trustPath = path.join(toolDir, 'trustedFolders.json');
|
|
586
830
|
let data = {};
|
|
@@ -597,113 +841,8 @@ export function ensureGeminiWorkspaceTrust(toolDir) {
|
|
|
597
841
|
}
|
|
598
842
|
}
|
|
599
843
|
|
|
600
|
-
export function extractClaudeCredentialsBlob(home, execFn = execFileSync) {
|
|
601
|
-
if (process.platform === 'darwin') {
|
|
602
|
-
try {
|
|
603
|
-
const keychainAccount = path.basename(home);
|
|
604
|
-
const credentials = execFn('security', [
|
|
605
|
-
'find-generic-password',
|
|
606
|
-
'-a',
|
|
607
|
-
keychainAccount,
|
|
608
|
-
'-s',
|
|
609
|
-
'Claude Code-credentials',
|
|
610
|
-
'-w'
|
|
611
|
-
], {
|
|
612
|
-
encoding: 'utf8',
|
|
613
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
614
|
-
});
|
|
615
|
-
const trimmed = typeof credentials === 'string' ? credentials.trim() : '';
|
|
616
|
-
if (!trimmed) {
|
|
617
|
-
return null;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const parsed = JSON.parse(trimmed);
|
|
621
|
-
const payload = parsed?.claudeAiOauth ?? parsed;
|
|
622
|
-
const scopes = Array.isArray(payload?.scopes) ? payload.scopes : [];
|
|
623
|
-
const hasRequiredScopes = scopes.includes('user:profile')
|
|
624
|
-
&& scopes.includes('user:sessions:claude_code');
|
|
625
|
-
if (!payload?.accessToken || !payload?.refreshToken || !hasRequiredScopes) {
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
return trimmed;
|
|
629
|
-
} catch {
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const credentialsPath = path.join(home, '.claude', '.credentials.json');
|
|
635
|
-
if (!fs.existsSync(credentialsPath)) {
|
|
636
|
-
return null;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
try {
|
|
640
|
-
const raw = fs.readFileSync(credentialsPath, 'utf8');
|
|
641
|
-
const parsed = JSON.parse(raw);
|
|
642
|
-
const payload = parsed?.claudeAiOauth ?? parsed;
|
|
643
|
-
const scopes = Array.isArray(payload?.scopes) ? payload.scopes : [];
|
|
644
|
-
const hasRequiredScopes = scopes.includes('user:profile')
|
|
645
|
-
&& scopes.includes('user:sessions:claude_code');
|
|
646
|
-
if (!payload?.accessToken || !payload?.refreshToken || !hasRequiredScopes) {
|
|
647
|
-
return null;
|
|
648
|
-
}
|
|
649
|
-
return raw;
|
|
650
|
-
} catch {
|
|
651
|
-
return null;
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
export function claudeCredentialsDir(home, project) {
|
|
656
|
-
return hostJoin(home, '.agent-infra', 'credentials', project, 'claude-code');
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
export function claudeCredentialsPath(home, project) {
|
|
660
|
-
return hostJoin(claudeCredentialsDir(home, project), '.credentials.json');
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
export function writeClaudeCredentialsFile(home, project, blob) {
|
|
664
|
-
const dir = claudeCredentialsDir(home, project);
|
|
665
|
-
const filePath = claudeCredentialsPath(home, project);
|
|
666
|
-
|
|
667
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
668
|
-
fs.chmodSync(dir, 0o700);
|
|
669
|
-
fs.writeFileSync(filePath, blob, { mode: 0o600 });
|
|
670
|
-
fs.chmodSync(filePath, 0o600);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
export function assertClaudeCredentialsAvailable(
|
|
674
|
-
home,
|
|
675
|
-
project,
|
|
676
|
-
resolvedTools,
|
|
677
|
-
extractFn = extractClaudeCredentialsBlob,
|
|
678
|
-
writeFn = writeClaudeCredentialsFile
|
|
679
|
-
) {
|
|
680
|
-
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
681
|
-
if (!claudeCodeEntry) {
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
const blob = extractFn(home);
|
|
686
|
-
if (!blob) {
|
|
687
|
-
throw new Error([
|
|
688
|
-
'Claude Code credentials not found on host.',
|
|
689
|
-
'',
|
|
690
|
-
'The sandbox needs your Claude Code OAuth credentials so the container can use Claude Code.',
|
|
691
|
-
'',
|
|
692
|
-
'To fix:',
|
|
693
|
-
' 1. On the host, run "claude" once and complete the OAuth login flow.',
|
|
694
|
-
' 2. Verify with "claude /status" that you see your subscription.',
|
|
695
|
-
' 3. Re-run "ai sandbox create".',
|
|
696
|
-
'',
|
|
697
|
-
'Alternatively, if you do not need Claude Code in this sandbox,',
|
|
698
|
-
'remove "claude-code" from the "sandbox.tools" array in .agents/.airc.json.'
|
|
699
|
-
].join('\n'));
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
writeFn(home, project, blob);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
844
|
export function sandboxAliasesPath(home) {
|
|
706
|
-
return
|
|
845
|
+
return hostJoin(home, '.agent-infra', 'aliases', 'sandbox.sh');
|
|
707
846
|
}
|
|
708
847
|
|
|
709
848
|
function escapeRegExp(value) {
|
|
@@ -759,7 +898,7 @@ export function ensureSandboxAliasesFile(home) {
|
|
|
759
898
|
|
|
760
899
|
export function commandErrorMessage(error) {
|
|
761
900
|
const stderr = error?.stderr?.toString().trim();
|
|
762
|
-
return stderr || error?.message || 'Command failed';
|
|
901
|
+
return redactCommandError(stderr || error?.message || 'Command failed');
|
|
763
902
|
}
|
|
764
903
|
|
|
765
904
|
function runTaskCommand(cmd, args, opts = {}) {
|
|
@@ -770,17 +909,32 @@ function runTaskCommand(cmd, args, opts = {}) {
|
|
|
770
909
|
}
|
|
771
910
|
}
|
|
772
911
|
|
|
912
|
+
function runEngineTaskCommand(engine, cmd, args, opts = {}) {
|
|
913
|
+
const command = commandForEngine(engine, cmd, args);
|
|
914
|
+
return runTaskCommand(command.cmd, command.args, opts);
|
|
915
|
+
}
|
|
916
|
+
|
|
773
917
|
export function buildImage(
|
|
774
918
|
config,
|
|
775
919
|
tools,
|
|
776
920
|
dockerfilePath,
|
|
777
921
|
imageSignature,
|
|
778
|
-
{
|
|
922
|
+
{
|
|
923
|
+
engine,
|
|
924
|
+
runFn = runEngine,
|
|
925
|
+
runSafeFn = runSafeEngine,
|
|
926
|
+
runVerboseFn = runVerboseEngine,
|
|
927
|
+
env = process.env
|
|
928
|
+
} = {}
|
|
779
929
|
) {
|
|
780
|
-
const hostUid =
|
|
781
|
-
|
|
930
|
+
const { uid: hostUid, gid: hostGid } = resolveBuildUid({
|
|
931
|
+
engine,
|
|
932
|
+
runFn,
|
|
933
|
+
runSafeFn,
|
|
934
|
+
env
|
|
935
|
+
});
|
|
782
936
|
|
|
783
|
-
runVerboseFn('docker', [
|
|
937
|
+
runVerboseFn(engine, 'docker', [
|
|
784
938
|
'build',
|
|
785
939
|
'-t',
|
|
786
940
|
config.imageName,
|
|
@@ -795,8 +949,8 @@ export function buildImage(
|
|
|
795
949
|
'--label',
|
|
796
950
|
`${sandboxImageConfigLabel(config)}=${imageSignature}`,
|
|
797
951
|
'-f',
|
|
798
|
-
dockerfilePath,
|
|
799
|
-
config.repoRoot
|
|
952
|
+
toEnginePath(engine, dockerfilePath),
|
|
953
|
+
toEnginePath(engine, config.repoRoot)
|
|
800
954
|
], { cwd: config.repoRoot });
|
|
801
955
|
}
|
|
802
956
|
|
|
@@ -821,6 +975,9 @@ export async function create(args) {
|
|
|
821
975
|
throw new Error(USAGE);
|
|
822
976
|
}
|
|
823
977
|
|
|
978
|
+
validateSelinuxDisableEnv();
|
|
979
|
+
validateClaudeCredentialsEnvOverride();
|
|
980
|
+
|
|
824
981
|
const config = loadConfig();
|
|
825
982
|
const [branchOrTaskId, base] = positionals;
|
|
826
983
|
const branch = resolveTaskBranch(branchOrTaskId, config.repoRoot);
|
|
@@ -848,9 +1005,12 @@ export async function create(args) {
|
|
|
848
1005
|
);
|
|
849
1006
|
const container = containerName(effectiveConfig, branch);
|
|
850
1007
|
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0];
|
|
1008
|
+
const shareCommon = shareCommonDir(effectiveConfig);
|
|
1009
|
+
const shareBranch = shareBranchDir(effectiveConfig, branch);
|
|
851
1010
|
const preparedDockerfile = prepareDockerfile(effectiveConfig);
|
|
852
1011
|
const baseBranch = base ?? runSafe('git', ['-C', effectiveConfig.repoRoot, 'branch', '--show-current']);
|
|
853
1012
|
const expectedImageSignature = buildSignature(preparedDockerfile, tools);
|
|
1013
|
+
const engine = detectEngine(effectiveConfig);
|
|
854
1014
|
|
|
855
1015
|
p.intro(pc.cyan('AI Sandbox'));
|
|
856
1016
|
p.log.info(
|
|
@@ -864,9 +1024,9 @@ export async function create(args) {
|
|
|
864
1024
|
});
|
|
865
1025
|
p.log.success('Docker is ready');
|
|
866
1026
|
|
|
867
|
-
const imageExists =
|
|
1027
|
+
const imageExists = runOkEngine(engine, 'docker', ['image', 'inspect', effectiveConfig.imageName]);
|
|
868
1028
|
const currentImageSignature = imageExists
|
|
869
|
-
?
|
|
1029
|
+
? runSafeEngine(engine, 'docker', [
|
|
870
1030
|
'image',
|
|
871
1031
|
'inspect',
|
|
872
1032
|
'--format',
|
|
@@ -882,7 +1042,8 @@ export async function create(args) {
|
|
|
882
1042
|
effectiveConfig,
|
|
883
1043
|
tools,
|
|
884
1044
|
preparedDockerfile.path,
|
|
885
|
-
expectedImageSignature
|
|
1045
|
+
expectedImageSignature,
|
|
1046
|
+
{ engine }
|
|
886
1047
|
);
|
|
887
1048
|
p.log.success(imageExists ? 'Image rebuilt' : 'Image built');
|
|
888
1049
|
} else {
|
|
@@ -911,10 +1072,26 @@ export async function create(args) {
|
|
|
911
1072
|
|
|
912
1073
|
if (branchExists) {
|
|
913
1074
|
message(`Using existing branch '${branch}'...`);
|
|
914
|
-
|
|
1075
|
+
runEngineTaskCommand(engine, 'git', [
|
|
1076
|
+
'-C',
|
|
1077
|
+
toEnginePath(engine, effectiveConfig.repoRoot),
|
|
1078
|
+
'worktree',
|
|
1079
|
+
'add',
|
|
1080
|
+
toEnginePath(engine, worktree),
|
|
1081
|
+
branch
|
|
1082
|
+
]);
|
|
915
1083
|
} else {
|
|
916
1084
|
message(`Creating branch '${branch}' from '${baseBranch}'...`);
|
|
917
|
-
|
|
1085
|
+
runEngineTaskCommand(engine, 'git', [
|
|
1086
|
+
'-C',
|
|
1087
|
+
toEnginePath(engine, effectiveConfig.repoRoot),
|
|
1088
|
+
'worktree',
|
|
1089
|
+
'add',
|
|
1090
|
+
'-b',
|
|
1091
|
+
branch,
|
|
1092
|
+
toEnginePath(engine, worktree),
|
|
1093
|
+
baseBranch
|
|
1094
|
+
]);
|
|
918
1095
|
}
|
|
919
1096
|
|
|
920
1097
|
return `Worktree ready at ${worktree}`;
|
|
@@ -947,8 +1124,15 @@ export async function create(args) {
|
|
|
947
1124
|
continue;
|
|
948
1125
|
}
|
|
949
1126
|
let content = fs.readFileSync(filePath, 'utf8');
|
|
950
|
-
|
|
951
|
-
|
|
1127
|
+
const containerHome = path.posix.dirname(tool.containerMount);
|
|
1128
|
+
for (const hostPath of [effectiveConfig.repoRoot, effectiveConfig.home]) {
|
|
1129
|
+
const replacement = hostPath === effectiveConfig.repoRoot ? '/workspace' : containerHome;
|
|
1130
|
+
content = content.replaceAll(hostPath, replacement);
|
|
1131
|
+
const posixHostPath = hostPath.replaceAll('\\', '/');
|
|
1132
|
+
if (posixHostPath !== hostPath) {
|
|
1133
|
+
content = content.replaceAll(posixHostPath, replacement);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
952
1136
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
953
1137
|
}
|
|
954
1138
|
}
|
|
@@ -959,15 +1143,15 @@ export async function create(args) {
|
|
|
959
1143
|
{
|
|
960
1144
|
title: `Starting container '${container}'`,
|
|
961
1145
|
task: async (message) => {
|
|
962
|
-
const existing =
|
|
1146
|
+
const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
963
1147
|
const matchedContainers = containerNameCandidates(effectiveConfig, branch)
|
|
964
1148
|
.filter((name) => existing.includes(name));
|
|
965
1149
|
|
|
966
1150
|
if (matchedContainers.length > 0) {
|
|
967
1151
|
message('Removing old container instance...');
|
|
968
1152
|
for (const name of matchedContainers) {
|
|
969
|
-
|
|
970
|
-
|
|
1153
|
+
runSafeEngine(engine, 'docker', ['stop', name]);
|
|
1154
|
+
runSafeEngine(engine, 'docker', ['rm', name]);
|
|
971
1155
|
}
|
|
972
1156
|
}
|
|
973
1157
|
|
|
@@ -993,73 +1177,112 @@ export async function create(args) {
|
|
|
993
1177
|
signingKey
|
|
994
1178
|
)
|
|
995
1179
|
: null;
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1180
|
+
const envFile = buildContainerEnvFile(resolvedTools, engine);
|
|
1181
|
+
let hostShellConfig;
|
|
1182
|
+
try {
|
|
1183
|
+
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
1184
|
+
if (claudeCodeEntry) {
|
|
1185
|
+
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1186
|
+
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1187
|
+
// Credential availability is asserted up-front in create() so we
|
|
1188
|
+
// know the shared credentials file already exists at this point.
|
|
1189
|
+
}
|
|
1190
|
+
const codexEntry = resolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
1191
|
+
if (codexEntry) {
|
|
1192
|
+
ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
|
|
1193
|
+
ensureCodexWorkspaceTrust(codexEntry.dir);
|
|
1194
|
+
}
|
|
1195
|
+
const geminiEntry = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
|
|
1196
|
+
if (geminiEntry) {
|
|
1197
|
+
ensureGeminiWorkspaceTrust(geminiEntry.dir);
|
|
1198
|
+
}
|
|
1199
|
+
const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
|
|
1200
|
+
if (opencodeEntry) {
|
|
1201
|
+
// The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
|
|
1202
|
+
ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
|
|
1203
|
+
}
|
|
1204
|
+
const toolVolumes = resolvedTools.flatMap(({ tool, dir }) => [
|
|
1205
|
+
'-v',
|
|
1206
|
+
volumeArg(engine, dir, tool.containerMount)
|
|
1207
|
+
]);
|
|
1208
|
+
const workspaceDir = path.join(effectiveConfig.repoRoot, '.agents', 'workspace');
|
|
1209
|
+
hostShellConfig = prepareHostShellConfig({
|
|
1210
|
+
home: effectiveConfig.home,
|
|
1211
|
+
project: effectiveConfig.project,
|
|
1212
|
+
branch,
|
|
1213
|
+
repoRoot: effectiveConfig.repoRoot
|
|
1214
|
+
});
|
|
1215
|
+
const shellConfigVolumes = hostShellConfig.mounts.flatMap(({ hostPath, containerPath }) => [
|
|
1216
|
+
'-v',
|
|
1217
|
+
volumeArg(engine, hostPath, containerPath, ':ro')
|
|
1218
|
+
]);
|
|
1219
|
+
const liveMountVolumes = resolvedTools.flatMap(({ tool }) =>
|
|
1220
|
+
(tool.hostLiveMounts ?? [])
|
|
1221
|
+
.filter(({ hostPath }) => fs.existsSync(hostPath))
|
|
1222
|
+
.flatMap(({ hostPath, containerSubpath }) => [
|
|
1223
|
+
'-v',
|
|
1224
|
+
volumeArg(engine, hostPath, path.posix.join(tool.containerMount, containerSubpath))
|
|
1225
|
+
])
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
1229
|
+
fs.mkdirSync(shareCommon, { recursive: true });
|
|
1230
|
+
fs.mkdirSync(shareBranch, { recursive: true });
|
|
1231
|
+
|
|
1232
|
+
const dotfilesSnapshot = materializeDotfiles(
|
|
1233
|
+
effectiveConfig.dotfilesDir,
|
|
1234
|
+
dotfilesCacheDir(effectiveConfig.home, effectiveConfig.project)
|
|
1235
|
+
);
|
|
1236
|
+
const dotfilesMount = dotfilesSnapshot
|
|
1237
|
+
? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
|
|
1238
|
+
: [];
|
|
1239
|
+
|
|
1240
|
+
runEngineTaskCommand(engine, 'docker', [
|
|
1241
|
+
'run',
|
|
1242
|
+
'-d',
|
|
1243
|
+
'--name',
|
|
1244
|
+
container,
|
|
1245
|
+
'--hostname',
|
|
1246
|
+
`${effectiveConfig.project}-sandbox`,
|
|
1247
|
+
'--label',
|
|
1248
|
+
sandboxLabel(effectiveConfig),
|
|
1249
|
+
'--label',
|
|
1250
|
+
`${sandboxBranchLabel(effectiveConfig)}=${branch}`,
|
|
1251
|
+
'-v',
|
|
1252
|
+
volumeArg(engine, worktree, '/workspace'),
|
|
1253
|
+
'-v',
|
|
1254
|
+
volumeArg(engine, workspaceDir, '/workspace/.agents/workspace'),
|
|
1255
|
+
'-v',
|
|
1256
|
+
volumeArg(engine, shareCommon, '/share/common'),
|
|
1257
|
+
'-v',
|
|
1258
|
+
volumeArg(engine, shareBranch, '/share/branch'),
|
|
1259
|
+
'-v',
|
|
1260
|
+
volumeArg(
|
|
1261
|
+
engine,
|
|
1262
|
+
path.join(effectiveConfig.repoRoot, '.git'),
|
|
1263
|
+
`${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`
|
|
1264
|
+
),
|
|
1265
|
+
'-v',
|
|
1266
|
+
volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
|
|
1267
|
+
...dotfilesMount,
|
|
1268
|
+
...toolVolumes,
|
|
1269
|
+
...liveMountVolumes,
|
|
1270
|
+
...shellConfigVolumes,
|
|
1271
|
+
...envFile.dockerArgs,
|
|
1272
|
+
'-w',
|
|
1273
|
+
'/workspace',
|
|
1274
|
+
effectiveConfig.imageName
|
|
1275
|
+
]);
|
|
1276
|
+
} finally {
|
|
1277
|
+
envFile.cleanup();
|
|
1011
1278
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
repoRoot: effectiveConfig.repoRoot
|
|
1020
|
-
});
|
|
1021
|
-
const shellConfigVolumes = hostShellConfig.mounts.flatMap(({ hostPath, containerPath }) => [
|
|
1022
|
-
'-v',
|
|
1023
|
-
`${hostPath}:${containerPath}:ro`
|
|
1024
|
-
]);
|
|
1025
|
-
const liveMountVolumes = resolvedTools.flatMap(({ tool }) =>
|
|
1026
|
-
(tool.hostLiveMounts ?? [])
|
|
1027
|
-
.filter(({ hostPath }) => fs.existsSync(hostPath))
|
|
1028
|
-
.flatMap(({ hostPath, containerSubpath }) => [
|
|
1029
|
-
'-v',
|
|
1030
|
-
`${hostPath}:${path.join(tool.containerMount, containerSubpath)}`
|
|
1031
|
-
])
|
|
1032
|
-
);
|
|
1033
|
-
|
|
1034
|
-
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
1035
|
-
|
|
1036
|
-
runTaskCommand('docker', [
|
|
1037
|
-
'run',
|
|
1038
|
-
'-d',
|
|
1039
|
-
'--name',
|
|
1040
|
-
container,
|
|
1041
|
-
'--hostname',
|
|
1042
|
-
`${effectiveConfig.project}-sandbox`,
|
|
1043
|
-
'--label',
|
|
1044
|
-
sandboxLabel(effectiveConfig),
|
|
1045
|
-
'--label',
|
|
1046
|
-
`${sandboxBranchLabel(effectiveConfig)}=${branch}`,
|
|
1047
|
-
'-v',
|
|
1048
|
-
`${worktree}:/workspace`,
|
|
1049
|
-
'-v',
|
|
1050
|
-
`${workspaceDir}:/workspace/.agents/workspace`,
|
|
1051
|
-
'-v',
|
|
1052
|
-
`${effectiveConfig.repoRoot}/.git:${effectiveConfig.repoRoot}/.git`,
|
|
1053
|
-
'-v',
|
|
1054
|
-
`${path.join(effectiveConfig.home, '.ssh')}:/home/devuser/.ssh:ro`,
|
|
1055
|
-
...toolVolumes,
|
|
1056
|
-
...liveMountVolumes,
|
|
1057
|
-
...shellConfigVolumes,
|
|
1058
|
-
...envArgs,
|
|
1059
|
-
'-w',
|
|
1060
|
-
'/workspace',
|
|
1061
|
-
effectiveConfig.imageName
|
|
1062
|
-
]);
|
|
1279
|
+
|
|
1280
|
+
// Belt-and-suspenders: re-create the four shell-config symlinks at
|
|
1281
|
+
// runtime so users with a custom `sandbox.dockerfile` (which won't
|
|
1282
|
+
// include the ai-tools.dockerfile symlink fragment) still get
|
|
1283
|
+
// ~/.gitconfig and friends pointing into the host bind-mount.
|
|
1284
|
+
// `ln -sf` is idempotent for the default image.
|
|
1285
|
+
ensureShellConfigSymlinks(engine, container);
|
|
1063
1286
|
|
|
1064
1287
|
if (needsGpg) {
|
|
1065
1288
|
message(
|
|
@@ -1079,7 +1302,9 @@ export async function create(args) {
|
|
|
1079
1302
|
{
|
|
1080
1303
|
cachedOverride: cachedGpg,
|
|
1081
1304
|
repoPath: worktree,
|
|
1082
|
-
signingKey
|
|
1305
|
+
signingKey,
|
|
1306
|
+
dockerExecFn: (cmd, args, opts) => execEngine(engine, cmd, args, opts),
|
|
1307
|
+
dockerRunSafeFn: (cmd, args, opts) => runSafeEngine(engine, cmd, args, opts)
|
|
1083
1308
|
}
|
|
1084
1309
|
)) {
|
|
1085
1310
|
writeSanitizedGitconfig({
|
|
@@ -1106,7 +1331,7 @@ export async function create(args) {
|
|
|
1106
1331
|
|
|
1107
1332
|
for (const { tool } of resolvedTools) {
|
|
1108
1333
|
for (const command of tool.postSetupCmds ?? []) {
|
|
1109
|
-
|
|
1334
|
+
runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
|
|
1110
1335
|
}
|
|
1111
1336
|
}
|
|
1112
1337
|
|
|
@@ -1119,18 +1344,18 @@ export async function create(args) {
|
|
|
1119
1344
|
}
|
|
1120
1345
|
|
|
1121
1346
|
p.log.step('Verifying setup...');
|
|
1122
|
-
const runningContainers =
|
|
1347
|
+
const runningContainers = runSafeEngine(engine, 'docker', ['ps', '--format', '{{.Names}}']).split('\n');
|
|
1123
1348
|
const checks = [
|
|
1124
1349
|
{ name: 'Container running', ok: runningContainers.includes(container) },
|
|
1125
1350
|
...runtimeChecks(effectiveConfig.runtimes).map((check) => ({
|
|
1126
1351
|
name: check.name,
|
|
1127
|
-
ok:
|
|
1352
|
+
ok: runOkEngine(engine, 'docker', ['exec', container, ...check.cmd])
|
|
1128
1353
|
})),
|
|
1129
|
-
{ name: 'GitHub CLI', ok:
|
|
1354
|
+
{ name: 'GitHub CLI', ok: runOkEngine(engine, 'docker', ['exec', container, 'gh', '--version']) }
|
|
1130
1355
|
];
|
|
1131
1356
|
const toolChecks = tools.map((tool) => ({
|
|
1132
1357
|
name: tool.name,
|
|
1133
|
-
ok:
|
|
1358
|
+
ok: runOkEngine(engine, 'docker', ['exec', container, 'bash', '-lc', tool.versionCmd]),
|
|
1134
1359
|
hint: tool.setupHint
|
|
1135
1360
|
}));
|
|
1136
1361
|
|
|
@@ -1159,6 +1384,8 @@ Container: ${container}
|
|
|
1159
1384
|
Image: ${effectiveConfig.imageName}
|
|
1160
1385
|
Worktree: ${worktree}
|
|
1161
1386
|
Host aliases: ${sandboxAliasesPath(effectiveConfig.home)}
|
|
1387
|
+
Share (common): ${shareCommon} -> /share/common
|
|
1388
|
+
Share (branch): ${shareBranch} -> /share/branch
|
|
1162
1389
|
|
|
1163
1390
|
Management:
|
|
1164
1391
|
ai sandbox ls
|
|
@@ -1166,7 +1393,7 @@ Management:
|
|
|
1166
1393
|
ai sandbox rm ${branch}
|
|
1167
1394
|
|
|
1168
1395
|
Sandbox aliases:
|
|
1169
|
-
Edit the host aliases file to customize shortcuts
|
|
1396
|
+
Edit the host aliases file to customize shortcuts exposed at ${CONTAINER_HOME}/.bash_aliases inside the sandbox container.
|
|
1170
1397
|
|
|
1171
1398
|
Tool notes:
|
|
1172
1399
|
${toolHints}
|