@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
|
@@ -8,17 +8,18 @@ import * as p from '@clack/prompts';
|
|
|
8
8
|
import pc from 'picocolors';
|
|
9
9
|
import * as toml from 'smol-toml';
|
|
10
10
|
import { loadConfig } from "../config.js";
|
|
11
|
-
import { assertValidBranchName, containerName, containerNameCandidates, parsePositiveIntegerOption, sandboxBranchLabel, sandboxImageConfigLabel, sandboxLabel,
|
|
11
|
+
import { assertValidBranchName, containerName, containerNameCandidates, parsePositiveIntegerOption, sandboxBranchLabel, sandboxImageConfigLabel, sandboxLabel, shareBranchDir, shareCommonDir, shellConfigDir, worktreeDirCandidates } from "../constants.js";
|
|
12
12
|
import { prepareDockerfile } from "../dockerfile.js";
|
|
13
13
|
import { detectEngine, ensureDocker } from "../engine.js";
|
|
14
14
|
import { commandForEngine, execEngine, run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine, runVerboseEngine } from "../shell.js";
|
|
15
15
|
import { resolveTaskBranch } from "../task-resolver.js";
|
|
16
16
|
import { resolveTools, toolConfigDirCandidates, toolNpmPackagesArg } from "../tools.js";
|
|
17
17
|
import { hostJoin, toEnginePath, volumeArg } from "../engines/wsl2-paths.js";
|
|
18
|
+
import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from "../clipboard/paths.js";
|
|
18
19
|
import { validateSelinuxDisableEnv } from "../engines/selinux.js";
|
|
19
20
|
import { resolveBuildUid } from "../engines/native.js";
|
|
20
21
|
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
21
|
-
import {
|
|
22
|
+
import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
|
|
22
23
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
23
24
|
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
24
25
|
const SANDBOX_ALIAS_BLOCK_END = '# <<< agent-infra managed aliases <<<';
|
|
@@ -70,7 +71,13 @@ function resolveToolDirs(config, tools, branch) {
|
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
73
|
export function hostShellConfigDir(home, project, branch) {
|
|
73
|
-
return hostJoin(home, '.agent-infra', 'config', project,
|
|
74
|
+
return shellConfigDir({ shellConfigBase: hostJoin(home, '.agent-infra', 'config', project) }, branch);
|
|
75
|
+
}
|
|
76
|
+
export function buildClipboardVolumeArgs(engine, home) {
|
|
77
|
+
return [
|
|
78
|
+
'-v',
|
|
79
|
+
volumeArg(engine, clipboardHostDir(home), CONTAINER_CLIPBOARD_MOUNT, ':ro')
|
|
80
|
+
];
|
|
74
81
|
}
|
|
75
82
|
function runtimeChecks(runtimes) {
|
|
76
83
|
const checks = [];
|
|
@@ -825,11 +832,13 @@ export async function create(args) {
|
|
|
825
832
|
assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
|
|
826
833
|
const tools = resolveTools(effectiveConfig);
|
|
827
834
|
const resolvedTools = resolveToolDirs(effectiveConfig, tools, branch);
|
|
828
|
-
//
|
|
829
|
-
// Claude Code credential
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
835
|
+
// Fatal credential states still fail before filesystem/docker side effects.
|
|
836
|
+
// A genuinely missing Claude Code credential only removes Claude Code's
|
|
837
|
+
// sandbox config and credential mounts for this create run.
|
|
838
|
+
const credentialOutcome = prepareClaudeCredentials(effectiveConfig.home, effectiveConfig.project, resolvedTools);
|
|
839
|
+
const effectiveResolvedTools = credentialOutcome.status === 'SKIPPED'
|
|
840
|
+
? resolvedTools.filter(({ tool }) => tool.id !== 'claude-code')
|
|
841
|
+
: resolvedTools;
|
|
833
842
|
const container = containerName(effectiveConfig, branch);
|
|
834
843
|
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
|
|
835
844
|
const shareCommon = shareCommonDir(effectiveConfig);
|
|
@@ -840,6 +849,11 @@ export async function create(args) {
|
|
|
840
849
|
const engine = detectEngine(effectiveConfig);
|
|
841
850
|
p.intro(pc.cyan('AI Sandbox'));
|
|
842
851
|
p.log.info(`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`);
|
|
852
|
+
if (credentialOutcome.status === 'SKIPPED') {
|
|
853
|
+
p.log.warn('Claude Code credentials not found on host - creating this sandbox WITHOUT Claude Code credentials.\n'
|
|
854
|
+
+ ' Claude Code is still installed in the image but will not be authenticated.\n'
|
|
855
|
+
+ ' To enable it: run "claude" once on the host to complete login, then re-run "ai sandbox create".');
|
|
856
|
+
}
|
|
843
857
|
try {
|
|
844
858
|
p.log.step('Checking container engine...');
|
|
845
859
|
await ensureDocker(effectiveConfig, (detail) => {
|
|
@@ -913,7 +927,7 @@ export async function create(args) {
|
|
|
913
927
|
{
|
|
914
928
|
title: 'Preparing tool state',
|
|
915
929
|
task: async () => {
|
|
916
|
-
for (const { tool, dir } of
|
|
930
|
+
for (const { tool, dir } of effectiveResolvedTools) {
|
|
917
931
|
fs.mkdirSync(dir, { recursive: true });
|
|
918
932
|
for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
|
|
919
933
|
const destination = path.join(dir, sandboxName);
|
|
@@ -946,7 +960,7 @@ export async function create(args) {
|
|
|
946
960
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
947
961
|
}
|
|
948
962
|
}
|
|
949
|
-
return `${
|
|
963
|
+
return `${effectiveResolvedTools.length} tool config directories ready`;
|
|
950
964
|
}
|
|
951
965
|
},
|
|
952
966
|
{
|
|
@@ -978,31 +992,32 @@ export async function create(args) {
|
|
|
978
992
|
const cachedGpg = needsGpg
|
|
979
993
|
? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
|
|
980
994
|
: null;
|
|
981
|
-
const envFile = buildContainerEnvFile(
|
|
995
|
+
const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
|
|
982
996
|
let hostShellConfig;
|
|
983
997
|
try {
|
|
984
|
-
const claudeCodeEntry =
|
|
998
|
+
const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
985
999
|
if (claudeCodeEntry) {
|
|
986
1000
|
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
987
1001
|
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
988
|
-
//
|
|
989
|
-
//
|
|
1002
|
+
// prepareClaudeCredentials wrote the shared credentials file
|
|
1003
|
+
// before this point. If credentials were missing, the
|
|
1004
|
+
// claude-code entry was removed from effectiveResolvedTools.
|
|
990
1005
|
}
|
|
991
|
-
const codexEntry =
|
|
1006
|
+
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
992
1007
|
if (codexEntry) {
|
|
993
1008
|
ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
|
|
994
1009
|
ensureCodexWorkspaceTrust(codexEntry.dir);
|
|
995
1010
|
}
|
|
996
|
-
const geminiEntry =
|
|
1011
|
+
const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
|
|
997
1012
|
if (geminiEntry) {
|
|
998
1013
|
ensureGeminiWorkspaceTrust(geminiEntry.dir);
|
|
999
1014
|
}
|
|
1000
|
-
const opencodeEntry =
|
|
1015
|
+
const opencodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'opencode');
|
|
1001
1016
|
if (opencodeEntry) {
|
|
1002
1017
|
// The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
|
|
1003
1018
|
ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
|
|
1004
1019
|
}
|
|
1005
|
-
const toolVolumes =
|
|
1020
|
+
const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => [
|
|
1006
1021
|
'-v',
|
|
1007
1022
|
volumeArg(engine, dir, tool.containerMount)
|
|
1008
1023
|
]);
|
|
@@ -1017,7 +1032,7 @@ export async function create(args) {
|
|
|
1017
1032
|
'-v',
|
|
1018
1033
|
volumeArg(engine, hostPath, containerPath, ':ro')
|
|
1019
1034
|
]);
|
|
1020
|
-
const liveMountVolumes =
|
|
1035
|
+
const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
|
|
1021
1036
|
.filter(({ hostPath }) => fs.existsSync(hostPath))
|
|
1022
1037
|
.flatMap(({ hostPath, containerSubpath }) => [
|
|
1023
1038
|
'-v',
|
|
@@ -1026,6 +1041,7 @@ export async function create(args) {
|
|
|
1026
1041
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
1027
1042
|
fs.mkdirSync(shareCommon, { recursive: true });
|
|
1028
1043
|
fs.mkdirSync(shareBranch, { recursive: true });
|
|
1044
|
+
fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
|
|
1029
1045
|
const dotfilesSnapshot = materializeDotfiles(effectiveConfig.dotfilesDir, dotfilesCacheDir(effectiveConfig.home, effectiveConfig.project));
|
|
1030
1046
|
const dotfilesMount = dotfilesSnapshot
|
|
1031
1047
|
? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
|
|
@@ -1049,6 +1065,7 @@ export async function create(args) {
|
|
|
1049
1065
|
volumeArg(engine, shareCommon, '/share/common'),
|
|
1050
1066
|
'-v',
|
|
1051
1067
|
volumeArg(engine, shareBranch, '/share/branch'),
|
|
1068
|
+
...buildClipboardVolumeArgs(engine, effectiveConfig.home),
|
|
1052
1069
|
'-v',
|
|
1053
1070
|
volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
|
|
1054
1071
|
'-v',
|
|
@@ -1105,7 +1122,7 @@ export async function create(args) {
|
|
|
1105
1122
|
: 'Host GPG keys unavailable; using stripped git config fallback...');
|
|
1106
1123
|
}
|
|
1107
1124
|
}
|
|
1108
|
-
for (const { tool } of
|
|
1125
|
+
for (const { tool } of effectiveResolvedTools) {
|
|
1109
1126
|
for (const command of tool.postSetupCmds ?? []) {
|
|
1110
1127
|
runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
|
|
1111
1128
|
}
|
|
@@ -1143,7 +1160,7 @@ export async function create(args) {
|
|
|
1143
1160
|
}
|
|
1144
1161
|
}
|
|
1145
1162
|
p.outro(pc.green('Sandbox ready'));
|
|
1146
|
-
const toolHints =
|
|
1163
|
+
const toolHints = effectiveResolvedTools.map(({ tool, dir }) => {
|
|
1147
1164
|
const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
|
|
1148
1165
|
const hint = hasLiveMount
|
|
1149
1166
|
? 'Live-mounted auth/config files stay in sync with the host.'
|
|
@@ -5,6 +5,7 @@ import { formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials,
|
|
|
5
5
|
import { runInteractiveEngine, runSafeEngine } from "../shell.js";
|
|
6
6
|
import { resolveTaskBranch } from "../task-resolver.js";
|
|
7
7
|
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
8
|
+
import { runInteractiveWithClipboardBridge } from "../clipboard/bridge.js";
|
|
8
9
|
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
9
10
|
const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
|
|
10
11
|
// Terminal-detection variables that interactive TUIs (e.g. claude-code)
|
|
@@ -51,7 +52,7 @@ export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY)
|
|
|
51
52
|
}
|
|
52
53
|
return null;
|
|
53
54
|
}
|
|
54
|
-
export function enter(args) {
|
|
55
|
+
export async function enter(args) {
|
|
55
56
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
56
57
|
process.stdout.write(`${USAGE}\n`);
|
|
57
58
|
if (args.length === 0) {
|
|
@@ -91,7 +92,12 @@ export function enter(args) {
|
|
|
91
92
|
catch (error) {
|
|
92
93
|
process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
93
94
|
}
|
|
94
|
-
return
|
|
95
|
+
return runInteractiveWithClipboardBridge({
|
|
96
|
+
engine,
|
|
97
|
+
dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
|
|
98
|
+
container,
|
|
99
|
+
home: config.home
|
|
100
|
+
});
|
|
95
101
|
}
|
|
96
102
|
return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);
|
|
97
103
|
}
|
|
@@ -8,7 +8,7 @@ import { detectEngine } from "../engine.js";
|
|
|
8
8
|
import { runSafeEngine } from "../shell.js";
|
|
9
9
|
import { resolveTools, toolProjectDirCandidates } from "../tools.js";
|
|
10
10
|
const USAGE = 'Usage: ai sandbox ls';
|
|
11
|
-
const
|
|
11
|
+
const CONTAINER_TABLE_HEADERS = ['NAMES', 'STATUS', 'BRANCH'];
|
|
12
12
|
// Exported to lock the docker/podman-compatible format in unit tests.
|
|
13
13
|
export function containerListFormat() {
|
|
14
14
|
return '{{.Names}}\t{{.Status}}\t{{.Labels}}';
|
|
@@ -30,6 +30,19 @@ export function parseLabels(csv) {
|
|
|
30
30
|
}
|
|
31
31
|
return labels;
|
|
32
32
|
}
|
|
33
|
+
export function formatContainerTable(rows) {
|
|
34
|
+
const columns = rows.map((row) => [row.name, row.status, row.branch]);
|
|
35
|
+
const widths = [
|
|
36
|
+
Math.max(CONTAINER_TABLE_HEADERS[0].length, ...rows.map((row) => row.name.length)),
|
|
37
|
+
Math.max(CONTAINER_TABLE_HEADERS[1].length, ...rows.map((row) => row.status.length)),
|
|
38
|
+
Math.max(CONTAINER_TABLE_HEADERS[2].length, ...rows.map((row) => row.branch.length))
|
|
39
|
+
];
|
|
40
|
+
const renderRow = (values) => `${values[0].padEnd(widths[0])} ${values[1].padEnd(widths[1])} ${values[2]}`.trimEnd();
|
|
41
|
+
return [
|
|
42
|
+
renderRow(CONTAINER_TABLE_HEADERS),
|
|
43
|
+
...columns.map((column) => renderRow(column))
|
|
44
|
+
];
|
|
45
|
+
}
|
|
33
46
|
function listChildren(dir) {
|
|
34
47
|
if (!fs.existsSync(dir)) {
|
|
35
48
|
return [];
|
|
@@ -60,11 +73,13 @@ export function ls(args = []) {
|
|
|
60
73
|
}
|
|
61
74
|
else {
|
|
62
75
|
const branchKey = sandboxBranchLabel(config);
|
|
63
|
-
|
|
64
|
-
for (const line of containers.split('\n')) {
|
|
76
|
+
const rows = containers.split('\n').map((line) => {
|
|
65
77
|
const [name = '', status = '', labelsCsv = ''] = line.split('\t');
|
|
66
78
|
const branch = parseLabels(labelsCsv)[branchKey] ?? '';
|
|
67
|
-
|
|
79
|
+
return { name, status, branch };
|
|
80
|
+
});
|
|
81
|
+
for (const line of formatContainerTable(rows)) {
|
|
82
|
+
process.stdout.write(` ${line}\n`);
|
|
68
83
|
}
|
|
69
84
|
}
|
|
70
85
|
p.log.step('Worktrees');
|
|
@@ -0,0 +1,176 @@
|
|
|
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.js";
|
|
7
|
+
import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
|
|
8
|
+
import { detectEngine } from "../engine.js";
|
|
9
|
+
import { hostJoin } from "../engines/wsl2-paths.js";
|
|
10
|
+
import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
|
|
11
|
+
import { parseLabels } from "./ls.js";
|
|
12
|
+
import { runEngine, runSafe } from "../shell.js";
|
|
13
|
+
import { resolveTools } from "../tools.js";
|
|
14
|
+
const USAGE = `Usage: ai sandbox prune [--dry-run]`;
|
|
15
|
+
function listChildDirs(base) {
|
|
16
|
+
if (!fs.existsSync(base)) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return fs.readdirSync(base)
|
|
20
|
+
.sort()
|
|
21
|
+
.map((entry) => path.join(base, entry))
|
|
22
|
+
.filter((entry) => {
|
|
23
|
+
try {
|
|
24
|
+
return fs.statSync(entry).isDirectory();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function activeSafeNames(activeBranches) {
|
|
32
|
+
const names = new Set();
|
|
33
|
+
for (const branch of activeBranches) {
|
|
34
|
+
try {
|
|
35
|
+
for (const name of safeNameCandidates(branch)) {
|
|
36
|
+
names.add(name);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
names.add(branch);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
function orphanDirs(base, activeNames) {
|
|
46
|
+
return listChildDirs(base).filter((dir) => !activeNames.has(path.basename(dir)));
|
|
47
|
+
}
|
|
48
|
+
function addGroup(groups, group) {
|
|
49
|
+
if (group.dirs.length > 0) {
|
|
50
|
+
groups.push(group);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function collectOrphanGroups(config, tools, activeBranches) {
|
|
54
|
+
const activeNames = activeSafeNames(activeBranches);
|
|
55
|
+
const groups = [];
|
|
56
|
+
const shareBranchesBase = hostJoin(config.shareBase, 'branches');
|
|
57
|
+
addGroup(groups, {
|
|
58
|
+
kind: 'shell',
|
|
59
|
+
label: 'Shell config dirs',
|
|
60
|
+
base: config.shellConfigBase,
|
|
61
|
+
dirs: orphanDirs(config.shellConfigBase, activeNames)
|
|
62
|
+
});
|
|
63
|
+
addGroup(groups, {
|
|
64
|
+
kind: 'worktree',
|
|
65
|
+
label: 'Worktrees',
|
|
66
|
+
base: config.worktreeBase,
|
|
67
|
+
dirs: orphanDirs(config.worktreeBase, activeNames)
|
|
68
|
+
});
|
|
69
|
+
addGroup(groups, {
|
|
70
|
+
kind: 'share',
|
|
71
|
+
label: 'Share branch dirs',
|
|
72
|
+
base: shareBranchesBase,
|
|
73
|
+
dirs: orphanDirs(shareBranchesBase, activeNames)
|
|
74
|
+
});
|
|
75
|
+
for (const tool of tools) {
|
|
76
|
+
const base = hostJoin(tool.sandboxBase, config.project);
|
|
77
|
+
addGroup(groups, {
|
|
78
|
+
kind: 'tool',
|
|
79
|
+
label: `${tool.name} state`,
|
|
80
|
+
base,
|
|
81
|
+
dirs: orphanDirs(base, activeNames)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return groups;
|
|
85
|
+
}
|
|
86
|
+
export function removeOrphanGroups(config, groups) {
|
|
87
|
+
let removedWorktrees = false;
|
|
88
|
+
for (const group of groups) {
|
|
89
|
+
for (const dir of group.dirs) {
|
|
90
|
+
if (group.kind === 'worktree') {
|
|
91
|
+
removeWorktreeDir(config.repoRoot, group.base, dir);
|
|
92
|
+
removedWorktrees = true;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
removeManagedDir(group.base, dir);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return removedWorktrees;
|
|
100
|
+
}
|
|
101
|
+
function activeBranchesFromLabels(config, labelsOutput) {
|
|
102
|
+
const branchKey = sandboxBranchLabel(config);
|
|
103
|
+
return labelsOutput.split('\n')
|
|
104
|
+
.map((line) => parseLabels(line)[branchKey] ?? '')
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
function orphanCount(groups) {
|
|
108
|
+
return groups.reduce((sum, group) => sum + group.dirs.length, 0);
|
|
109
|
+
}
|
|
110
|
+
function writeGroups(groups) {
|
|
111
|
+
for (const group of groups) {
|
|
112
|
+
p.log.step(group.label);
|
|
113
|
+
for (const dir of group.dirs) {
|
|
114
|
+
process.stdout.write(` ${dir}\n`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function prune(args) {
|
|
119
|
+
const { values } = parseArgs({
|
|
120
|
+
args,
|
|
121
|
+
strict: true,
|
|
122
|
+
options: {
|
|
123
|
+
'dry-run': { type: 'boolean' },
|
|
124
|
+
help: { type: 'boolean', short: 'h' }
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
if (values.help) {
|
|
128
|
+
process.stdout.write(`${USAGE}\n`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const config = loadConfig();
|
|
132
|
+
const tools = resolveTools(config);
|
|
133
|
+
const engine = detectEngine(config);
|
|
134
|
+
const psArgs = [
|
|
135
|
+
'ps',
|
|
136
|
+
'-a',
|
|
137
|
+
'--filter',
|
|
138
|
+
`label=${sandboxLabel(config)}`,
|
|
139
|
+
'--format',
|
|
140
|
+
'{{.Labels}}'
|
|
141
|
+
];
|
|
142
|
+
let labelsOutput;
|
|
143
|
+
try {
|
|
144
|
+
labelsOutput = runEngine(engine, 'docker', psArgs);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new Error('Unable to determine active sandbox branches: docker ps failed');
|
|
148
|
+
}
|
|
149
|
+
const groups = collectOrphanGroups(config, tools, activeBranchesFromLabels(config, labelsOutput));
|
|
150
|
+
const count = orphanCount(groups);
|
|
151
|
+
p.intro(pc.cyan(`Pruning orphaned sandbox state for ${config.project}`));
|
|
152
|
+
if (count === 0) {
|
|
153
|
+
p.log.success('No orphaned sandbox state dirs found');
|
|
154
|
+
p.outro(pc.green('Sandbox prune complete'));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
writeGroups(groups);
|
|
158
|
+
if (values['dry-run']) {
|
|
159
|
+
p.outro(pc.green('Dry run complete'));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const shouldRemove = await p.confirm({
|
|
163
|
+
message: `Remove ${count} orphaned sandbox state dirs?`,
|
|
164
|
+
initialValue: true
|
|
165
|
+
});
|
|
166
|
+
if (p.isCancel(shouldRemove) || !shouldRemove) {
|
|
167
|
+
p.outro('Cancelled');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const removedWorktrees = removeOrphanGroups(config, groups);
|
|
171
|
+
if (removedWorktrees) {
|
|
172
|
+
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
173
|
+
}
|
|
174
|
+
p.outro(pc.green('Orphaned sandbox state dirs removed'));
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=prune.js.map
|
|
@@ -4,24 +4,17 @@ import { parseArgs } from 'node:util';
|
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
5
|
import pc from 'picocolors';
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
|
-
import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, worktreeDirCandidates } from "../constants.js";
|
|
7
|
+
import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from "../constants.js";
|
|
8
8
|
import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from "../engine.js";
|
|
9
|
-
import {
|
|
9
|
+
import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
|
|
10
|
+
import { runOk, runSafe, runSafeEngine } from "../shell.js";
|
|
10
11
|
import { resolveTaskBranch } from "../task-resolver.js";
|
|
11
12
|
import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from "../tools.js";
|
|
12
13
|
const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
|
|
14
|
+
export { assertManagedPath } from "../managed-fs.js";
|
|
13
15
|
function projectToolDirs(config, tools) {
|
|
14
16
|
return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
|
|
15
17
|
}
|
|
16
|
-
export function assertManagedPath(root, target) {
|
|
17
|
-
const resolvedRoot = path.resolve(root);
|
|
18
|
-
const resolvedTarget = path.resolve(target);
|
|
19
|
-
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
20
|
-
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
|
|
24
|
-
}
|
|
25
18
|
async function rmOne(config, tools, branch) {
|
|
26
19
|
assertValidBranchName(branch);
|
|
27
20
|
const engine = detectEngine(config);
|
|
@@ -73,13 +66,7 @@ async function rmOne(config, tools, branch) {
|
|
|
73
66
|
}
|
|
74
67
|
if (shouldRemoveWorktree) {
|
|
75
68
|
for (const worktree of existingWorktrees) {
|
|
76
|
-
|
|
77
|
-
run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
assertManagedPath(config.worktreeBase, worktree);
|
|
81
|
-
fs.rmSync(worktree, { recursive: true, force: true });
|
|
82
|
-
}
|
|
69
|
+
removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree);
|
|
83
70
|
}
|
|
84
71
|
const shouldDeleteBranch = await p.confirm({
|
|
85
72
|
message: `Also delete local branch '${effectiveBranch}'?`,
|
|
@@ -94,11 +81,14 @@ async function rmOne(config, tools, branch) {
|
|
|
94
81
|
}
|
|
95
82
|
for (const { tool, candidates } of toolCandidates) {
|
|
96
83
|
for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
|
|
97
|
-
|
|
98
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
removeManagedDir(tool.sandboxBase, dir);
|
|
99
85
|
p.log.success(`${tool.name} state removed: ${dir}`);
|
|
100
86
|
}
|
|
101
87
|
}
|
|
88
|
+
for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => fs.existsSync(candidate))) {
|
|
89
|
+
removeManagedDir(config.shellConfigBase, dir);
|
|
90
|
+
p.log.success(`Shell config removed: ${dir}`);
|
|
91
|
+
}
|
|
102
92
|
const shareBranch = shareBranchDir(config, effectiveBranch);
|
|
103
93
|
if (fs.existsSync(shareBranch)) {
|
|
104
94
|
const shouldRemoveShare = await p.confirm({
|
|
@@ -106,8 +96,7 @@ async function rmOne(config, tools, branch) {
|
|
|
106
96
|
initialValue: true
|
|
107
97
|
});
|
|
108
98
|
if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
|
|
109
|
-
|
|
110
|
-
fs.rmSync(shareBranch, { recursive: true, force: true });
|
|
99
|
+
removeManagedDir(config.shareBase, shareBranch);
|
|
111
100
|
p.log.success(`Share dir removed: ${shareBranch}`);
|
|
112
101
|
}
|
|
113
102
|
}
|
|
@@ -144,32 +133,37 @@ async function rmAll(config, tools) {
|
|
|
144
133
|
if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
|
|
145
134
|
for (const entry of fs.readdirSync(config.worktreeBase)) {
|
|
146
135
|
const dir = path.join(config.worktreeBase, entry);
|
|
147
|
-
|
|
148
|
-
run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
assertManagedPath(config.worktreeBase, dir);
|
|
152
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
153
|
-
}
|
|
136
|
+
removeWorktreeDir(config.repoRoot, config.worktreeBase, dir);
|
|
154
137
|
}
|
|
155
138
|
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
156
139
|
}
|
|
157
140
|
}
|
|
158
141
|
for (const dir of projectToolDirs(config, tools)) {
|
|
159
142
|
if (fs.existsSync(dir)) {
|
|
160
|
-
|
|
161
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
143
|
+
removeManagedDir(path.dirname(dir), dir);
|
|
162
144
|
p.log.success(`Removed tool state: ${dir}`);
|
|
163
145
|
}
|
|
164
146
|
}
|
|
147
|
+
if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
|
|
148
|
+
const shouldRemoveShellConfigs = await p.confirm({
|
|
149
|
+
message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
|
|
150
|
+
initialValue: true
|
|
151
|
+
});
|
|
152
|
+
if (!p.isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
|
|
153
|
+
for (const entry of fs.readdirSync(config.shellConfigBase)) {
|
|
154
|
+
const dir = path.join(config.shellConfigBase, entry);
|
|
155
|
+
removeManagedDir(config.shellConfigBase, dir);
|
|
156
|
+
}
|
|
157
|
+
p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
165
160
|
if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
|
|
166
161
|
const shouldRemoveAllShares = await p.confirm({
|
|
167
162
|
message: `Remove all share dirs for project (${config.shareBase})?`,
|
|
168
163
|
initialValue: true
|
|
169
164
|
});
|
|
170
165
|
if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
|
|
171
|
-
|
|
172
|
-
fs.rmSync(config.shareBase, { recursive: true, force: true });
|
|
166
|
+
removeManagedDir(path.dirname(config.shareBase), config.shareBase);
|
|
173
167
|
p.log.success(`Project share dirs removed: ${config.shareBase}`);
|
|
174
168
|
}
|
|
175
169
|
}
|
|
@@ -86,6 +86,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
|
|
|
86
86
|
imageName: `${project}-sandbox:latest`,
|
|
87
87
|
worktreeBase: hostJoin(home, '.agent-infra', 'worktrees', project),
|
|
88
88
|
shareBase: hostJoin(home, '.agent-infra', 'share', project),
|
|
89
|
+
shellConfigBase: hostJoin(home, '.agent-infra', 'config', project),
|
|
89
90
|
dotfilesDir: hostJoin(home, '.agent-infra', 'dotfiles'),
|
|
90
91
|
engine,
|
|
91
92
|
runtimes,
|
|
@@ -57,6 +57,12 @@ export function shareCommonDir(config) {
|
|
|
57
57
|
export function shareBranchDir(config, branch) {
|
|
58
58
|
return hostJoin(config.shareBase, 'branches', sanitizeBranchName(branch));
|
|
59
59
|
}
|
|
60
|
+
export function shellConfigDir(config, branch) {
|
|
61
|
+
return hostJoin(config.shellConfigBase, sanitizeBranchName(branch));
|
|
62
|
+
}
|
|
63
|
+
export function shellConfigDirCandidates(config, branch) {
|
|
64
|
+
return safeNameCandidates(branch).map((name) => hostJoin(config.shellConfigBase, name));
|
|
65
|
+
}
|
|
60
66
|
export function sandboxLabel(config) {
|
|
61
67
|
return `${config.project}.sandbox`;
|
|
62
68
|
}
|
|
@@ -395,43 +395,62 @@ export function formatRemaining(expiresAt) {
|
|
|
395
395
|
const minutes = totalMinutes % 60;
|
|
396
396
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
397
397
|
}
|
|
398
|
-
export function
|
|
398
|
+
export function prepareClaudeCredentials(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
|
|
399
399
|
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
400
400
|
if (!claudeCodeEntry) {
|
|
401
|
-
return;
|
|
401
|
+
return { status: 'NOT_APPLICABLE' };
|
|
402
402
|
}
|
|
403
403
|
let blob = null;
|
|
404
404
|
const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
|
|
405
405
|
const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
|
|
406
406
|
if (hasCustomInspectFn || !hasCustomExtractFn) {
|
|
407
407
|
const inspection = inspectFn(home);
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
408
|
+
switch (inspection.status) {
|
|
409
|
+
case 'OK':
|
|
410
|
+
blob = inspection.blob;
|
|
411
|
+
break;
|
|
412
|
+
case 'MISSING':
|
|
413
|
+
return { status: 'SKIPPED' };
|
|
414
|
+
case 'KEYCHAIN_LOCKED':
|
|
415
|
+
throw new Error([
|
|
416
|
+
'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
|
|
417
|
+
'',
|
|
418
|
+
buildLockedGuidance()
|
|
419
|
+
].join('\n'));
|
|
420
|
+
case 'STALE_ACCESS':
|
|
421
|
+
throw new Error([
|
|
422
|
+
'Claude Code credentials on host are invalid or expired.',
|
|
423
|
+
'',
|
|
424
|
+
'The sandbox needs valid Claude Code OAuth credentials so the container can use Claude Code.',
|
|
425
|
+
'',
|
|
426
|
+
'To fix:',
|
|
427
|
+
' 1. On the host, run "claude" once and complete the OAuth login flow.',
|
|
428
|
+
' 2. Verify with "claude /status" that you see your subscription.',
|
|
429
|
+
' 3. Re-run "ai sandbox create".'
|
|
430
|
+
].join('\n'));
|
|
431
|
+
case 'KEYCHAIN_ERROR':
|
|
432
|
+
throw new Error([
|
|
433
|
+
'Claude Code credentials could not be read from the host keychain.',
|
|
434
|
+
'',
|
|
435
|
+
inspection.detail ? `Detail: ${inspection.detail}` : 'Detail: unknown keychain error',
|
|
436
|
+
'',
|
|
437
|
+
'To fix:',
|
|
438
|
+
' 1. Unlock or repair the host keychain, then re-run "ai sandbox create".',
|
|
439
|
+
' 2. For SSH / CI, set AGENT_INFRA_CLAUDE_CREDENTIALS_FILE to an absolute path containing a valid credentials blob.'
|
|
440
|
+
].join('\n'));
|
|
441
|
+
default: {
|
|
442
|
+
const _exhaustive = inspection;
|
|
443
|
+
throw new Error(`Unhandled Claude Code credential inspection status: ${_exhaustive.status}`);
|
|
444
|
+
}
|
|
414
445
|
}
|
|
415
|
-
blob = inspection.status === 'OK' ? inspection.blob : null;
|
|
416
446
|
}
|
|
417
447
|
else {
|
|
418
448
|
blob = extractFn(home);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
'Claude Code credentials not found on host.',
|
|
423
|
-
'',
|
|
424
|
-
'The sandbox needs your Claude Code OAuth credentials so the container can use Claude Code.',
|
|
425
|
-
'',
|
|
426
|
-
'To fix:',
|
|
427
|
-
' 1. On the host, run "claude" once and complete the OAuth login flow.',
|
|
428
|
-
' 2. Verify with "claude /status" that you see your subscription.',
|
|
429
|
-
' 3. Re-run "ai sandbox create".',
|
|
430
|
-
'',
|
|
431
|
-
'Alternatively, if you do not need Claude Code in this sandbox,',
|
|
432
|
-
'remove "claude-code" from the "sandbox.tools" array in .agents/.airc.json.'
|
|
433
|
-
].join('\n'));
|
|
449
|
+
if (!blob) {
|
|
450
|
+
return { status: 'SKIPPED' };
|
|
451
|
+
}
|
|
434
452
|
}
|
|
435
453
|
writeFn(home, project, blob);
|
|
454
|
+
return { status: 'OK' };
|
|
436
455
|
}
|
|
437
456
|
//# sourceMappingURL=credentials.js.map
|