@fitlab-ai/agent-infra 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +18 -2
  2. package/README.zh-CN.md +18 -2
  3. package/bin/cli.js +19 -0
  4. package/lib/defaults.json +17 -0
  5. package/lib/init.js +1 -0
  6. package/lib/log.js +5 -10
  7. package/lib/merge.js +885 -0
  8. package/lib/sandbox/commands/create.js +1170 -0
  9. package/lib/sandbox/commands/enter.js +64 -0
  10. package/lib/sandbox/commands/ls.js +71 -0
  11. package/lib/sandbox/commands/rebuild.js +102 -0
  12. package/lib/sandbox/commands/rm.js +211 -0
  13. package/lib/sandbox/commands/vm.js +101 -0
  14. package/lib/sandbox/config.js +79 -0
  15. package/lib/sandbox/constants.js +113 -0
  16. package/lib/sandbox/dockerfile.js +95 -0
  17. package/lib/sandbox/engine.js +93 -0
  18. package/lib/sandbox/index.js +64 -0
  19. package/lib/sandbox/runtimes/ai-tools.dockerfile +26 -0
  20. package/lib/sandbox/runtimes/base.dockerfile +30 -0
  21. package/lib/sandbox/runtimes/java17.dockerfile +3 -0
  22. package/lib/sandbox/runtimes/java21.dockerfile +3 -0
  23. package/lib/sandbox/runtimes/node20.dockerfile +3 -0
  24. package/lib/sandbox/runtimes/node22.dockerfile +3 -0
  25. package/lib/sandbox/runtimes/python3.dockerfile +3 -0
  26. package/lib/sandbox/shell.js +48 -0
  27. package/lib/sandbox/task-resolver.js +35 -0
  28. package/lib/sandbox/tools.js +135 -0
  29. package/lib/update.js +16 -2
  30. package/package.json +5 -1
  31. package/templates/.agents/rules/pr-sync.md +110 -0
  32. package/templates/.agents/rules/pr-sync.zh-CN.md +110 -0
  33. package/templates/.agents/scripts/validate-artifact.js +117 -1
  34. package/templates/.agents/skills/archive-tasks/SKILL.md +6 -3
  35. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +6 -3
  36. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +91 -8
  37. package/templates/.agents/skills/commit/SKILL.md +9 -1
  38. package/templates/.agents/skills/commit/SKILL.zh-CN.md +9 -1
  39. package/templates/.agents/skills/commit/config/verify.json +5 -1
  40. package/templates/.agents/skills/commit/reference/pr-summary-sync.md +21 -0
  41. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +21 -0
  42. package/templates/.agents/skills/commit/reference/task-status-update.md +2 -0
  43. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -0
  44. package/templates/.agents/skills/create-pr/SKILL.md +2 -1
  45. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -1
  46. package/templates/.agents/skills/create-pr/reference/comment-publish.md +7 -74
  47. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +6 -73
  48. package/templates/.agents/skills/create-task/SKILL.md +6 -0
  49. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +6 -0
  50. package/templates/.agents/skills/create-task/config/verify.json +1 -0
  51. package/templates/.agents/skills/import-issue/SKILL.md +2 -0
  52. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +2 -0
  53. package/templates/.agents/skills/import-issue/config/verify.json +1 -0
  54. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -1
  55. package/templates/.agents/templates/task.md +5 -4
  56. package/templates/.agents/templates/task.zh-CN.md +5 -4
@@ -0,0 +1,113 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ const validatedBranches = new Set();
6
+
7
+ function dedupe(items) {
8
+ return [...new Set(items)];
9
+ }
10
+
11
+ export function assertValidBranchName(branch) {
12
+ if (validatedBranches.has(branch)) {
13
+ return;
14
+ }
15
+
16
+ if (!branch || branch.trim().length === 0) {
17
+ throw new Error('Branch name is required');
18
+ }
19
+
20
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch)) {
21
+ throw new Error(`Invalid branch name '${branch}': only letters, digits, ., _, -, and / are allowed`);
22
+ }
23
+
24
+ try {
25
+ execFileSync('git', ['check-ref-format', '--branch', branch], {
26
+ stdio: ['pipe', 'pipe', 'pipe']
27
+ });
28
+ } catch {
29
+ throw new Error(`Invalid branch name '${branch}': does not satisfy git branch naming rules`);
30
+ }
31
+
32
+ validatedBranches.add(branch);
33
+ }
34
+
35
+ export function sanitizeBranchName(branch) {
36
+ assertValidBranchName(branch);
37
+ return branch.replace(/\//g, '..');
38
+ }
39
+
40
+ export function legacySanitizeBranchName(branch) {
41
+ assertValidBranchName(branch);
42
+ return branch.replace(/\//g, '-');
43
+ }
44
+
45
+ export function safeNameCandidates(branch) {
46
+ return dedupe([sanitizeBranchName(branch), legacySanitizeBranchName(branch)]);
47
+ }
48
+
49
+ export function containerName(config, branch) {
50
+ return `${config.containerPrefix}-${sanitizeBranchName(branch)}`;
51
+ }
52
+
53
+ export function containerNameCandidates(config, branch) {
54
+ return safeNameCandidates(branch).map((name) => `${config.containerPrefix}-${name}`);
55
+ }
56
+
57
+ export function worktreeDir(config, branch) {
58
+ return path.join(config.worktreeBase, sanitizeBranchName(branch));
59
+ }
60
+
61
+ export function worktreeDirCandidates(config, branch) {
62
+ return safeNameCandidates(branch).map((name) => path.join(config.worktreeBase, name));
63
+ }
64
+
65
+ export function sandboxLabel(config) {
66
+ return `${config.project}.sandbox`;
67
+ }
68
+
69
+ export function sandboxBranchLabel(config) {
70
+ return `${sandboxLabel(config)}.branch`;
71
+ }
72
+
73
+ export function sandboxImageConfigLabel(config) {
74
+ return `${sandboxLabel(config)}.image-config`;
75
+ }
76
+
77
+ export function parsePositiveIntegerOption(value, optionName) {
78
+ if (value === undefined || value === null) {
79
+ return undefined;
80
+ }
81
+
82
+ const parsed = Number(value);
83
+ if (!Number.isInteger(parsed) || parsed <= 0) {
84
+ throw new Error(`${optionName} must be a positive integer, got: ${value}`);
85
+ }
86
+
87
+ return parsed;
88
+ }
89
+
90
+ export function detectHostResources() {
91
+ if (process.platform === 'darwin') {
92
+ try {
93
+ const hostCpu = Number(execFileSync('sysctl', ['-n', 'hw.ncpu'], { encoding: 'utf8' }).trim());
94
+ const hostMemBytes = Number(execFileSync('sysctl', ['-n', 'hw.memsize'], { encoding: 'utf8' }).trim());
95
+ const hostMemGb = Math.floor(hostMemBytes / 1024 / 1024 / 1024);
96
+
97
+ return {
98
+ cpu: Math.max(1, hostCpu - 2),
99
+ memory: Math.max(2, Math.floor(hostMemGb / 2))
100
+ };
101
+ } catch {
102
+ // Fall through to generic detection below.
103
+ }
104
+ }
105
+
106
+ const hostCpu = os.cpus()?.length ?? 4;
107
+ const hostMemGb = Math.floor(os.totalmem() / 1024 / 1024 / 1024);
108
+
109
+ return {
110
+ cpu: Math.max(1, Math.min(hostCpu, hostCpu - 1 || 1)),
111
+ memory: Math.max(2, Math.floor(hostMemGb / 2))
112
+ };
113
+ }
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const RUNTIMES_DIR = path.join(
8
+ path.dirname(fileURLToPath(import.meta.url)),
9
+ 'runtimes'
10
+ );
11
+
12
+ function listRuntimeFragments() {
13
+ return fs.readdirSync(RUNTIMES_DIR)
14
+ .filter((file) => file.endsWith('.dockerfile'))
15
+ .map((file) => file.replace(/\.dockerfile$/, ''));
16
+ }
17
+
18
+ export function availableRuntimes() {
19
+ return listRuntimeFragments()
20
+ .filter((name) => name !== 'base' && name !== 'ai-tools')
21
+ .sort();
22
+ }
23
+
24
+ function dockerfileContent(config) {
25
+ if (config.dockerfile) {
26
+ const customPath = path.resolve(config.repoRoot, config.dockerfile);
27
+ if (!fs.existsSync(customPath)) {
28
+ throw new Error(`Custom Dockerfile not found: ${customPath}`);
29
+ }
30
+ return fs.readFileSync(customPath, 'utf8');
31
+ }
32
+
33
+ const validRuntimes = new Set(availableRuntimes());
34
+ for (const runtime of config.runtimes) {
35
+ if (!validRuntimes.has(runtime)) {
36
+ throw new Error(
37
+ `Unknown runtime: ${runtime}. Available runtimes: ${[...validRuntimes].join(', ')}`
38
+ );
39
+ }
40
+ }
41
+
42
+ const fragments = [
43
+ 'base.dockerfile',
44
+ ...config.runtimes.map((runtime) => `${runtime}.dockerfile`),
45
+ 'ai-tools.dockerfile'
46
+ ];
47
+
48
+ const content = fragments
49
+ .map((fragment) => fs.readFileSync(path.join(RUNTIMES_DIR, fragment), 'utf8').trimEnd())
50
+ .join('\n\n');
51
+
52
+ return `${content}\n`;
53
+ }
54
+
55
+ export function dockerfileSignature(config) {
56
+ return createHash('sha256')
57
+ .update(dockerfileContent(config))
58
+ .digest('hex')
59
+ .slice(0, 12);
60
+ }
61
+
62
+ export function prepareDockerfile(config) {
63
+ if (config.dockerfile) {
64
+ const customPath = path.resolve(config.repoRoot, config.dockerfile);
65
+ if (!fs.existsSync(customPath)) {
66
+ throw new Error(`Custom Dockerfile not found: ${customPath}`);
67
+ }
68
+
69
+ return {
70
+ path: customPath,
71
+ signature: dockerfileSignature(config),
72
+ cleanup() {}
73
+ };
74
+ }
75
+
76
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${config.project}-sandbox-`));
77
+ const tempPath = path.join(tempDir, 'Dockerfile');
78
+ fs.writeFileSync(tempPath, dockerfileContent(config), 'utf8');
79
+
80
+ return {
81
+ path: tempPath,
82
+ signature: dockerfileSignature(config),
83
+ cleanup() {
84
+ fs.rmSync(tempDir, { recursive: true, force: true });
85
+ }
86
+ };
87
+ }
88
+
89
+ export function composeDockerfile(config) {
90
+ const content = dockerfileContent(config);
91
+
92
+ const tempPath = path.join(os.tmpdir(), `${config.project}-sandbox.Dockerfile`);
93
+ fs.writeFileSync(tempPath, content, 'utf8');
94
+ return tempPath;
95
+ }
@@ -0,0 +1,93 @@
1
+ import { platform } from 'node:os';
2
+ import { detectHostResources } from './constants.js';
3
+ import { run, runOk, runSafe, runVerbose } from './shell.js';
4
+
5
+ export function detectEngine() {
6
+ const os = platform();
7
+ if (os === 'darwin') {
8
+ return 'colima';
9
+ }
10
+ if (os === 'linux') {
11
+ return 'native';
12
+ }
13
+ if (os === 'win32') {
14
+ return 'wsl2';
15
+ }
16
+ return 'unsupported';
17
+ }
18
+
19
+ function colimaArgs(config, runSafeFn = runSafe) {
20
+ const arch = runSafeFn('uname', ['-m']);
21
+ const defaults = detectHostResources();
22
+ const cpu = config.vm.cpu ?? defaults.cpu;
23
+ const memory = config.vm.memory ?? defaults.memory;
24
+ const disk = config.vm.disk ?? 60;
25
+ const args = ['start', '--cpu', String(cpu), '--memory', String(memory), '--disk', String(disk)];
26
+
27
+ if (arch === 'arm64') {
28
+ args.push('--arch', 'aarch64', '--vm-type=vz', '--mount-type=virtiofs');
29
+ } else {
30
+ args.push('--arch', 'x86_64');
31
+ }
32
+
33
+ return args;
34
+ }
35
+
36
+ export async function ensureColima(
37
+ config,
38
+ onMessage,
39
+ { runOkFn = runOk, runSafeFn = runSafe, runVerboseFn = runVerbose } = {}
40
+ ) {
41
+ if (!runOkFn('which', ['colima'])) {
42
+ onMessage?.('Installing colima + docker via Homebrew...');
43
+ runVerboseFn('brew', ['install', 'colima', 'docker']);
44
+ }
45
+
46
+ if (!runOkFn('colima', ['status'])) {
47
+ onMessage?.('Starting Colima VM...');
48
+ runVerboseFn('colima', colimaArgs(config, runSafeFn));
49
+ }
50
+
51
+ if (!runOkFn('docker', ['info'])) {
52
+ throw new Error('Docker daemon is not available after starting Colima');
53
+ }
54
+ }
55
+
56
+ export async function ensureDocker(config, onMessage) {
57
+ const engine = detectEngine();
58
+
59
+ if (engine === 'colima') {
60
+ await ensureColima(config, onMessage);
61
+ return;
62
+ }
63
+
64
+ if (engine === 'native') {
65
+ if (!runOk('docker', ['info'])) {
66
+ throw new Error('Docker daemon is not running. Please start Docker first.');
67
+ }
68
+ return;
69
+ }
70
+
71
+ if (engine === 'wsl2') {
72
+ throw new Error('Windows sandbox support is reserved for a future WSL2 implementation.');
73
+ }
74
+
75
+ throw new Error(`Unsupported sandbox engine: ${engine}`);
76
+ }
77
+
78
+ export function isVmManaged() {
79
+ return detectEngine() === 'colima';
80
+ }
81
+
82
+ export function startManagedVm(config) {
83
+ if (!isVmManaged()) {
84
+ throw new Error('VM management is only available on macOS with Colima.');
85
+ }
86
+
87
+ if (runOk('colima', ['status'])) {
88
+ return 'already-running';
89
+ }
90
+
91
+ runVerbose('colima', colimaArgs(config));
92
+ return 'started';
93
+ }
@@ -0,0 +1,64 @@
1
+ const USAGE = `Usage: ai sandbox <command> [options]
2
+
3
+ Commands:
4
+ create <branch> [base] Create a sandbox (VM + image + worktree + container)
5
+ exec <branch> [cmd...] Enter sandbox or run a command
6
+ ls List sandboxes for the current project
7
+ rm <branch> [--all] Remove a sandbox or all sandboxes
8
+ vm status|start|stop Manage the sandbox VM (macOS only)
9
+ rebuild [--quiet] Rebuild the sandbox image
10
+
11
+ Run 'ai sandbox <command> --help' for details.`;
12
+
13
+ export async function runSandbox(args) {
14
+ const [subcommand, ...rest] = args;
15
+
16
+ if (!subcommand) {
17
+ process.stdout.write(`${USAGE}\n`);
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+
22
+ if (subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
23
+ process.stdout.write(`${USAGE}\n`);
24
+ return;
25
+ }
26
+
27
+ switch (subcommand) {
28
+ case 'create': {
29
+ const { create } = await import('./commands/create.js');
30
+ await create(rest);
31
+ break;
32
+ }
33
+ case 'exec': {
34
+ const { enter } = await import('./commands/enter.js');
35
+ const exitCode = enter(rest);
36
+ if (typeof exitCode === 'number' && exitCode !== 0) {
37
+ process.exitCode = exitCode;
38
+ }
39
+ break;
40
+ }
41
+ case 'ls': {
42
+ const { ls } = await import('./commands/ls.js');
43
+ ls(rest);
44
+ break;
45
+ }
46
+ case 'rm': {
47
+ const { rm } = await import('./commands/rm.js');
48
+ await rm(rest);
49
+ break;
50
+ }
51
+ case 'vm': {
52
+ const { vm } = await import('./commands/vm.js');
53
+ await vm(rest);
54
+ break;
55
+ }
56
+ case 'rebuild': {
57
+ const { rebuild } = await import('./commands/rebuild.js');
58
+ await rebuild(rest);
59
+ break;
60
+ }
61
+ default:
62
+ throw new Error(`Unknown sandbox command: ${subcommand}`);
63
+ }
64
+ }
@@ -0,0 +1,26 @@
1
+ USER devuser
2
+ ENV NPM_CONFIG_PREFIX=/home/devuser/.npm-global
3
+ ENV PATH="/home/devuser/.npm-global/bin:${PATH}"
4
+
5
+ ARG AI_TOOL_PACKAGES
6
+ RUN if [ -z "${AI_TOOL_PACKAGES}" ]; then \
7
+ echo "AI_TOOL_PACKAGES build arg is required"; \
8
+ exit 1; \
9
+ fi && \
10
+ npm install -g ${AI_TOOL_PACKAGES}
11
+
12
+ RUN npm install -g pyright
13
+
14
+ RUN mkdir -p /home/devuser/.local/share /home/devuser/.local/state
15
+
16
+ RUN git config --global --add safe.directory /workspace
17
+
18
+ RUN echo 'export NPM_CONFIG_PREFIX=/home/devuser/.npm-global' >> /home/devuser/.bashrc && \
19
+ echo 'export PATH="/home/devuser/.npm-global/bin:${PATH}"' >> /home/devuser/.bashrc && \
20
+ echo 'export GIT_CONFIG_GLOBAL=/home/devuser/.gitconfig' >> /home/devuser/.bashrc && \
21
+ echo 'export GPG_TTY=$(tty)' >> /home/devuser/.bashrc && \
22
+ echo '[ -f ~/.bash_aliases ] && . ~/.bash_aliases' >> /home/devuser/.bashrc
23
+
24
+ WORKDIR /workspace
25
+
26
+ CMD ["tail", "-f", "/dev/null"]
@@ -0,0 +1,30 @@
1
+ FROM ubuntu:22.04
2
+
3
+ LABEL description="AI coding sandbox"
4
+
5
+ ENV DEBIAN_FRONTEND=noninteractive
6
+ ENV TZ=Asia/Shanghai
7
+
8
+ ARG HOST_UID=1000
9
+ ARG HOST_GID=1000
10
+ RUN (groupadd -g ${HOST_GID} devuser || true) && \
11
+ useradd -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash devuser
12
+
13
+ RUN apt-get update && apt-get install -y \
14
+ curl wget git vim tmux file \
15
+ build-essential ca-certificates gnupg lsb-release \
16
+ locales \
17
+ && locale-gen en_US.UTF-8 \
18
+ && (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
19
+ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
20
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
21
+ > /etc/apt/sources.list.d/github-cli.list \
22
+ && apt-get update && apt-get install -y gh \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ ENV LANG=en_US.UTF-8
26
+ ENV LC_ALL=en_US.UTF-8
27
+ ENV TERM=xterm-256color
28
+ ENV COLORTERM=truecolor
29
+
30
+ RUN ln -s /workspace /home/devuser/workspace
@@ -0,0 +1,3 @@
1
+ RUN apt-get update && apt-get install -y \
2
+ openjdk-17-jdk maven \
3
+ && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,3 @@
1
+ RUN apt-get update && apt-get install -y \
2
+ openjdk-21-jdk maven \
3
+ && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,3 @@
1
+ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
2
+ apt-get install -y nodejs && \
3
+ rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,3 @@
1
+ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
2
+ apt-get install -y nodejs && \
3
+ rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,3 @@
1
+ RUN apt-get update && apt-get install -y \
2
+ python3 python3-pip python3-venv \
3
+ && rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,48 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
4
+
5
+ function normalizeOptions(opts = {}, stdio) {
6
+ return {
7
+ cwd: opts.cwd,
8
+ encoding: opts.encoding,
9
+ stdio,
10
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS
11
+ };
12
+ }
13
+
14
+ export function run(cmd, args, opts = {}) {
15
+ return execFileSync(cmd, args, {
16
+ ...normalizeOptions(opts, ['pipe', 'pipe', 'pipe']),
17
+ encoding: 'utf8'
18
+ }).trim();
19
+ }
20
+
21
+ export function runOk(cmd, args, opts = {}) {
22
+ const result = spawnSync(cmd, args, normalizeOptions(opts, 'pipe'));
23
+ return result.status === 0;
24
+ }
25
+
26
+ export function runInteractive(cmd, args, opts = {}) {
27
+ const result = spawnSync(cmd, args, normalizeOptions(opts, 'inherit'));
28
+ return result.status ?? 1;
29
+ }
30
+
31
+ export function runVerbose(cmd, args, opts = {}) {
32
+ const result = spawnSync(cmd, args, normalizeOptions(opts, 'inherit'));
33
+
34
+ if (result.status !== 0) {
35
+ if (result.signal === 'SIGTERM') {
36
+ throw new Error(`Command timed out after ${opts.timeout ?? DEFAULT_TIMEOUT_MS}ms: ${cmd} ${args.join(' ')}`);
37
+ }
38
+ throw new Error(`Command failed with exit code ${result.status}: ${cmd} ${args.join(' ')}`);
39
+ }
40
+ }
41
+
42
+ export function runSafe(cmd, args, opts = {}) {
43
+ const result = spawnSync(cmd, args, {
44
+ ...normalizeOptions(opts, ['pipe', 'pipe', 'pipe']),
45
+ encoding: 'utf8',
46
+ });
47
+ return (result.stdout ?? '').trim();
48
+ }
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
5
+
6
+ function readTaskContent(repoRoot, taskId) {
7
+ const taskPath = path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md');
8
+ if (!fs.existsSync(taskPath)) {
9
+ throw new Error(`Task not found: ${taskId}`);
10
+ }
11
+ return fs.readFileSync(taskPath, 'utf8');
12
+ }
13
+
14
+ function resolveBranchFromTaskContent(content, taskId) {
15
+ const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
16
+ if (frontmatterBranch && frontmatterBranch[1].trim()) {
17
+ return frontmatterBranch[1].trim();
18
+ }
19
+
20
+ const contextBranch = content.match(/^- \*\*(?:分支|Branch)\*\*:[ \t]*`?([^`\n]+)`?$/m);
21
+ if (contextBranch && contextBranch[1].trim()) {
22
+ return contextBranch[1].trim();
23
+ }
24
+
25
+ throw new Error(`Task ${taskId} has no branch field in task.md`);
26
+ }
27
+
28
+ export function resolveTaskBranch(arg, repoRoot) {
29
+ if (!TASK_ID_RE.test(arg)) {
30
+ return arg;
31
+ }
32
+
33
+ const content = readTaskContent(repoRoot, arg);
34
+ return resolveBranchFromTaskContent(content, arg);
35
+ }
@@ -0,0 +1,135 @@
1
+ import path from 'node:path';
2
+ import { safeNameCandidates, sanitizeBranchName } from './constants.js';
3
+
4
+ /**
5
+ * @typedef {Object} SandboxTool
6
+ * @property {string} id
7
+ * @property {string} name
8
+ * @property {string} npmPackage
9
+ * @property {string} sandboxBase
10
+ * @property {string} containerMount
11
+ * @property {string} versionCmd
12
+ * @property {string} setupHint
13
+ * @property {Record<string, string>=} envVars
14
+ * @property {Array<{ hostPath: string, sandboxName: string }>=} hostPreSeedFiles
15
+ * @property {Array<{ hostDir: string, sandboxSubdir: string }>=} hostPreSeedDirs
16
+ * @property {string[]=} pathRewriteFiles
17
+ * @property {Array<{ hostPath: string, containerSubpath: string }>=} hostLiveMounts
18
+ * @property {string[]=} postSetupCmds
19
+ */
20
+
21
+ function createBuiltinTools(home, project) {
22
+ /** @type {Record<string, SandboxTool>} */
23
+ return {
24
+ 'claude-code': {
25
+ id: 'claude-code',
26
+ name: 'Claude Code',
27
+ npmPackage: '@anthropic-ai/claude-code',
28
+ sandboxBase: path.join(home, '.agent-infra', 'sandboxes', 'claude-code'),
29
+ containerMount: '/home/devuser/.claude',
30
+ versionCmd: 'claude --version',
31
+ setupHint: 'Authenticates via host credentials live-mounted at ~/.claude/.credentials.json',
32
+ // Claude Code stores user data (.claude.json — onboarding state, theme,
33
+ // workspace trust) at $HOME/.claude.json by default, which sits OUTSIDE
34
+ // the bind-mounted /home/devuser/.claude tree, so our preseeded
35
+ // .claude.json never gets read and the theme picker re-runs on every
36
+ // container start. Pinning CLAUDE_CONFIG_DIR to the tool mount relocates
37
+ // .claude.json into the same directory as .credentials.json/settings.json,
38
+ // letting ensureClaudeOnboarding actually take effect.
39
+ envVars: { CLAUDE_CONFIG_DIR: '/home/devuser/.claude' },
40
+ hostPreSeedDirs: [
41
+ { hostDir: path.join(home, '.claude', 'plugins'), sandboxSubdir: 'plugins' }
42
+ ],
43
+ pathRewriteFiles: [
44
+ 'plugins/installed_plugins.json',
45
+ 'plugins/known_marketplaces.json'
46
+ ],
47
+ hostLiveMounts: [
48
+ {
49
+ hostPath: path.join(home, '.agent-infra', 'credentials', project, 'claude-code', '.credentials.json'),
50
+ containerSubpath: '.credentials.json'
51
+ }
52
+ ]
53
+ },
54
+ codex: {
55
+ id: 'codex',
56
+ name: 'Codex',
57
+ npmPackage: '@openai/codex',
58
+ sandboxBase: path.join(home, '.agent-infra', 'sandboxes', 'codex'),
59
+ containerMount: '/home/devuser/.codex',
60
+ versionCmd: 'codex --version',
61
+ setupHint: 'Run codex once inside the container and choose Device Code login if needed.',
62
+ hostLiveMounts: [
63
+ { hostPath: path.join(home, '.codex', 'auth.json'), containerSubpath: 'auth.json' }
64
+ ],
65
+ postSetupCmds: [
66
+ 'test -d /workspace/.codex/commands && ln -sfn /workspace/.codex/commands /home/devuser/.codex/prompts || true'
67
+ ]
68
+ },
69
+ opencode: {
70
+ id: 'opencode',
71
+ name: 'OpenCode',
72
+ npmPackage: 'opencode-ai',
73
+ sandboxBase: path.join(home, '.agent-infra', 'sandboxes', 'opencode'),
74
+ containerMount: '/home/devuser/.local/share/opencode',
75
+ versionCmd: 'opencode version',
76
+ setupHint: 'Configure OpenCode credentials inside the container before first use.',
77
+ hostLiveMounts: [
78
+ {
79
+ hostPath: path.join(home, '.local', 'share', 'opencode', 'auth.json'),
80
+ containerSubpath: 'auth.json'
81
+ }
82
+ ]
83
+ },
84
+ 'gemini-cli': {
85
+ id: 'gemini-cli',
86
+ name: 'Gemini CLI',
87
+ npmPackage: '@google/gemini-cli',
88
+ sandboxBase: path.join(home, '.agent-infra', 'sandboxes', 'gemini-cli'),
89
+ containerMount: '/home/devuser/.gemini',
90
+ versionCmd: 'gemini --version',
91
+ setupHint: 'Run gemini inside the container to finish authentication.',
92
+ hostLiveMounts: [
93
+ { hostPath: path.join(home, '.gemini', 'oauth_creds.json'), containerSubpath: 'oauth_creds.json' }
94
+ ],
95
+ hostPreSeedFiles: [
96
+ { hostPath: path.join(home, '.gemini', 'settings.json'), sandboxName: 'settings.json' },
97
+ { hostPath: path.join(home, '.gemini', 'google_accounts.json'), sandboxName: 'google_accounts.json' }
98
+ ]
99
+ }
100
+ };
101
+ }
102
+
103
+ function validateTool(tool) {
104
+ if (!tool.npmPackage || !tool.containerMount.startsWith('/')) {
105
+ throw new Error(`Invalid sandbox tool descriptor: ${tool.id}`);
106
+ }
107
+ }
108
+
109
+ export function resolveTools(config) {
110
+ const builtins = createBuiltinTools(config.home, config.project);
111
+ return config.tools.map((id) => {
112
+ const tool = builtins[id];
113
+ if (!tool) {
114
+ throw new Error(`Unknown sandbox tool: ${id}`);
115
+ }
116
+ validateTool(tool);
117
+ return tool;
118
+ });
119
+ }
120
+
121
+ export function toolConfigDir(tool, project, branch) {
122
+ return path.join(tool.sandboxBase, project, sanitizeBranchName(branch));
123
+ }
124
+
125
+ export function toolConfigDirCandidates(tool, project, branch) {
126
+ return safeNameCandidates(branch).map((name) => path.join(tool.sandboxBase, project, name));
127
+ }
128
+
129
+ export function toolProjectDirCandidates(tool, project) {
130
+ return [path.join(tool.sandboxBase, project)];
131
+ }
132
+
133
+ export function toolNpmPackagesArg(tools) {
134
+ return tools.map((tool) => tool.npmPackage).join(' ');
135
+ }