@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.
- package/README.md +13 -3
- package/README.zh-CN.md +10 -3
- package/bin/cli.ts +6 -1
- package/dist/bin/cli.js +6 -1
- package/dist/lib/sandbox/clipboard/bridge.js +216 -0
- package/dist/lib/sandbox/clipboard/darwin.js +73 -0
- package/dist/lib/sandbox/clipboard/index.js +9 -0
- package/dist/lib/sandbox/clipboard/keys.js +58 -0
- package/dist/lib/sandbox/clipboard/node-pty.js +13 -0
- package/dist/lib/sandbox/clipboard/paths.js +59 -0
- package/dist/lib/sandbox/commands/create.js +38 -21
- package/dist/lib/sandbox/commands/enter.js +8 -2
- package/dist/lib/sandbox/commands/ls.js +19 -4
- package/dist/lib/sandbox/commands/prune.js +176 -0
- package/dist/lib/sandbox/commands/rm.js +27 -33
- package/dist/lib/sandbox/config.js +1 -0
- package/dist/lib/sandbox/constants.js +6 -0
- package/dist/lib/sandbox/credentials.js +43 -24
- package/dist/lib/sandbox/index.js +7 -1
- package/dist/lib/sandbox/managed-fs.js +25 -0
- package/dist/lib/sandbox/tools.js +1 -1
- package/dist/lib/version.js +9 -2
- package/lib/sandbox/clipboard/bridge.ts +285 -0
- package/lib/sandbox/clipboard/darwin.ts +90 -0
- package/lib/sandbox/clipboard/index.ts +13 -0
- package/lib/sandbox/clipboard/keys.ts +78 -0
- package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
- package/lib/sandbox/clipboard/node-pty.ts +34 -0
- package/lib/sandbox/clipboard/paths.ts +71 -0
- package/lib/sandbox/commands/create.ts +44 -21
- package/lib/sandbox/commands/enter.ts +8 -2
- package/lib/sandbox/commands/ls.ts +28 -4
- package/lib/sandbox/commands/prune.ts +211 -0
- package/lib/sandbox/commands/rm.ts +30 -32
- package/lib/sandbox/config.ts +2 -0
- package/lib/sandbox/constants.ts +9 -0
- package/lib/sandbox/credentials.ts +49 -26
- package/lib/sandbox/index.ts +7 -1
- package/lib/sandbox/managed-fs.ts +27 -0
- package/lib/sandbox/tools.ts +1 -1
- package/lib/version.ts +11 -4
- package/package.json +5 -1
- package/templates/.agents/README.en.md +19 -0
- package/templates/.agents/README.zh-CN.md +19 -0
- package/templates/.agents/rules/create-issue.github.en.md +3 -3
- package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
- package/templates/.agents/skills/analyze-task/SKILL.en.md +29 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +29 -0
- package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
- package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
- package/templates/.agents/skills/complete-task/SKILL.en.md +16 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +16 -0
- package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
- package/templates/.agents/skills/create-pr/config/verify.json +1 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
- package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
- package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
- package/templates/.agents/skills/plan-task/SKILL.en.md +24 -0
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +24 -0
- package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
- package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
- package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
- package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
- package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
- package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
- package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
- package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
- 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
|
-
|
|
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
|
|
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
|
-
//
|
|
1104
|
-
// Claude Code credential
|
|
1105
|
-
//
|
|
1106
|
-
|
|
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
|
|
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 `${
|
|
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(
|
|
1306
|
+
const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
|
|
1287
1307
|
let hostShellConfig: HostShellConfig;
|
|
1288
1308
|
try {
|
|
1289
|
-
const claudeCodeEntry =
|
|
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
|
-
//
|
|
1294
|
-
//
|
|
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 =
|
|
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 =
|
|
1322
|
+
const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
|
|
1302
1323
|
if (geminiEntry) {
|
|
1303
1324
|
ensureGeminiWorkspaceTrust(geminiEntry.dir);
|
|
1304
1325
|
}
|
|
1305
|
-
const opencodeEntry =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/sandbox/config.ts
CHANGED
|
@@ -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,
|
package/lib/sandbox/constants.ts
CHANGED
|
@@ -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
|
}
|