@fitlab-ai/agent-infra 0.6.2-alpha.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +13 -3
  2. package/README.zh-CN.md +10 -3
  3. package/bin/cli.ts +6 -1
  4. package/dist/bin/cli.js +6 -1
  5. package/dist/lib/sandbox/clipboard/bridge.js +216 -0
  6. package/dist/lib/sandbox/clipboard/darwin.js +73 -0
  7. package/dist/lib/sandbox/clipboard/index.js +9 -0
  8. package/dist/lib/sandbox/clipboard/keys.js +58 -0
  9. package/dist/lib/sandbox/clipboard/node-pty.js +13 -0
  10. package/dist/lib/sandbox/clipboard/paths.js +59 -0
  11. package/dist/lib/sandbox/commands/create.js +38 -21
  12. package/dist/lib/sandbox/commands/enter.js +8 -2
  13. package/dist/lib/sandbox/commands/ls.js +19 -4
  14. package/dist/lib/sandbox/commands/prune.js +176 -0
  15. package/dist/lib/sandbox/commands/rm.js +27 -33
  16. package/dist/lib/sandbox/config.js +1 -0
  17. package/dist/lib/sandbox/constants.js +6 -0
  18. package/dist/lib/sandbox/credentials.js +43 -24
  19. package/dist/lib/sandbox/index.js +7 -1
  20. package/dist/lib/sandbox/managed-fs.js +25 -0
  21. package/dist/lib/sandbox/tools.js +1 -1
  22. package/dist/lib/version.js +9 -2
  23. package/lib/sandbox/clipboard/bridge.ts +285 -0
  24. package/lib/sandbox/clipboard/darwin.ts +90 -0
  25. package/lib/sandbox/clipboard/index.ts +13 -0
  26. package/lib/sandbox/clipboard/keys.ts +78 -0
  27. package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
  28. package/lib/sandbox/clipboard/node-pty.ts +34 -0
  29. package/lib/sandbox/clipboard/paths.ts +71 -0
  30. package/lib/sandbox/commands/create.ts +44 -21
  31. package/lib/sandbox/commands/enter.ts +8 -2
  32. package/lib/sandbox/commands/ls.ts +28 -4
  33. package/lib/sandbox/commands/prune.ts +211 -0
  34. package/lib/sandbox/commands/rm.ts +30 -32
  35. package/lib/sandbox/config.ts +2 -0
  36. package/lib/sandbox/constants.ts +9 -0
  37. package/lib/sandbox/credentials.ts +49 -26
  38. package/lib/sandbox/index.ts +7 -1
  39. package/lib/sandbox/managed-fs.ts +27 -0
  40. package/lib/sandbox/tools.ts +1 -1
  41. package/lib/version.ts +11 -4
  42. package/package.json +5 -1
  43. package/templates/.agents/README.en.md +19 -0
  44. package/templates/.agents/README.zh-CN.md +19 -0
  45. package/templates/.agents/rules/create-issue.github.en.md +3 -3
  46. package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
  47. package/templates/.agents/skills/analyze-task/SKILL.en.md +29 -0
  48. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +29 -0
  49. package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
  50. package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
  51. package/templates/.agents/skills/complete-task/SKILL.en.md +16 -0
  52. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +16 -0
  53. package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
  54. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
  55. package/templates/.agents/skills/create-pr/config/verify.json +1 -0
  56. package/templates/.agents/skills/create-task/SKILL.en.md +3 -3
  57. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -3
  58. package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
  59. package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
  60. package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
  61. package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
  62. package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
  63. package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
  64. package/templates/.agents/skills/plan-task/SKILL.en.md +24 -0
  65. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +24 -0
  66. package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
  67. package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
  68. package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
  69. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
  70. package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
  71. package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
  72. package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
  73. package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
  74. package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
  75. package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
  76. package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
  77. package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
  78. package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
  79. package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
  80. package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
  81. package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
  82. package/dist/package.json +0 -5
@@ -17,9 +17,9 @@ import {
17
17
  sandboxBranchLabel,
18
18
  sandboxImageConfigLabel,
19
19
  sandboxLabel,
20
- sanitizeBranchName,
21
20
  shareBranchDir,
22
21
  shareCommonDir,
22
+ shellConfigDir,
23
23
  worktreeDirCandidates
24
24
  } from '../constants.ts';
25
25
  import { prepareDockerfile } from '../dockerfile.ts';
@@ -39,11 +39,12 @@ import { resolveTaskBranch } from '../task-resolver.ts';
39
39
  import { resolveTools, toolConfigDirCandidates, toolNpmPackagesArg } from '../tools.ts';
40
40
  import type { SandboxTool } from '../tools.ts';
41
41
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
42
+ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.ts';
42
43
  import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
43
44
  import { resolveBuildUid } from '../engines/native.ts';
44
45
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
45
46
  import {
46
- assertClaudeCredentialsAvailable,
47
+ prepareClaudeCredentials,
47
48
  redactCommandError,
48
49
  validateClaudeCredentialsEnvOverride
49
50
  } from '../credentials.ts';
@@ -127,7 +128,17 @@ function resolveToolDirs(config: Pick<SandboxCreateConfig, 'project'>, tools: Sa
127
128
  }
128
129
 
129
130
  export function hostShellConfigDir(home: string, project: string, branch: string): string {
130
- return hostJoin(home, '.agent-infra', 'config', project, sanitizeBranchName(branch));
131
+ return shellConfigDir(
132
+ { shellConfigBase: hostJoin(home, '.agent-infra', 'config', project) },
133
+ branch
134
+ );
135
+ }
136
+
137
+ export function buildClipboardVolumeArgs(engine: string, home: string): string[] {
138
+ return [
139
+ '-v',
140
+ volumeArg(engine, clipboardHostDir(home), CONTAINER_CLIPBOARD_MOUNT, ':ro')
141
+ ];
131
142
  }
132
143
 
133
144
  function runtimeChecks(runtimes: string[]): RuntimeCheck[] {
@@ -1100,15 +1111,17 @@ export async function create(args: string[]): Promise<void> {
1100
1111
  assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
1101
1112
  const tools = resolveTools(effectiveConfig);
1102
1113
  const resolvedTools = resolveToolDirs(effectiveConfig, tools, branch);
1103
- // Fail fast before any filesystem/docker side effects so a missing
1104
- // Claude Code credential blob doesn't leave the user with a stale
1105
- // worktree, docker image, or temporary Dockerfile they need to manually
1106
- // clean up.
1107
- assertClaudeCredentialsAvailable(
1114
+ // Fatal credential states still fail before filesystem/docker side effects.
1115
+ // A genuinely missing Claude Code credential only removes Claude Code's
1116
+ // sandbox config and credential mounts for this create run.
1117
+ const credentialOutcome = prepareClaudeCredentials(
1108
1118
  effectiveConfig.home,
1109
1119
  effectiveConfig.project,
1110
1120
  resolvedTools
1111
1121
  );
1122
+ const effectiveResolvedTools = credentialOutcome.status === 'SKIPPED'
1123
+ ? resolvedTools.filter(({ tool }) => tool.id !== 'claude-code')
1124
+ : resolvedTools;
1112
1125
  const container = containerName(effectiveConfig, branch);
1113
1126
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
1114
1127
  const shareCommon = shareCommonDir(effectiveConfig);
@@ -1122,6 +1135,13 @@ export async function create(args: string[]): Promise<void> {
1122
1135
  p.log.info(
1123
1136
  `Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`
1124
1137
  );
1138
+ if (credentialOutcome.status === 'SKIPPED') {
1139
+ p.log.warn(
1140
+ 'Claude Code credentials not found on host - creating this sandbox WITHOUT Claude Code credentials.\n'
1141
+ + ' Claude Code is still installed in the image but will not be authenticated.\n'
1142
+ + ' To enable it: run "claude" once on the host to complete login, then re-run "ai sandbox create".'
1143
+ );
1144
+ }
1125
1145
 
1126
1146
  try {
1127
1147
  p.log.step('Checking container engine...');
@@ -1206,7 +1226,7 @@ export async function create(args: string[]): Promise<void> {
1206
1226
  {
1207
1227
  title: 'Preparing tool state',
1208
1228
  task: async () => {
1209
- for (const { tool, dir } of resolvedTools) {
1229
+ for (const { tool, dir } of effectiveResolvedTools) {
1210
1230
  fs.mkdirSync(dir, { recursive: true });
1211
1231
 
1212
1232
  for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
@@ -1243,7 +1263,7 @@ export async function create(args: string[]): Promise<void> {
1243
1263
  }
1244
1264
  }
1245
1265
 
1246
- return `${resolvedTools.length} tool config directories ready`;
1266
+ return `${effectiveResolvedTools.length} tool config directories ready`;
1247
1267
  }
1248
1268
  },
1249
1269
  {
@@ -1283,31 +1303,32 @@ export async function create(args: string[]): Promise<void> {
1283
1303
  signingKey
1284
1304
  )
1285
1305
  : null;
1286
- const envFile = buildContainerEnvFile(resolvedTools, engine);
1306
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1287
1307
  let hostShellConfig: HostShellConfig;
1288
1308
  try {
1289
- const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
1309
+ const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
1290
1310
  if (claudeCodeEntry) {
1291
1311
  ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
1292
1312
  ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
1293
- // Credential availability is asserted up-front in create() so we
1294
- // know the shared credentials file already exists at this point.
1313
+ // prepareClaudeCredentials wrote the shared credentials file
1314
+ // before this point. If credentials were missing, the
1315
+ // claude-code entry was removed from effectiveResolvedTools.
1295
1316
  }
1296
- const codexEntry = resolvedTools.find(({ tool }) => tool.id === 'codex');
1317
+ const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
1297
1318
  if (codexEntry) {
1298
1319
  ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
1299
1320
  ensureCodexWorkspaceTrust(codexEntry.dir);
1300
1321
  }
1301
- const geminiEntry = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1322
+ const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1302
1323
  if (geminiEntry) {
1303
1324
  ensureGeminiWorkspaceTrust(geminiEntry.dir);
1304
1325
  }
1305
- const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
1326
+ const opencodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'opencode');
1306
1327
  if (opencodeEntry) {
1307
1328
  // The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
1308
1329
  ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
1309
1330
  }
1310
- const toolVolumes = resolvedTools.flatMap(({ tool, dir }) => [
1331
+ const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => [
1311
1332
  '-v',
1312
1333
  volumeArg(engine, dir, tool.containerMount)
1313
1334
  ]);
@@ -1322,7 +1343,7 @@ export async function create(args: string[]): Promise<void> {
1322
1343
  '-v',
1323
1344
  volumeArg(engine, hostPath, containerPath, ':ro')
1324
1345
  ]);
1325
- const liveMountVolumes = resolvedTools.flatMap(({ tool }) =>
1346
+ const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) =>
1326
1347
  (tool.hostLiveMounts ?? [])
1327
1348
  .filter(({ hostPath }) => fs.existsSync(hostPath))
1328
1349
  .flatMap(({ hostPath, containerSubpath }) => [
@@ -1334,6 +1355,7 @@ export async function create(args: string[]): Promise<void> {
1334
1355
  fs.mkdirSync(workspaceDir, { recursive: true });
1335
1356
  fs.mkdirSync(shareCommon, { recursive: true });
1336
1357
  fs.mkdirSync(shareBranch, { recursive: true });
1358
+ fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
1337
1359
 
1338
1360
  const dotfilesSnapshot = materializeDotfiles(
1339
1361
  effectiveConfig.dotfilesDir,
@@ -1362,6 +1384,7 @@ export async function create(args: string[]): Promise<void> {
1362
1384
  volumeArg(engine, shareCommon, '/share/common'),
1363
1385
  '-v',
1364
1386
  volumeArg(engine, shareBranch, '/share/branch'),
1387
+ ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1365
1388
  '-v',
1366
1389
  volumeArg(
1367
1390
  engine,
@@ -1435,7 +1458,7 @@ export async function create(args: string[]): Promise<void> {
1435
1458
  }
1436
1459
  }
1437
1460
 
1438
- for (const { tool } of resolvedTools) {
1461
+ for (const { tool } of effectiveResolvedTools) {
1439
1462
  for (const command of tool.postSetupCmds ?? []) {
1440
1463
  runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
1441
1464
  }
@@ -1477,7 +1500,7 @@ export async function create(args: string[]): Promise<void> {
1477
1500
 
1478
1501
  p.outro(pc.green('Sandbox ready'));
1479
1502
 
1480
- const toolHints = resolvedTools.map(({ tool, dir }) => {
1503
+ const toolHints = effectiveResolvedTools.map(({ tool, dir }) => {
1481
1504
  const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
1482
1505
  const hint = hasLiveMount
1483
1506
  ? 'Live-mounted auth/config files stay in sync with the host.'
@@ -11,6 +11,7 @@ import {
11
11
  import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
12
12
  import { resolveTaskBranch } from '../task-resolver.ts';
13
13
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
14
+ import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
14
15
 
15
16
  const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
16
17
  const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
@@ -65,7 +66,7 @@ export function formatCredentialSyncStatus(
65
66
  return null;
66
67
  }
67
68
 
68
- export function enter(args: string[]): number {
69
+ export async function enter(args: string[]): Promise<number> {
69
70
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
70
71
  process.stdout.write(`${USAGE}\n`);
71
72
  if (args.length === 0) {
@@ -108,7 +109,12 @@ export function enter(args: string[]): number {
108
109
  process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
109
110
  }
110
111
 
111
- return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH]);
112
+ return runInteractiveWithClipboardBridge({
113
+ engine,
114
+ dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
115
+ container,
116
+ home: config.home
117
+ });
112
118
  }
113
119
 
114
120
  return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);
@@ -9,7 +9,13 @@ import { runSafeEngine } from '../shell.ts';
9
9
  import { resolveTools, toolProjectDirCandidates } from '../tools.ts';
10
10
 
11
11
  const USAGE = 'Usage: ai sandbox ls';
12
- const CONTAINER_LIST_HEADER = 'NAMES\tSTATUS\tBRANCH';
12
+ const CONTAINER_TABLE_HEADERS = ['NAMES', 'STATUS', 'BRANCH'] as const;
13
+
14
+ type ContainerTableRow = {
15
+ name: string;
16
+ status: string;
17
+ branch: string;
18
+ };
13
19
 
14
20
  // Exported to lock the docker/podman-compatible format in unit tests.
15
21
  export function containerListFormat(): string {
@@ -35,6 +41,22 @@ export function parseLabels(csv: string): Record<string, string> {
35
41
  return labels;
36
42
  }
37
43
 
44
+ export function formatContainerTable(rows: ContainerTableRow[]): string[] {
45
+ const columns = rows.map((row) => [row.name, row.status, row.branch] as const);
46
+ const widths = [
47
+ Math.max(CONTAINER_TABLE_HEADERS[0].length, ...rows.map((row) => row.name.length)),
48
+ Math.max(CONTAINER_TABLE_HEADERS[1].length, ...rows.map((row) => row.status.length)),
49
+ Math.max(CONTAINER_TABLE_HEADERS[2].length, ...rows.map((row) => row.branch.length))
50
+ ] as const;
51
+ const renderRow = (values: readonly [string, string, string]): string =>
52
+ `${values[0].padEnd(widths[0])} ${values[1].padEnd(widths[1])} ${values[2]}`.trimEnd();
53
+
54
+ return [
55
+ renderRow(CONTAINER_TABLE_HEADERS),
56
+ ...columns.map((column) => renderRow(column))
57
+ ];
58
+ }
59
+
38
60
  function listChildren(dir: string): string[] {
39
61
  if (!fs.existsSync(dir)) {
40
62
  return [];
@@ -69,11 +91,13 @@ export function ls(args: string[] = []): void {
69
91
  p.log.warn(' No sandbox containers');
70
92
  } else {
71
93
  const branchKey = sandboxBranchLabel(config);
72
- process.stdout.write(` ${CONTAINER_LIST_HEADER}\n`);
73
- for (const line of containers.split('\n')) {
94
+ const rows = containers.split('\n').map((line) => {
74
95
  const [name = '', status = '', labelsCsv = ''] = line.split('\t');
75
96
  const branch = parseLabels(labelsCsv)[branchKey] ?? '';
76
- process.stdout.write(` ${name}\t${status}\t${branch}\n`);
97
+ return { name, status, branch };
98
+ });
99
+ for (const line of formatContainerTable(rows)) {
100
+ process.stdout.write(` ${line}\n`);
77
101
  }
78
102
  }
79
103
 
@@ -0,0 +1,211 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import * as p from '@clack/prompts';
5
+ import pc from 'picocolors';
6
+ import { loadConfig } from '../config.ts';
7
+ import type { SandboxConfig } from '../config.ts';
8
+ import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.ts';
9
+ import { detectEngine } from '../engine.ts';
10
+ import { hostJoin } from '../engines/wsl2-paths.ts';
11
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
12
+ import { parseLabels } from './ls.ts';
13
+ import { runEngine, runSafe } from '../shell.ts';
14
+ import { resolveTools } from '../tools.ts';
15
+ import type { SandboxTool } from '../tools.ts';
16
+
17
+ const USAGE = `Usage: ai sandbox prune [--dry-run]`;
18
+
19
+ type OrphanKind = 'shell' | 'worktree' | 'share' | 'tool';
20
+
21
+ export type OrphanGroup = {
22
+ kind: OrphanKind;
23
+ label: string;
24
+ base: string;
25
+ dirs: string[];
26
+ };
27
+
28
+ function listChildDirs(base: string): string[] {
29
+ if (!fs.existsSync(base)) {
30
+ return [];
31
+ }
32
+
33
+ return fs.readdirSync(base)
34
+ .sort()
35
+ .map((entry) => path.join(base, entry))
36
+ .filter((entry) => {
37
+ try {
38
+ return fs.statSync(entry).isDirectory();
39
+ } catch {
40
+ return false;
41
+ }
42
+ });
43
+ }
44
+
45
+ function activeSafeNames(activeBranches: string[]): Set<string> {
46
+ const names = new Set<string>();
47
+ for (const branch of activeBranches) {
48
+ try {
49
+ for (const name of safeNameCandidates(branch)) {
50
+ names.add(name);
51
+ }
52
+ } catch {
53
+ names.add(branch);
54
+ }
55
+ }
56
+ return names;
57
+ }
58
+
59
+ function orphanDirs(base: string, activeNames: Set<string>): string[] {
60
+ return listChildDirs(base).filter((dir) => !activeNames.has(path.basename(dir)));
61
+ }
62
+
63
+ function addGroup(groups: OrphanGroup[], group: OrphanGroup): void {
64
+ if (group.dirs.length > 0) {
65
+ groups.push(group);
66
+ }
67
+ }
68
+
69
+ export function collectOrphanGroups(
70
+ config: SandboxConfig,
71
+ tools: SandboxTool[],
72
+ activeBranches: string[]
73
+ ): OrphanGroup[] {
74
+ const activeNames = activeSafeNames(activeBranches);
75
+ const groups: OrphanGroup[] = [];
76
+ const shareBranchesBase = hostJoin(config.shareBase, 'branches');
77
+
78
+ addGroup(groups, {
79
+ kind: 'shell',
80
+ label: 'Shell config dirs',
81
+ base: config.shellConfigBase,
82
+ dirs: orphanDirs(config.shellConfigBase, activeNames)
83
+ });
84
+ addGroup(groups, {
85
+ kind: 'worktree',
86
+ label: 'Worktrees',
87
+ base: config.worktreeBase,
88
+ dirs: orphanDirs(config.worktreeBase, activeNames)
89
+ });
90
+ addGroup(groups, {
91
+ kind: 'share',
92
+ label: 'Share branch dirs',
93
+ base: shareBranchesBase,
94
+ dirs: orphanDirs(shareBranchesBase, activeNames)
95
+ });
96
+
97
+ for (const tool of tools) {
98
+ const base = hostJoin(tool.sandboxBase, config.project);
99
+ addGroup(groups, {
100
+ kind: 'tool',
101
+ label: `${tool.name} state`,
102
+ base,
103
+ dirs: orphanDirs(base, activeNames)
104
+ });
105
+ }
106
+
107
+ return groups;
108
+ }
109
+
110
+ export function removeOrphanGroups(config: SandboxConfig, groups: OrphanGroup[]): boolean {
111
+ let removedWorktrees = false;
112
+ for (const group of groups) {
113
+ for (const dir of group.dirs) {
114
+ if (group.kind === 'worktree') {
115
+ removeWorktreeDir(config.repoRoot, group.base, dir);
116
+ removedWorktrees = true;
117
+ } else {
118
+ removeManagedDir(group.base, dir);
119
+ }
120
+ }
121
+ }
122
+ return removedWorktrees;
123
+ }
124
+
125
+ function activeBranchesFromLabels(config: SandboxConfig, labelsOutput: string): string[] {
126
+ const branchKey = sandboxBranchLabel(config);
127
+ return labelsOutput.split('\n')
128
+ .map((line) => parseLabels(line)[branchKey] ?? '')
129
+ .filter(Boolean);
130
+ }
131
+
132
+ function orphanCount(groups: OrphanGroup[]): number {
133
+ return groups.reduce((sum, group) => sum + group.dirs.length, 0);
134
+ }
135
+
136
+ function writeGroups(groups: OrphanGroup[]): void {
137
+ for (const group of groups) {
138
+ p.log.step(group.label);
139
+ for (const dir of group.dirs) {
140
+ process.stdout.write(` ${dir}\n`);
141
+ }
142
+ }
143
+ }
144
+
145
+ export async function prune(args: string[]): Promise<void> {
146
+ const { values } = parseArgs({
147
+ args,
148
+ strict: true,
149
+ options: {
150
+ 'dry-run': { type: 'boolean' },
151
+ help: { type: 'boolean', short: 'h' }
152
+ }
153
+ });
154
+
155
+ if (values.help) {
156
+ process.stdout.write(`${USAGE}\n`);
157
+ return;
158
+ }
159
+
160
+ const config = loadConfig();
161
+ const tools = resolveTools(config);
162
+ const engine = detectEngine(config);
163
+ const psArgs = [
164
+ 'ps',
165
+ '-a',
166
+ '--filter',
167
+ `label=${sandboxLabel(config)}`,
168
+ '--format',
169
+ '{{.Labels}}'
170
+ ];
171
+ let labelsOutput: string;
172
+ try {
173
+ labelsOutput = runEngine(engine, 'docker', psArgs);
174
+ } catch {
175
+ throw new Error('Unable to determine active sandbox branches: docker ps failed');
176
+ }
177
+ const groups = collectOrphanGroups(config, tools, activeBranchesFromLabels(config, labelsOutput));
178
+ const count = orphanCount(groups);
179
+
180
+ p.intro(pc.cyan(`Pruning orphaned sandbox state for ${config.project}`));
181
+
182
+ if (count === 0) {
183
+ p.log.success('No orphaned sandbox state dirs found');
184
+ p.outro(pc.green('Sandbox prune complete'));
185
+ return;
186
+ }
187
+
188
+ writeGroups(groups);
189
+
190
+ if (values['dry-run']) {
191
+ p.outro(pc.green('Dry run complete'));
192
+ return;
193
+ }
194
+
195
+ const shouldRemove = await p.confirm({
196
+ message: `Remove ${count} orphaned sandbox state dirs?`,
197
+ initialValue: true
198
+ });
199
+
200
+ if (p.isCancel(shouldRemove) || !shouldRemove) {
201
+ p.outro('Cancelled');
202
+ return;
203
+ }
204
+
205
+ const removedWorktrees = removeOrphanGroups(config, groups);
206
+ if (removedWorktrees) {
207
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
208
+ }
209
+
210
+ p.outro(pc.green('Orphaned sandbox state dirs removed'));
211
+ }
@@ -11,31 +11,23 @@ import {
11
11
  sandboxBranchLabel,
12
12
  sandboxLabel,
13
13
  shareBranchDir,
14
+ shellConfigDirCandidates,
14
15
  worktreeDirCandidates
15
16
  } from '../constants.ts';
16
17
  import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
17
- import { run, runOk, runSafe, runSafeEngine } from '../shell.ts';
18
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
19
+ import { runOk, runSafe, runSafeEngine } from '../shell.ts';
18
20
  import { resolveTaskBranch } from '../task-resolver.ts';
19
21
  import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
20
22
  import type { SandboxTool } from '../tools.ts';
21
23
 
22
24
  const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
25
+ export { assertManagedPath } from '../managed-fs.ts';
23
26
 
24
27
  function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[] {
25
28
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
26
29
  }
27
30
 
28
- export function assertManagedPath(root: string, target: string): void {
29
- const resolvedRoot = path.resolve(root);
30
- const resolvedTarget = path.resolve(target);
31
- const relative = path.relative(resolvedRoot, resolvedTarget);
32
- if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
33
- return;
34
- }
35
-
36
- throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
37
- }
38
-
39
31
  async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string): Promise<void> {
40
32
  assertValidBranchName(branch);
41
33
  const engine = detectEngine(config);
@@ -93,12 +85,7 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
93
85
 
94
86
  if (shouldRemoveWorktree) {
95
87
  for (const worktree of existingWorktrees) {
96
- try {
97
- run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
98
- } catch {
99
- assertManagedPath(config.worktreeBase, worktree);
100
- fs.rmSync(worktree, { recursive: true, force: true });
101
- }
88
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree);
102
89
  }
103
90
 
104
91
  const shouldDeleteBranch = await p.confirm({
@@ -116,12 +103,16 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
116
103
 
117
104
  for (const { tool, candidates } of toolCandidates) {
118
105
  for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
119
- assertManagedPath(tool.sandboxBase, dir);
120
- fs.rmSync(dir, { recursive: true, force: true });
106
+ removeManagedDir(tool.sandboxBase, dir);
121
107
  p.log.success(`${tool.name} state removed: ${dir}`);
122
108
  }
123
109
  }
124
110
 
111
+ for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => fs.existsSync(candidate))) {
112
+ removeManagedDir(config.shellConfigBase, dir);
113
+ p.log.success(`Shell config removed: ${dir}`);
114
+ }
115
+
125
116
  const shareBranch = shareBranchDir(config, effectiveBranch);
126
117
  if (fs.existsSync(shareBranch)) {
127
118
  const shouldRemoveShare = await p.confirm({
@@ -129,8 +120,7 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
129
120
  initialValue: true
130
121
  });
131
122
  if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
132
- assertManagedPath(config.shareBase, shareBranch);
133
- fs.rmSync(shareBranch, { recursive: true, force: true });
123
+ removeManagedDir(config.shareBase, shareBranch);
134
124
  p.log.success(`Share dir removed: ${shareBranch}`);
135
125
  }
136
126
  }
@@ -171,12 +161,7 @@ async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void>
171
161
  if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
172
162
  for (const entry of fs.readdirSync(config.worktreeBase)) {
173
163
  const dir = path.join(config.worktreeBase, entry);
174
- try {
175
- run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
176
- } catch {
177
- assertManagedPath(config.worktreeBase, dir);
178
- fs.rmSync(dir, { recursive: true, force: true });
179
- }
164
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, dir);
180
165
  }
181
166
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
182
167
  }
@@ -184,20 +169,33 @@ async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void>
184
169
 
185
170
  for (const dir of projectToolDirs(config, tools)) {
186
171
  if (fs.existsSync(dir)) {
187
- assertManagedPath(path.dirname(dir), dir);
188
- fs.rmSync(dir, { recursive: true, force: true });
172
+ removeManagedDir(path.dirname(dir), dir);
189
173
  p.log.success(`Removed tool state: ${dir}`);
190
174
  }
191
175
  }
192
176
 
177
+ if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
178
+ const shouldRemoveShellConfigs = await p.confirm({
179
+ message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
180
+ initialValue: true
181
+ });
182
+
183
+ if (!p.isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
184
+ for (const entry of fs.readdirSync(config.shellConfigBase)) {
185
+ const dir = path.join(config.shellConfigBase, entry);
186
+ removeManagedDir(config.shellConfigBase, dir);
187
+ }
188
+ p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
189
+ }
190
+ }
191
+
193
192
  if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
194
193
  const shouldRemoveAllShares = await p.confirm({
195
194
  message: `Remove all share dirs for project (${config.shareBase})?`,
196
195
  initialValue: true
197
196
  });
198
197
  if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
199
- assertManagedPath(path.dirname(config.shareBase), config.shareBase);
200
- fs.rmSync(config.shareBase, { recursive: true, force: true });
198
+ removeManagedDir(path.dirname(config.shareBase), config.shareBase);
201
199
  p.log.success(`Project share dirs removed: ${config.shareBase}`);
202
200
  }
203
201
  }
@@ -46,6 +46,7 @@ export type SandboxConfig = {
46
46
  imageName: string;
47
47
  worktreeBase: string;
48
48
  shareBase: string;
49
+ shellConfigBase: string;
49
50
  dotfilesDir: string;
50
51
  engine: string | null;
51
52
  runtimes: string[];
@@ -145,6 +146,7 @@ export function loadConfig({
145
146
  imageName: `${project}-sandbox:latest`,
146
147
  worktreeBase: hostJoin(home, '.agent-infra', 'worktrees', project),
147
148
  shareBase: hostJoin(home, '.agent-infra', 'share', project),
149
+ shellConfigBase: hostJoin(home, '.agent-infra', 'config', project),
148
150
  dotfilesDir: hostJoin(home, '.agent-infra', 'dotfiles'),
149
151
  engine,
150
152
  runtimes,
@@ -9,6 +9,7 @@ type SandboxPathConfig = {
9
9
  containerPrefix: string;
10
10
  worktreeBase: string;
11
11
  shareBase: string;
12
+ shellConfigBase: string;
12
13
  };
13
14
 
14
15
  type HostResources = {
@@ -86,6 +87,14 @@ export function shareBranchDir(config: Pick<SandboxPathConfig, 'shareBase'>, bra
86
87
  return hostJoin(config.shareBase, 'branches', sanitizeBranchName(branch));
87
88
  }
88
89
 
90
+ export function shellConfigDir(config: Pick<SandboxPathConfig, 'shellConfigBase'>, branch: string): string {
91
+ return hostJoin(config.shellConfigBase, sanitizeBranchName(branch));
92
+ }
93
+
94
+ export function shellConfigDirCandidates(config: Pick<SandboxPathConfig, 'shellConfigBase'>, branch: string): string[] {
95
+ return safeNameCandidates(branch).map((name) => hostJoin(config.shellConfigBase, name));
96
+ }
97
+
89
98
  export function sandboxLabel(config: Pick<SandboxPathConfig, 'project'>): string {
90
99
  return `${config.project}.sandbox`;
91
100
  }