@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,31 +1,19 @@
1
1
  import { loadConfig } from '../config.js';
2
2
  import { assertValidBranchName, containerNameCandidates } from '../constants.js';
3
- import { runInteractive, runSafe } from '../shell.js';
3
+ import { detectEngine } from '../engine.js';
4
+ import {
5
+ formatCredentialWarnings,
6
+ formatRemaining,
7
+ reconcileClaudeCredentials,
8
+ redactCommandError,
9
+ validateClaudeCredentialsEnvOverride
10
+ } from '../credentials.js';
11
+ import { runInteractiveEngine, runSafeEngine } from '../shell.js';
4
12
  import { resolveTaskBranch } from '../task-resolver.js';
13
+ import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
5
14
 
6
15
  const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
7
- export const TMUX_ENTRY_SCRIPT = `
8
- SESSION=work
9
-
10
- if ! command -v tmux >/dev/null 2>&1; then
11
- exec bash
12
- fi
13
-
14
- if ! tmux has-session -t "$SESSION" 2>/dev/null; then
15
- exec tmux new-session -s "$SESSION"
16
- fi
17
-
18
- tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \\
19
- while read -r name attached; do
20
- [ "$name" = "$SESSION" ] && continue
21
- case "$name" in
22
- ''|*[!0-9]*) continue ;;
23
- esac
24
- [ "$attached" = "0" ] && tmux kill-session -t "$name" 2>/dev/null || true
25
- done
26
-
27
- exec tmux new-session -t "$SESSION"
28
- `.trim();
16
+ const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
29
17
 
30
18
  // Terminal-detection variables that interactive TUIs (e.g. claude-code)
31
19
  // inspect to enable progressive enhancements such as the kitty keyboard
@@ -50,6 +38,30 @@ export function terminalEnvFlags(env = process.env) {
50
38
  return flags;
51
39
  }
52
40
 
41
+ export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY) {
42
+ if (result.status === 'STALE_ACCESS') {
43
+ return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
44
+ }
45
+ if (result.status === 'MISSING') {
46
+ return 'Warning: Claude Code credentials missing on host. Run "claude /login" to authenticate.\n';
47
+ }
48
+ if (result.status === 'KEYCHAIN_WRITE_FAILED') {
49
+ return `Warning: A sandbox refresh produced newer credentials but host Keychain write failed (${formatCredentialWarnings(result.warnings)}). Run "ai sandbox refresh" again or "claude /status" on the host to retry.\n`;
50
+ }
51
+ if (result.status === 'KEYCHAIN_LOCKED' || result.status === 'KEYCHAIN_ERROR') {
52
+ return 'Warning: Host keychain is unavailable; Claude credential sync skipped. Run "ai sandbox refresh" for details.\n';
53
+ }
54
+ if (result.status === 'OK' && result.authoritative !== 'host') {
55
+ const message = `Synced Claude Code credentials from sandbox refresh back to host (expires in ${formatRemaining(result.expiresAt)})`;
56
+ return isTTY ? `\x1b[2m${message}\x1b[0m\n` : `${message}\n`;
57
+ }
58
+ if (result.status === 'OK' && result.filesWritten.length > 0) {
59
+ const message = `Synced Claude Code credentials from host Keychain (expires in ${formatRemaining(result.expiresAt)})`;
60
+ return isTTY ? `\x1b[2m${message}\x1b[0m\n` : `${message}\n`;
61
+ }
62
+ return null;
63
+ }
64
+
53
65
  export function enter(args) {
54
66
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
55
67
  process.stdout.write(`${USAGE}\n`);
@@ -60,20 +72,41 @@ export function enter(args) {
60
72
  }
61
73
 
62
74
  const config = loadConfig();
75
+ validateClaudeCredentialsEnvOverride();
76
+ const engine = detectEngine(config);
63
77
  const [branchOrTaskId, ...cmd] = args;
64
78
  const branch = resolveTaskBranch(branchOrTaskId, config.repoRoot);
65
79
  assertValidBranchName(branch);
66
- const running = runSafe('docker', ['ps', '--format', '{{.Names}}']).split('\n');
80
+ const running = runSafeEngine(engine, 'docker', ['ps', '--format', '{{.Names}}']).split('\n');
67
81
  const container = containerNameCandidates(config, branch).find((name) => running.includes(name));
68
82
 
69
83
  if (!container) {
70
84
  throw new Error(`No running sandbox found for branch '${branch}'`);
71
85
  }
72
86
 
87
+ if (config.tools.includes('claude-code')) {
88
+ try {
89
+ // Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
90
+ const result = reconcileClaudeCredentials(config.home);
91
+ const message = formatCredentialSyncStatus(result);
92
+ if (message) {
93
+ process.stderr.write(message);
94
+ }
95
+ } catch (error) {
96
+ process.stderr.write(`Warning: Failed to sync Claude Code credentials: ${redactCommandError(error?.message ?? 'unknown error')}\n`);
97
+ }
98
+ }
99
+
73
100
  const envFlags = terminalEnvFlags();
74
101
  if (cmd.length === 0) {
75
- return runInteractive('docker', ['exec', '-it', ...envFlags, container, 'bash', '-c', TMUX_ENTRY_SCRIPT]);
102
+ try {
103
+ materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
104
+ } catch (error) {
105
+ process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error?.message ?? 'unknown error')}\n`);
106
+ }
107
+
108
+ return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH]);
76
109
  }
77
110
 
78
- return runInteractive('docker', ['exec', '-it', ...envFlags, container, ...cmd]);
111
+ return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);
79
112
  }
@@ -3,11 +3,37 @@ import path from 'node:path';
3
3
  import * as p from '@clack/prompts';
4
4
  import pc from 'picocolors';
5
5
  import { loadConfig } from '../config.js';
6
- import { sandboxLabel } from '../constants.js';
7
- import { runSafe } from '../shell.js';
6
+ import { sandboxBranchLabel, sandboxLabel } from '../constants.js';
7
+ import { detectEngine } from '../engine.js';
8
+ import { runSafeEngine } from '../shell.js';
8
9
  import { resolveTools, toolProjectDirCandidates } from '../tools.js';
9
10
 
10
11
  const USAGE = 'Usage: ai sandbox ls';
12
+ const CONTAINER_LIST_HEADER = 'NAMES\tSTATUS\tBRANCH';
13
+
14
+ // Exported to lock the docker/podman-compatible format in unit tests.
15
+ export function containerListFormat() {
16
+ return '{{.Names}}\t{{.Status}}\t{{.Labels}}';
17
+ }
18
+
19
+ export function parseLabels(csv) {
20
+ if (!csv) {
21
+ return {};
22
+ }
23
+
24
+ const labels = {};
25
+ for (const pair of csv.split(',')) {
26
+ if (!pair) {
27
+ continue;
28
+ }
29
+ const eq = pair.indexOf('=');
30
+ if (eq < 0) {
31
+ continue;
32
+ }
33
+ labels[pair.slice(0, eq)] = pair.slice(eq + 1);
34
+ }
35
+ return labels;
36
+ }
11
37
 
12
38
  function listChildren(dir) {
13
39
  if (!fs.existsSync(dir)) {
@@ -24,25 +50,30 @@ export function ls(args = []) {
24
50
  }
25
51
 
26
52
  const config = loadConfig();
53
+ const engine = detectEngine(config);
27
54
  const tools = resolveTools(config);
28
55
  const label = sandboxLabel(config);
29
- const containers = runSafe('docker', [
56
+ const containers = runSafeEngine(engine, 'docker', [
30
57
  'ps',
31
58
  '-a',
32
59
  '--filter',
33
60
  `label=${label}`,
34
61
  '--format',
35
- 'table {{.Names}}\t{{.Status}}\t{{.Label "' + `${label}.branch` + '"}}'
62
+ containerListFormat()
36
63
  ]);
37
64
 
38
65
  p.intro(pc.cyan(`Sandbox status for ${config.project}`));
39
66
 
40
67
  p.log.step('Containers');
41
- if (!containers || containers.split('\n').length <= 1) {
68
+ if (!containers) {
42
69
  p.log.warn(' No sandbox containers');
43
70
  } else {
71
+ const branchKey = sandboxBranchLabel(config);
72
+ process.stdout.write(` ${CONTAINER_LIST_HEADER}\n`);
44
73
  for (const line of containers.split('\n')) {
45
- process.stdout.write(` ${line}\n`);
74
+ const [name = '', status = '', labelsCsv = ''] = line.split('\t');
75
+ const branch = parseLabels(labelsCsv)[branchKey] ?? '';
76
+ process.stdout.write(` ${name}\t${status}\t${branch}\n`);
46
77
  }
47
78
  }
48
79
 
@@ -5,9 +5,11 @@ import pc from 'picocolors';
5
5
  import { loadConfig } from '../config.js';
6
6
  import { prepareDockerfile } from '../dockerfile.js';
7
7
  import { sandboxImageConfigLabel, sandboxLabel } from '../constants.js';
8
- import { ensureDocker } from '../engine.js';
9
- import { run, runOk, runVerbose } from '../shell.js';
8
+ import { detectEngine, ensureDocker } from '../engine.js';
9
+ import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from '../shell.js';
10
10
  import { resolveTools, toolNpmPackagesArg } from '../tools.js';
11
+ import { toEnginePath } from '../engines/wsl2-paths.js';
12
+ import { resolveBuildUid } from '../engines/native.js';
11
13
 
12
14
  const USAGE = `Usage: ai sandbox rebuild [--quiet]`;
13
15
 
@@ -21,9 +23,19 @@ function buildSignature(preparedDockerfile, tools) {
21
23
  .slice(0, 12);
22
24
  }
23
25
 
24
- function buildArgs(config, tools, dockerfilePath, imageSignature) {
25
- const hostUid = run('id', ['-u']);
26
- const hostGid = run('id', ['-g']);
26
+ export function buildArgs(
27
+ config,
28
+ tools,
29
+ dockerfilePath,
30
+ imageSignature,
31
+ { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env } = {}
32
+ ) {
33
+ const { uid: hostUid, gid: hostGid } = resolveBuildUid({
34
+ engine,
35
+ runFn,
36
+ runSafeFn,
37
+ env
38
+ });
27
39
 
28
40
  return [
29
41
  'build',
@@ -40,14 +52,14 @@ function buildArgs(config, tools, dockerfilePath, imageSignature) {
40
52
  '--label',
41
53
  `${sandboxImageConfigLabel(config)}=${imageSignature}`,
42
54
  '-f',
43
- dockerfilePath,
44
- config.repoRoot
55
+ toEnginePath(engine, dockerfilePath),
56
+ toEnginePath(engine, config.repoRoot)
45
57
  ];
46
58
  }
47
59
 
48
- function removeImageIfPresent(imageName) {
49
- if (runOk('docker', ['image', 'inspect', imageName])) {
50
- run('docker', ['rmi', imageName]);
60
+ function removeImageIfPresent(imageName, engine) {
61
+ if (runOkEngine(engine, 'docker', ['image', 'inspect', imageName])) {
62
+ runEngine(engine, 'docker', ['rmi', imageName]);
51
63
  }
52
64
  }
53
65
 
@@ -72,6 +84,7 @@ export async function rebuild(args) {
72
84
  const preparedDockerfile = prepareDockerfile(config);
73
85
  const imageSignature = buildSignature(preparedDockerfile, tools);
74
86
  const quiet = values.quiet ?? false;
87
+ const engine = detectEngine(config);
75
88
 
76
89
  await ensureDocker(config);
77
90
  p.intro(pc.cyan('Rebuilding sandbox image'));
@@ -80,18 +93,21 @@ export async function rebuild(args) {
80
93
  if (quiet) {
81
94
  const spinner = p.spinner();
82
95
  spinner.start(`Removing old image ${config.imageName}...`);
83
- removeImageIfPresent(config.imageName);
96
+ removeImageIfPresent(config.imageName, engine);
84
97
  spinner.stop('Old image removed');
85
98
  spinner.start('Building image...');
86
- run('docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature), { cwd: config.repoRoot });
99
+ runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }), {
100
+ cwd: config.repoRoot
101
+ });
87
102
  spinner.stop(pc.green('Sandbox image rebuilt'));
88
103
  } else {
89
104
  p.log.step(`Removing old image ${config.imageName}`);
90
- removeImageIfPresent(config.imageName);
105
+ removeImageIfPresent(config.imageName, engine);
91
106
  p.log.step('Building image');
92
- runVerbose(
107
+ runVerboseEngine(
108
+ engine,
93
109
  'docker',
94
- buildArgs(config, tools, preparedDockerfile.path, imageSignature),
110
+ buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }),
95
111
  { cwd: config.repoRoot }
96
112
  );
97
113
  p.log.success(pc.green('Sandbox image rebuilt'));
@@ -0,0 +1,119 @@
1
+ import { homedir } from 'node:os';
2
+ import { parseArgs } from 'node:util';
3
+ import {
4
+ buildLockedGuidance,
5
+ discoverProjects,
6
+ formatCredentialWarnings,
7
+ formatRemaining,
8
+ reconcileClaudeCredentials,
9
+ redactCommandError,
10
+ validateClaudeCredentialsEnvOverride
11
+ } from '../credentials.js';
12
+ import { runProbe } from '../shell.js';
13
+
14
+ const USAGE = 'Usage: ai sandbox refresh';
15
+
16
+ export function probeClaudeStatus(spawnFn = runProbe) {
17
+ const result = spawnFn('claude', ['/status'], {
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'pipe'],
20
+ timeout: 30_000
21
+ });
22
+ return {
23
+ ok: result.status === 0,
24
+ stderr: result.stderr ?? '',
25
+ error: result.error?.message ?? null
26
+ };
27
+ }
28
+
29
+ export async function refresh(args, deps = {}) {
30
+ const {
31
+ spawnFn = runProbe,
32
+ execFn,
33
+ readFn,
34
+ existsFn,
35
+ writeFn,
36
+ writeHostFn,
37
+ discoverFn = discoverProjects,
38
+ writeStdout = (chunk) => process.stdout.write(chunk),
39
+ writeStderr = (chunk) => process.stderr.write(chunk)
40
+ } = deps;
41
+
42
+ if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
43
+ writeStdout(`${USAGE}\n`);
44
+ return 0;
45
+ }
46
+
47
+ const { positionals } = parseArgs({ args, allowPositionals: true, strict: true });
48
+ if (positionals.length > 0) {
49
+ throw new Error(USAGE);
50
+ }
51
+ validateClaudeCredentialsEnvOverride();
52
+
53
+ const home = homedir();
54
+ if (!home) {
55
+ throw new Error('sandbox: home directory is required');
56
+ }
57
+
58
+ const projects = discoverFn(home);
59
+ if (projects.length === 0) {
60
+ writeStdout('No project credentials to refresh.\n');
61
+ return 0;
62
+ }
63
+
64
+ const reconcileOptions = { execFn, readFn, existsFn, writeFn, writeHostFn, projects };
65
+ let result = reconcileClaudeCredentials(home, reconcileOptions);
66
+ if (result.status === 'STALE_ACCESS' && result.authoritative === null) {
67
+ writeStdout('Host credentials appear stale; probing claude /status to trigger refresh...\n');
68
+ const probe = probeClaudeStatus(spawnFn);
69
+ if (!probe.ok) {
70
+ writeStderr(`Probe failed: ${redactCommandError(probe.stderr || probe.error || 'unknown error')}\n`);
71
+ writeStderr('Run "claude /login" on the host to renew credentials.\n');
72
+ return 1;
73
+ }
74
+ writeStdout('Probe succeeded; re-inspecting host credentials.\n');
75
+ result = reconcileClaudeCredentials(home, reconcileOptions);
76
+ }
77
+
78
+ if (result.status === 'MISSING') {
79
+ writeStderr('No Claude Code credentials found on host.\n');
80
+ writeStderr('Run "claude /login" on the host to authenticate.\n');
81
+ return 1;
82
+ }
83
+
84
+ if (result.status === 'KEYCHAIN_LOCKED') {
85
+ writeStderr(`${buildLockedGuidance()}\n`);
86
+ return 1;
87
+ }
88
+
89
+ if (result.status === 'KEYCHAIN_ERROR') {
90
+ writeStderr(`Host keychain error: ${redactCommandError(result.detail || 'unknown error')}\n`);
91
+ writeStderr(`${buildLockedGuidance()}\n`);
92
+ return 1;
93
+ }
94
+
95
+ if (result.status === 'KEYCHAIN_WRITE_FAILED') {
96
+ writeStderr(`[host] keychain write failed: ${formatCredentialWarnings(result.warnings) || 'unknown error'}\n`);
97
+ return 1;
98
+ }
99
+
100
+ if (result.status !== 'OK') {
101
+ writeStderr('Host credentials still invalid after probe; run "claude /login".\n');
102
+ return 1;
103
+ }
104
+
105
+ if (result.authoritative && result.authoritative !== 'host' && result.hostWritten) {
106
+ writeStdout(`[host] reconciled from ${result.authoritative}\n`);
107
+ }
108
+
109
+ for (const project of projects) {
110
+ const action = result.filesWritten.includes(project) ? 'updated' : 'unchanged';
111
+ writeStdout(`[${project}] ${action}; expires in ${formatRemaining(result.expiresAt)}\n`);
112
+ }
113
+
114
+ for (const failure of result.fileErrors) {
115
+ writeStderr(`[${failure.project}] sync failed: ${failure.error}\n`);
116
+ }
117
+
118
+ return result.fileErrors.length > 0 ? 1 : 0;
119
+ }
@@ -9,10 +9,11 @@ import {
9
9
  containerNameCandidates,
10
10
  sandboxBranchLabel,
11
11
  sandboxLabel,
12
+ shareBranchDir,
12
13
  worktreeDirCandidates
13
14
  } from '../constants.js';
14
- import { detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.js';
15
- import { run, runOk, runSafe } from '../shell.js';
15
+ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.js';
16
+ import { run, runOk, runSafe, runSafeEngine } from '../shell.js';
16
17
  import { resolveTaskBranch } from '../task-resolver.js';
17
18
  import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.js';
18
19
 
@@ -22,8 +23,20 @@ function projectToolDirs(config, tools) {
22
23
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
23
24
  }
24
25
 
26
+ export function assertManagedPath(root, target) {
27
+ const resolvedRoot = path.resolve(root);
28
+ const resolvedTarget = path.resolve(target);
29
+ const relative = path.relative(resolvedRoot, resolvedTarget);
30
+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
31
+ return;
32
+ }
33
+
34
+ throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
35
+ }
36
+
25
37
  async function rmOne(config, tools, branch) {
26
38
  assertValidBranchName(branch);
39
+ const engine = detectEngine(config);
27
40
  let effectiveBranch = branch;
28
41
  let worktreeCandidates = worktreeDirCandidates(config, branch);
29
42
  let toolCandidates = tools.map((tool) => ({
@@ -33,12 +46,12 @@ async function rmOne(config, tools, branch) {
33
46
 
34
47
  p.intro(pc.cyan(`Removing sandbox for ${branch}`));
35
48
 
36
- const existing = runSafe('docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
49
+ const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
37
50
  const matchedContainers = containerNameCandidates(config, branch)
38
51
  .filter((name) => existing.includes(name));
39
52
 
40
53
  if (matchedContainers.length > 0) {
41
- const resolvedBranch = runSafe('docker', [
54
+ const resolvedBranch = runSafeEngine(engine, 'docker', [
42
55
  'inspect',
43
56
  '-f',
44
57
  `{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
@@ -56,8 +69,8 @@ async function rmOne(config, tools, branch) {
56
69
  const spinner = p.spinner();
57
70
  spinner.start(`Stopping container(s): ${matchedContainers.join(', ')}`);
58
71
  for (const name of matchedContainers) {
59
- runSafe('docker', ['stop', name]);
60
- runSafe('docker', ['rm', name]);
72
+ runSafeEngine(engine, 'docker', ['stop', name]);
73
+ runSafeEngine(engine, 'docker', ['rm', name]);
61
74
  }
62
75
  spinner.stop(pc.green(`Removed container(s): ${matchedContainers.join(', ')}`));
63
76
  } else {
@@ -81,6 +94,7 @@ async function rmOne(config, tools, branch) {
81
94
  try {
82
95
  run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
83
96
  } catch {
97
+ assertManagedPath(config.worktreeBase, worktree);
84
98
  fs.rmSync(worktree, { recursive: true, force: true });
85
99
  }
86
100
  }
@@ -100,18 +114,33 @@ async function rmOne(config, tools, branch) {
100
114
 
101
115
  for (const { tool, candidates } of toolCandidates) {
102
116
  for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
117
+ assertManagedPath(tool.sandboxBase, dir);
103
118
  fs.rmSync(dir, { recursive: true, force: true });
104
119
  p.log.success(`${tool.name} state removed: ${dir}`);
105
120
  }
106
121
  }
107
122
 
123
+ const shareBranch = shareBranchDir(config, effectiveBranch);
124
+ if (fs.existsSync(shareBranch)) {
125
+ const shouldRemoveShare = await p.confirm({
126
+ message: `Remove share dir for branch '${effectiveBranch}' (${shareBranch})?`,
127
+ initialValue: true
128
+ });
129
+ if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
130
+ assertManagedPath(config.shareBase, shareBranch);
131
+ fs.rmSync(shareBranch, { recursive: true, force: true });
132
+ p.log.success(`Share dir removed: ${shareBranch}`);
133
+ }
134
+ }
135
+
108
136
  p.outro(pc.green('Sandbox removed'));
109
137
  }
110
138
 
111
139
  async function rmAll(config, tools) {
140
+ const engine = detectEngine(config);
112
141
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
113
142
 
114
- const containers = runSafe('docker', [
143
+ const containers = runSafeEngine(engine, 'docker', [
115
144
  'ps',
116
145
  '-a',
117
146
  '--filter',
@@ -123,8 +152,8 @@ async function rmAll(config, tools) {
123
152
  const spinner = p.spinner();
124
153
  spinner.start('Stopping project sandbox containers...');
125
154
  for (const name of containers.split('\n').filter(Boolean)) {
126
- runSafe('docker', ['stop', name]);
127
- runSafe('docker', ['rm', name]);
155
+ runSafeEngine(engine, 'docker', ['stop', name]);
156
+ runSafeEngine(engine, 'docker', ['rm', name]);
128
157
  }
129
158
  spinner.stop(pc.green('Project sandbox containers removed'));
130
159
  } else {
@@ -143,6 +172,7 @@ async function rmAll(config, tools) {
143
172
  try {
144
173
  run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
145
174
  } catch {
175
+ assertManagedPath(config.worktreeBase, dir);
146
176
  fs.rmSync(dir, { recursive: true, force: true });
147
177
  }
148
178
  }
@@ -152,21 +182,39 @@ async function rmAll(config, tools) {
152
182
 
153
183
  for (const dir of projectToolDirs(config, tools)) {
154
184
  if (fs.existsSync(dir)) {
185
+ assertManagedPath(path.dirname(dir), dir);
155
186
  fs.rmSync(dir, { recursive: true, force: true });
156
187
  p.log.success(`Removed tool state: ${dir}`);
157
188
  }
158
189
  }
159
190
 
191
+ if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
192
+ const shouldRemoveAllShares = await p.confirm({
193
+ message: `Remove all share dirs for project (${config.shareBase})?`,
194
+ initialValue: true
195
+ });
196
+ if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
197
+ assertManagedPath(path.dirname(config.shareBase), config.shareBase);
198
+ fs.rmSync(config.shareBase, { recursive: true, force: true });
199
+ p.log.success(`Project share dirs removed: ${config.shareBase}`);
200
+ }
201
+ }
202
+
160
203
  const shouldRemoveImage = await p.confirm({
161
204
  message: `Remove image ${config.imageName}?`,
162
205
  initialValue: false
163
206
  });
164
207
  if (!p.isCancel(shouldRemoveImage) && shouldRemoveImage) {
165
- runSafe('docker', ['rmi', config.imageName]);
208
+ runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
166
209
  }
167
210
 
168
- const engine = detectEngine(config);
169
211
  if (isManagedEngine(engine)) {
212
+ if (engine === ENGINES.WSL2) {
213
+ p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');
214
+ p.outro(pc.green('All project sandboxes removed'));
215
+ return;
216
+ }
217
+
170
218
  const name = engineDisplayName(engine);
171
219
  const shouldStopVm = await p.confirm({
172
220
  message: `Stop ${name} VM?`,
@@ -7,20 +7,38 @@ import {
7
7
  ENGINES,
8
8
  detectEngine,
9
9
  engineDisplayName,
10
- ensureDocker,
11
10
  isManagedEngine,
11
+ startManagedVm,
12
12
  stopManagedVm
13
13
  } from '../engine.js';
14
14
  import { runOk, runSafe } from '../shell.js';
15
15
 
16
16
  const USAGE = `Usage: ai sandbox vm <status|start|stop> [--cpu <n>] [--memory <n>]`;
17
17
 
18
- function ensureManagedVm(engine) {
18
+ export function ensureManagedVm(engine) {
19
+ if (engine === ENGINES.NATIVE) {
20
+ throw new Error(
21
+ "Linux native Docker does not use a managed VM. Use 'ai sandbox create' directly."
22
+ );
23
+ }
24
+
19
25
  if (!isManagedEngine(engine)) {
20
- throw new Error(`VM management is unavailable for engine '${engineDisplayName(engine)}'.`);
26
+ throw new Error(
27
+ `VM management is unavailable for engine '${engineDisplayName(engine)}'. `
28
+ + (engine === ENGINES.DOCKER_DESKTOP
29
+ ? 'Docker Desktop is managed via its GUI (Settings -> Resources).'
30
+ : '')
31
+ );
21
32
  }
22
33
  }
23
34
 
35
+ export function wsl2BackendStatus({ runOkFn = runOk } = {}) {
36
+ const wslAvailable = runOkFn('wsl.exe', ['--status']) || runOkFn('wsl.exe', ['--', 'true']);
37
+ const dockerAvailable = wslAvailable && runOkFn('wsl.exe', ['--', 'docker', 'info']);
38
+
39
+ return { wslAvailable, dockerAvailable };
40
+ }
41
+
24
42
  function status() {
25
43
  const config = loadConfig();
26
44
  const engine = detectEngine(config);
@@ -28,6 +46,22 @@ function status() {
28
46
  ensureManagedVm(engine);
29
47
  p.intro(pc.cyan('Sandbox VM status'));
30
48
 
49
+ if (engine === ENGINES.WSL2) {
50
+ const backend = wsl2BackendStatus();
51
+ if (backend.wslAvailable) {
52
+ p.log.info('WSL2 is available');
53
+ } else {
54
+ p.log.warn('WSL2 is not available. Install WSL2 and configure a default Linux distribution.');
55
+ }
56
+
57
+ if (backend.dockerAvailable) {
58
+ p.log.info('Docker Desktop WSL integration is available');
59
+ } else {
60
+ p.log.warn('Docker is not available inside WSL2. Start Docker Desktop and enable WSL integration.');
61
+ }
62
+ return;
63
+ }
64
+
31
65
  if (engine === ENGINES.COLIMA) {
32
66
  if (runOk('colima', ['status'])) {
33
67
  process.stdout.write(`${runSafe('colima', ['status'])}\n`);
@@ -65,6 +99,16 @@ async function start(args) {
65
99
  const config = loadConfig();
66
100
  const engine = detectEngine(config);
67
101
  ensureManagedVm(engine);
102
+
103
+ p.intro(pc.cyan('Starting sandbox VM'));
104
+ if (engine === ENGINES.WSL2) {
105
+ p.log.warn(
106
+ 'WSL2 Docker backend is managed by Docker Desktop. '
107
+ + 'Start it from Docker Desktop GUI, then run "ai sandbox vm status" to check readiness.'
108
+ );
109
+ return;
110
+ }
111
+
68
112
  const effectiveConfig = {
69
113
  ...config,
70
114
  vm: {
@@ -74,10 +118,11 @@ async function start(args) {
74
118
  }
75
119
  };
76
120
 
77
- p.intro(pc.cyan('Starting sandbox VM'));
78
- await ensureDocker(effectiveConfig, (detail) => {
121
+ const onMessage = (detail) => {
79
122
  p.log.info(detail);
80
- });
123
+ };
124
+
125
+ startManagedVm(effectiveConfig, { onMessage });
81
126
  p.outro(pc.green('VM ready'));
82
127
  }
83
128
 
@@ -88,6 +133,11 @@ function stop() {
88
133
  ensureManagedVm(engine);
89
134
  p.intro(pc.cyan('Stopping sandbox VM'));
90
135
 
136
+ if (engine === ENGINES.WSL2) {
137
+ p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');
138
+ return;
139
+ }
140
+
91
141
  if (engine === ENGINES.COLIMA && !runOk('colima', ['status'])) {
92
142
  p.log.warn(`${name} VM is not running`);
93
143
  return;