@fitlab-ai/agent-infra 0.5.8 → 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.
Files changed (67) hide show
  1. package/README.md +237 -5
  2. package/README.zh-CN.md +213 -5
  3. package/bin/cli.js +2 -2
  4. package/lib/init.js +18 -4
  5. package/lib/sandbox/commands/create.js +467 -240
  6. package/lib/sandbox/commands/enter.js +59 -26
  7. package/lib/sandbox/commands/ls.js +37 -6
  8. package/lib/sandbox/commands/rebuild.js +31 -15
  9. package/lib/sandbox/commands/refresh.js +119 -0
  10. package/lib/sandbox/commands/rm.js +59 -11
  11. package/lib/sandbox/commands/vm.js +56 -6
  12. package/lib/sandbox/config.js +9 -5
  13. package/lib/sandbox/constants.js +18 -3
  14. package/lib/sandbox/credentials.js +520 -0
  15. package/lib/sandbox/dotfiles.js +189 -0
  16. package/lib/sandbox/engine.js +135 -157
  17. package/lib/sandbox/engines/colima.js +79 -0
  18. package/lib/sandbox/engines/docker-desktop.js +34 -0
  19. package/lib/sandbox/engines/index.js +27 -0
  20. package/lib/sandbox/engines/native.js +112 -0
  21. package/lib/sandbox/engines/orbstack.js +76 -0
  22. package/lib/sandbox/engines/selinux.js +60 -0
  23. package/lib/sandbox/engines/wsl2-paths.js +59 -0
  24. package/lib/sandbox/engines/wsl2.js +72 -0
  25. package/lib/sandbox/index.js +10 -1
  26. package/lib/sandbox/runtimes/ai-tools.dockerfile +14 -1
  27. package/lib/sandbox/runtimes/base.dockerfile +125 -3
  28. package/lib/sandbox/shell.js +53 -2
  29. package/lib/sandbox/tools.js +5 -5
  30. package/package.json +8 -4
  31. package/templates/.agents/rules/create-issue.en.md +5 -0
  32. package/templates/.agents/rules/create-issue.github.en.md +176 -0
  33. package/templates/.agents/rules/create-issue.github.zh-CN.md +176 -0
  34. package/templates/.agents/rules/create-issue.zh-CN.md +5 -0
  35. package/templates/.agents/rules/issue-pr-commands.github.en.md +29 -0
  36. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +29 -0
  37. package/templates/.agents/rules/issue-sync.github.en.md +1 -1
  38. package/templates/.agents/rules/issue-sync.github.zh-CN.md +1 -1
  39. package/templates/.agents/rules/milestone-inference.github.en.md +2 -2
  40. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +2 -2
  41. package/templates/.agents/scripts/{platform-adapters/find-existing-task.github.js → find-existing-task.js} +22 -79
  42. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +72 -42
  43. package/templates/.agents/skills/create-task/SKILL.en.md +69 -11
  44. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +70 -12
  45. package/templates/.agents/skills/create-task/config/verify.json +6 -1
  46. package/templates/.agents/skills/implement-task/reference/implementation-rules.en.md +7 -12
  47. package/templates/.agents/skills/implement-task/reference/implementation-rules.zh-CN.md +7 -12
  48. package/templates/.agents/skills/import-issue/SKILL.en.md +7 -9
  49. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +7 -9
  50. package/templates/.agents/skills/refine-task/reference/fix-workflow.en.md +2 -2
  51. package/templates/.agents/skills/refine-task/reference/fix-workflow.zh-CN.md +2 -2
  52. package/templates/.agents/skills/test/SKILL.en.md +45 -6
  53. package/templates/.agents/skills/test/SKILL.zh-CN.md +45 -6
  54. package/templates/.agents/scripts/platform-adapters/find-existing-task.js +0 -5
  55. package/templates/.agents/skills/create-issue/SKILL.en.md +0 -118
  56. package/templates/.agents/skills/create-issue/SKILL.zh-CN.md +0 -118
  57. package/templates/.agents/skills/create-issue/config/verify.json +0 -30
  58. package/templates/.agents/skills/create-issue/reference/label-and-type.en.md +0 -71
  59. package/templates/.agents/skills/create-issue/reference/label-and-type.zh-CN.md +0 -71
  60. package/templates/.agents/skills/create-issue/reference/template-matching.en.md +0 -17
  61. package/templates/.agents/skills/create-issue/reference/template-matching.zh-CN.md +0 -17
  62. package/templates/.claude/commands/create-issue.en.md +0 -8
  63. package/templates/.claude/commands/create-issue.zh-CN.md +0 -8
  64. package/templates/.gemini/commands/_project_/create-issue.en.toml +0 -8
  65. package/templates/.gemini/commands/_project_/create-issue.zh-CN.toml +0 -8
  66. package/templates/.opencode/commands/create-issue.en.md +0 -11
  67. package/templates/.opencode/commands/create-issue.zh-CN.md +0 -11
@@ -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 { run, runOk, runSafe, runVerbose } from '../shell.js';
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 mounted at
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 path.join(home, '.agent-infra', 'config', project, sanitizeBranchName(branch));
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 lines = gitconfig
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 = path.join(home, '.gitconfig');
220
- if (!fs.existsSync(gitconfigPath)) {
221
- return null;
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(fs.readFileSync(gitconfigPath, 'utf8'), home, {
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
- /** @type {Array<{ hostPath: string, containerPath: string }>} */
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 = path.join(home, file);
291
+ const hostPath = hostJoin(home, file);
253
292
  if (!fs.existsSync(hostPath)) {
254
293
  continue;
255
294
  }
256
295
 
257
- const targetPath = path.join(hostDir, file);
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
- const targetPath = path.join(hostDir, '.bash_aliases');
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
- execFn('docker', ['exec', '-i', container, 'gpg', '--import'], {
507
+ dockerExecFn('docker', ['exec', '-i', container, 'gpg', '--import'], {
467
508
  input: pubKeys,
468
509
  stdio: ['pipe', 'pipe', 'pipe']
469
510
  });
470
- execFn('docker', ['exec', '-i', container, 'gpg', '--batch', '--import'], {
511
+ dockerExecFn('docker', ['exec', '-i', container, 'gpg', '--batch', '--import'], {
471
512
  input: secKeys,
472
513
  stdio: ['pipe', 'pipe', 'pipe']
473
514
  });
474
515
 
475
- runSafeFn('docker', ['exec', container, 'gpgconf', '--launch', 'gpg-agent']);
516
+ dockerRunSafeFn('docker', ['exec', container, 'gpgconf', '--launch', 'gpg-agent']);
476
517
  return true;
477
518
  }
478
519
 
479
- export function buildContainerEnvArgs(resolvedTools, runSafeCommand = runSafe) {
480
- const envArgs = resolvedTools.flatMap(({ tool }) =>
481
- Object.entries(tool.envVars ?? {}).flatMap(([key, value]) => ['-e', `${key}=${value}`])
482
- );
483
- const ghToken = runSafeCommand('gh', ['auth', 'token']);
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
- envArgs.push('-e', `GH_TOKEN=${ghToken}`);
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 envArgs;
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
- export function ensureClaudeOnboarding(toolDir) {
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 path.join(home, '.agent-infra', 'aliases', 'sandbox.sh');
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
- { runFn = run, runVerboseFn = runVerbose } = {}
922
+ {
923
+ engine,
924
+ runFn = runEngine,
925
+ runSafeFn = runSafeEngine,
926
+ runVerboseFn = runVerboseEngine,
927
+ env = process.env
928
+ } = {}
779
929
  ) {
780
- const hostUid = runFn('id', ['-u']);
781
- const hostGid = runFn('id', ['-g']);
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 = runOk('docker', ['image', 'inspect', effectiveConfig.imageName]);
1027
+ const imageExists = runOkEngine(engine, 'docker', ['image', 'inspect', effectiveConfig.imageName]);
868
1028
  const currentImageSignature = imageExists
869
- ? runSafe('docker', [
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
- runTaskCommand('git', ['-C', effectiveConfig.repoRoot, 'worktree', 'add', worktree, branch]);
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
- runTaskCommand('git', ['-C', effectiveConfig.repoRoot, 'worktree', 'add', '-b', branch, worktree, baseBranch]);
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
- content = content.replaceAll(effectiveConfig.repoRoot, '/workspace');
951
- content = content.replaceAll(effectiveConfig.home, path.dirname(tool.containerMount));
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 = runSafe('docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
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
- runSafe('docker', ['stop', name]);
970
- runSafe('docker', ['rm', name]);
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 envArgs = buildContainerEnvArgs(resolvedTools);
997
- const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
998
- if (claudeCodeEntry) {
999
- ensureClaudeOnboarding(claudeCodeEntry.dir);
1000
- ensureClaudeSettings(claudeCodeEntry.dir);
1001
- // Credential availability is asserted up-front in create() so we
1002
- // know the shared credentials file already exists at this point.
1003
- }
1004
- const codexEntry = resolvedTools.find(({ tool }) => tool.id === 'codex');
1005
- if (codexEntry) {
1006
- ensureCodexWorkspaceTrust(codexEntry.dir);
1007
- }
1008
- const geminiEntry = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1009
- if (geminiEntry) {
1010
- ensureGeminiWorkspaceTrust(geminiEntry.dir);
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
- // OpenCode has no workspace trust mechanism, so no preseed step is needed.
1013
- const toolVolumes = resolvedTools.flatMap(({ tool, dir }) => ['-v', `${dir}:${tool.containerMount}`]);
1014
- const workspaceDir = path.join(effectiveConfig.repoRoot, '.agents', 'workspace');
1015
- const hostShellConfig = prepareHostShellConfig({
1016
- home: effectiveConfig.home,
1017
- project: effectiveConfig.project,
1018
- branch,
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
- runSafe('docker', ['exec', container, 'bash', '-lc', command]);
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 = runSafe('docker', ['ps', '--format', '{{.Names}}']).split('\n');
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: runOk('docker', ['exec', container, ...check.cmd])
1352
+ ok: runOkEngine(engine, 'docker', ['exec', container, ...check.cmd])
1128
1353
  })),
1129
- { name: 'GitHub CLI', ok: runOk('docker', ['exec', container, 'gh', '--version']) }
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: runOk('docker', ['exec', container, 'bash', '-lc', tool.versionCmd]),
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 mounted at ${CONTAINER_HOME}/.bash_aliases.
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}