@fitlab-ai/agent-infra 0.6.2 → 0.6.4
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 +218 -0
- package/dist/lib/sandbox/clipboard/darwin.js +66 -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 +15 -2
- package/dist/lib/sandbox/commands/enter.js +14 -3
- 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/host-timezone.js +33 -0
- package/dist/lib/sandbox/index.js +7 -1
- package/dist/lib/sandbox/managed-fs.js +25 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +21 -16
- package/dist/lib/sandbox/tools.js +1 -1
- package/dist/lib/version.js +9 -2
- package/lib/sandbox/clipboard/bridge.ts +286 -0
- package/lib/sandbox/clipboard/darwin.ts +91 -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 +19 -2
- package/lib/sandbox/commands/enter.ts +15 -3
- 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/host-timezone.ts +42 -0
- package/lib/sandbox/index.ts +7 -1
- package/lib/sandbox/managed-fs.ts +27 -0
- package/lib/sandbox/runtimes/base.dockerfile +21 -16
- package/lib/sandbox/tools.ts +1 -1
- package/lib/version.ts +11 -4
- package/package.json +10 -6
- 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 +19 -1
- package/templates/.agents/rules/create-issue.github.zh-CN.md +19 -1
- package/templates/.agents/rules/milestone-inference.github.en.md +12 -0
- package/templates/.agents/rules/milestone-inference.github.zh-CN.md +12 -0
- package/templates/.agents/rules/testing-discipline.en.md +44 -0
- package/templates/.agents/rules/testing-discipline.zh-CN.md +44 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +26 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +26 -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 +15 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +15 -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-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/create-task/config/verify.json +1 -0
- 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/import-issue/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/plan-task/SKILL.en.md +22 -0
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +22 -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
|
@@ -11,6 +11,8 @@ 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';
|
|
15
|
+
import { detectHostTimezone } from '../host-timezone.ts';
|
|
14
16
|
|
|
15
17
|
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
16
18
|
const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
|
|
@@ -38,6 +40,11 @@ export function terminalEnvFlags(env: NodeJS.ProcessEnv = process.env): string[]
|
|
|
38
40
|
return flags;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
export function hostTimezoneEnvFlags(detect = detectHostTimezone): string[] {
|
|
44
|
+
const tz = detect();
|
|
45
|
+
return tz ? ['-e', `TZ=${tz}`] : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
export function formatCredentialSyncStatus(
|
|
42
49
|
result: ReturnType<typeof reconcileClaudeCredentials>,
|
|
43
50
|
isTTY = process.stderr.isTTY
|
|
@@ -65,7 +72,7 @@ export function formatCredentialSyncStatus(
|
|
|
65
72
|
return null;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
export function enter(args: string[]): number {
|
|
75
|
+
export async function enter(args: string[]): Promise<number> {
|
|
69
76
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
70
77
|
process.stdout.write(`${USAGE}\n`);
|
|
71
78
|
if (args.length === 0) {
|
|
@@ -100,7 +107,7 @@ export function enter(args: string[]): number {
|
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
const envFlags = terminalEnvFlags();
|
|
110
|
+
const envFlags = [...terminalEnvFlags(), ...hostTimezoneEnvFlags()];
|
|
104
111
|
if (cmd.length === 0) {
|
|
105
112
|
try {
|
|
106
113
|
materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
|
|
@@ -108,7 +115,12 @@ export function enter(args: string[]): number {
|
|
|
108
115
|
process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
return
|
|
118
|
+
return runInteractiveWithClipboardBridge({
|
|
119
|
+
engine,
|
|
120
|
+
dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
|
|
121
|
+
container,
|
|
122
|
+
home: config.home
|
|
123
|
+
});
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
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
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
export type DetectHostTimezoneOptions = {
|
|
5
|
+
platform?: NodeJS.Platform;
|
|
6
|
+
readlink?: (targetPath: string) => string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ZONEINFO_MARK = '/zoneinfo/';
|
|
11
|
+
const IANA_ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
|
12
|
+
|
|
13
|
+
function safeTimezone(value: string | undefined): string | null {
|
|
14
|
+
if (!value || !IANA_ZONE_RE.test(value)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function detectHostTimezone(options: DetectHostTimezoneOptions = {}): string | null {
|
|
21
|
+
const platform = options.platform ?? os.platform();
|
|
22
|
+
const env = options.env ?? process.env;
|
|
23
|
+
if (env.TZ) {
|
|
24
|
+
return safeTimezone(env.TZ);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const readlink = options.readlink ?? fs.readlinkSync;
|
|
32
|
+
try {
|
|
33
|
+
const target = readlink('/etc/localtime');
|
|
34
|
+
const idx = target.indexOf(ZONEINFO_MARK);
|
|
35
|
+
if (idx < 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return safeTimezone(target.slice(idx + ZONEINFO_MARK.length));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/lib/sandbox/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ Commands:
|
|
|
6
6
|
refresh Sync host Claude Code credentials to all sandbox copies
|
|
7
7
|
ls List sandboxes for the current project
|
|
8
8
|
rm <branch> [--all] Remove a sandbox or all sandboxes
|
|
9
|
+
prune [--dry-run] Remove orphaned per-branch state dirs
|
|
9
10
|
vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
|
|
10
11
|
rebuild [--quiet] Rebuild the sandbox image
|
|
11
12
|
|
|
@@ -33,7 +34,7 @@ export async function runSandbox(args: string[]): Promise<void> {
|
|
|
33
34
|
}
|
|
34
35
|
case 'exec': {
|
|
35
36
|
const { enter } = await import('./commands/enter.ts');
|
|
36
|
-
const exitCode = enter(rest);
|
|
37
|
+
const exitCode = await enter(rest);
|
|
37
38
|
if (typeof exitCode === 'number' && exitCode !== 0) {
|
|
38
39
|
process.exitCode = exitCode;
|
|
39
40
|
}
|
|
@@ -57,6 +58,11 @@ export async function runSandbox(args: string[]): Promise<void> {
|
|
|
57
58
|
await rm(rest);
|
|
58
59
|
break;
|
|
59
60
|
}
|
|
61
|
+
case 'prune': {
|
|
62
|
+
const { prune } = await import('./commands/prune.ts');
|
|
63
|
+
await prune(rest);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
60
66
|
case 'vm': {
|
|
61
67
|
const { vm } = await import('./commands/vm.ts');
|
|
62
68
|
await vm(rest);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { run } from './shell.ts';
|
|
4
|
+
|
|
5
|
+
export function assertManagedPath(root: string, target: string): void {
|
|
6
|
+
const resolvedRoot = path.resolve(root);
|
|
7
|
+
const resolvedTarget = path.resolve(target);
|
|
8
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
9
|
+
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function removeManagedDir(root: string, dir: string): void {
|
|
17
|
+
assertManagedPath(root, dir);
|
|
18
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function removeWorktreeDir(repoRoot: string, worktreeBase: string, dir: string): void {
|
|
22
|
+
try {
|
|
23
|
+
run('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
|
|
24
|
+
} catch {
|
|
25
|
+
removeManagedDir(worktreeBase, dir);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -3,7 +3,6 @@ FROM ubuntu:22.04
|
|
|
3
3
|
LABEL description="AI coding sandbox"
|
|
4
4
|
|
|
5
5
|
ENV DEBIAN_FRONTEND=noninteractive
|
|
6
|
-
ENV TZ=Asia/Shanghai
|
|
7
6
|
|
|
8
7
|
ARG HOST_UID=1000
|
|
9
8
|
ARG HOST_GID=1000
|
|
@@ -22,7 +21,7 @@ RUN apt-get update && apt-get install -y \
|
|
|
22
21
|
build-essential ca-certificates gnupg lsb-release \
|
|
23
22
|
libevent-core-2.1-7 libncursesw6 libtinfo6 \
|
|
24
23
|
pkg-config bison libevent-dev libncurses-dev \
|
|
25
|
-
locales \
|
|
24
|
+
locales tzdata \
|
|
26
25
|
&& locale-gen en_US.UTF-8 \
|
|
27
26
|
&& (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
28
27
|
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
|
|
@@ -45,13 +44,13 @@ RUN apt-get update && apt-get install -y \
|
|
|
45
44
|
&& rm -rf /var/lib/apt/lists/*
|
|
46
45
|
|
|
47
46
|
# Enable extended keys in CSI u format so Shift+Enter and other modified
|
|
48
|
-
# keys are forwarded through tmux. Preserve terminal
|
|
47
|
+
# keys are forwarded through tmux. Preserve terminal/timezone variables
|
|
49
48
|
# injected at `docker exec` time when new tmux sessions are created.
|
|
50
49
|
RUN printf '%s\n' \
|
|
51
50
|
'set -g extended-keys always' \
|
|
52
51
|
'set -g extended-keys-format csi-u' \
|
|
53
52
|
"set -as terminal-features 'xterm*:extkeys'" \
|
|
54
|
-
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
|
|
53
|
+
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION TZ'" \
|
|
55
54
|
'set -g mouse on' \
|
|
56
55
|
'set -g status-interval 1' \
|
|
57
56
|
'set -g status-right-length 80' \
|
|
@@ -146,7 +145,7 @@ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bi
|
|
|
146
145
|
#!/bin/sh
|
|
147
146
|
set -eu
|
|
148
147
|
|
|
149
|
-
sandbox-dotfiles-link >/dev/null || true
|
|
148
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
150
149
|
|
|
151
150
|
SESSION=work
|
|
152
151
|
|
|
@@ -154,20 +153,26 @@ if ! command -v tmux >/dev/null 2>&1; then
|
|
|
154
153
|
exec bash
|
|
155
154
|
fi
|
|
156
155
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
|
|
162
|
-
while read -r name attached; do
|
|
163
|
-
[ "$name" = "$SESSION" ] && continue
|
|
156
|
+
# Drop stale grouped sessions left by older entry-script versions (the windows
|
|
157
|
+
# live on $SESSION, so killing the group members only removes view entries).
|
|
158
|
+
tmux list-sessions -F '#{session_name}' 2>/dev/null | while IFS= read -r name; do
|
|
164
159
|
case "$name" in
|
|
165
|
-
|
|
160
|
+
"$SESSION"-*) tmux kill-session -t "$name" 2>/dev/null || true ;;
|
|
166
161
|
esac
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
done
|
|
163
|
+
|
|
164
|
+
# Reuse the single $SESSION; -d detaches any pre-existing client so the new
|
|
165
|
+
# one becomes the sole owner of window-size, eliminating size races.
|
|
166
|
+
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
167
|
+
# Push the per-exec TZ into the running session's env so new
|
|
168
|
+
# windows/panes pick up the host timezone without a session kill.
|
|
169
|
+
if [ -n "${TZ:-}" ]; then
|
|
170
|
+
tmux set-environment -t "$SESSION" TZ "$TZ" 2>/dev/null || true
|
|
171
|
+
fi
|
|
172
|
+
exec tmux attach -d -t "$SESSION"
|
|
173
|
+
fi
|
|
169
174
|
|
|
170
|
-
exec tmux new-session -
|
|
175
|
+
exec tmux new-session -s "$SESSION"
|
|
171
176
|
SCRIPT
|
|
172
177
|
|
|
173
178
|
ENV LANG=en_US.UTF-8
|
package/lib/sandbox/tools.ts
CHANGED
|
@@ -28,7 +28,7 @@ function createBuiltinTools(home: string, project: string): Record<string, Sandb
|
|
|
28
28
|
'claude-code': {
|
|
29
29
|
id: 'claude-code',
|
|
30
30
|
name: 'Claude Code',
|
|
31
|
-
npmPackage: '@anthropic-ai/claude-code',
|
|
31
|
+
npmPackage: '@anthropic-ai/claude-code@stable',
|
|
32
32
|
sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'claude-code'),
|
|
33
33
|
containerMount: '/home/devuser/.claude',
|
|
34
34
|
versionCmd: 'claude --version',
|