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