Skip to content

Commit 4dca33d

Browse files
committed
Merge branch '3.6.x'
2 parents 98007a0 + e4c0100 commit 4dca33d

9 files changed

Lines changed: 225 additions & 118 deletions

File tree

docker/sandbox/Dockerfile.runtime-only

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ENV TZ=Asia/Shanghai
1414
# 创建非特权用户(UID/GID 与宿主机对齐,避免文件权限问题)
1515
ARG HOST_UID=1000
1616
ARG HOST_GID=1000
17+
ARG AI_TOOL_PACKAGES
1718
RUN (groupadd -g ${HOST_GID} devuser || true) && \
1819
useradd -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash devuser
1920

@@ -30,7 +31,7 @@ ENV LC_ALL=en_US.UTF-8
3031
ENV TERM=xterm-256color
3132
ENV COLORTERM=truecolor
3233

33-
# Node.js 20 LTS(Claude Code 和 Codex 需要 Node 18+)
34+
# Node.js 20 LTS(AI 工具依赖 Node 18+)
3435
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
3536
apt-get install -y nodejs && \
3637
rm -rf /var/lib/apt/lists/*
@@ -53,18 +54,22 @@ USER devuser
5354
ENV NPM_CONFIG_PREFIX=/home/devuser/.npm-global
5455
ENV PATH="/home/devuser/.npm-global/bin:${PATH}"
5556

56-
RUN npm install -g @anthropic-ai/claude-code && \
57-
npm install -g @openai/codex && \
58-
npm install -g opencode-ai
57+
RUN if [ -z "${AI_TOOL_PACKAGES}" ]; then \
58+
echo "AI_TOOL_PACKAGES build arg is required"; \
59+
exit 1; \
60+
fi && \
61+
npm install -g ${AI_TOOL_PACKAGES}
62+
63+
# 预创建 .local 目录结构(避免 Docker 卷挂载以 root 创建中间目录导致权限问题)
64+
RUN mkdir -p /home/devuser/.local/share /home/devuser/.local/state
5965

6066
# 默认信任 /workspace 挂载目录(避免 dubious ownership 错误)
6167
RUN git config --global --add safe.directory /workspace
6268

6369
# 写入环境变量到 .bashrc(交互式 shell 自动加载)
6470
RUN echo 'export NPM_CONFIG_PREFIX=/home/devuser/.npm-global' >> /home/devuser/.bashrc && \
6571
echo 'export PATH="/home/devuser/.npm-global/bin:${PATH}"' >> /home/devuser/.bashrc && \
66-
echo 'export GIT_CONFIG_GLOBAL=/home/devuser/.gitconfig' >> /home/devuser/.bashrc && \
67-
echo 'export CLAUDE_CONFIG_DIR=/home/devuser/.claude' >> /home/devuser/.bashrc
72+
echo 'export GIT_CONFIG_GLOBAL=/home/devuser/.gitconfig' >> /home/devuser/.bashrc
6873

6974
WORKDIR /workspace
7075

docker/sandbox/README.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ mvn clean install
9797
python3 script.py
9898
```
9999

100-
> **Claude Code vs Codex 认证差异**:Codex 创建沙箱时会自动从宿主机预植入认证凭据,可直接使用;Claude Code 首次需要在容器内完成一次 OAuth 登录(之后免登录)。详见[认证机制说明](#ai-工具认证机制)
100+
> **认证差异**:Codex 和 OpenCode 创建沙箱时会自动从宿主机预植入认证凭据,可直接使用;Claude Code 首次需要在容器内完成一次 OAuth 登录(之后免登录)。详见[认证机制说明](#ai-工具认证机制)
101101
102102
## 多容器并发工作流
103103

@@ -149,7 +149,7 @@ sandbox ls
149149
docker stop fit-dev-feat-xxx
150150
docker start fit-dev-feat-xxx
151151

152-
# 清理指定沙箱(容器 + worktree + Claude/Codex 配置
152+
# 清理指定沙箱(容器 + worktree + AI 工具配置
153153
sandbox rm feat-xxx
154154

155155
# 清理所有沙箱
@@ -184,6 +184,10 @@ sandbox vm start --cpu 6 --memory 8 # 自定义资源启动
184184
├── feat-xxx/ # → 挂载到容器 /home/devuser/.codex
185185
└── fix-bug-123/
186186
187+
~/.opencode-sandboxes/ # OpenCode 沙箱配置(每分支独立)
188+
├── feat-xxx/ # → 挂载到容器 /home/devuser/.local/share/opencode
189+
└── fix-bug-123/
190+
187191
主仓库: ~/projects/.../fit-framework/ (不变,不被容器挂载)
188192
```
189193

@@ -194,7 +198,7 @@ sandbox vm start --cpu 6 --memory 8 # 自定义资源启动
194198

195199
### 为什么每个沙箱使用独立的 AI 工具配置?
196200

197-
Claude CodeCodex 都在配置目录`~/.claude/``~/.codex/`)中存储会话历史、项目记忆、锁文件等状态数据。如果多个沙箱容器共享同一个配置目录,会导致:
201+
Claude CodeCodex、OpenCode 都在各自的配置目录`~/.claude/``~/.codex/``~/.local/share/opencode/`)中存储会话历史、项目记忆、锁文件等状态数据。如果多个沙箱容器共享同一个配置目录,会导致:
198202

199203
1. **并发写入冲突** — 多个容器同时写入 `history.jsonl``session-env/` 等文件会导致数据竞争和文件损坏
200204
2. **会话/记忆交叉污染** — 不同分支的项目上下文和会话历史会互相干扰
@@ -205,11 +209,12 @@ Claude Code 和 Codex 都在配置目录(`~/.claude/`、`~/.codex/`)中存
205209

206210
### AI 工具认证机制
207211

208-
Claude Code 和 Codex 在宿主机上使用不同的凭据存储方式,导致沙箱内的认证体验有所差异:
212+
各 AI 工具在宿主机上使用不同的凭据存储方式,导致沙箱内的认证体验有所差异:
209213

210214
| | 宿主机凭据存储 | 沙箱认证方式 | 首次使用 |
211215
|---|---|---|---|
212216
| **Codex** | 文件(`~/.codex/auth.json`| 自动从宿主机预植入 `auth.json` | 无需登录,直接使用 |
217+
| **OpenCode** | 文件(`~/.local/share/opencode/auth.json`| 自动从宿主机预植入 `auth.json` | 无需登录,直接使用 |
213218
| **Claude Code** | macOS Keychain(`Claude Code-credentials`| 容器内 OAuth 登录,凭据存入 `.credentials.json` | 需在容器内登录一次 |
214219

215220
**为什么 Claude Code 不能预植入?**
@@ -218,9 +223,25 @@ Claude Code 在 macOS 上将 OAuth token 存储在系统 Keychain 中,宿主
218223

219224
登录后凭据持久化在 `~/.claude-sandboxes/{branch}/` 中,后续使用**无需再次登录**
220225

221-
**Codex 为什么可以?**
226+
**Codex / OpenCode 为什么可以?**
227+
228+
Codex 和 OpenCode 始终使用文件存储凭据(分别为 `~/.codex/auth.json``~/.local/share/opencode/auth.json`),`sandbox create` 时自动将宿主机的凭据文件复制到沙箱配置目录,容器内可直接使用。
229+
230+
### AI 工具维护(单一事实源)
231+
232+
AI 工具的安装与运行配置以 `src/tools.ts` 中的 `AI_TOOLS` 注册表为唯一来源:
233+
234+
- 每个工具在注册表中声明 `name``npmPackage``sandboxBase``containerMount``versionCmd` 等信息
235+
- `sandbox create` / `sandbox rebuild` 会自动把注册表中的 `npmPackage` 列表作为 `AI_TOOL_PACKAGES` 传给 Docker build
236+
- `sandbox create` 会把注册表中的 `envVars` 作为 `docker run -e` 注入容器
237+
- `Dockerfile.runtime-only` 不需要硬编码工具包名,只消费 `AI_TOOL_PACKAGES`
238+
239+
这意味着新增工具时,通常只需:
240+
241+
1.`src/tools.ts``AI_TOOLS` 追加新描述符
242+
2. 运行 `sandbox rebuild` 重建镜像
222243

223-
Codex 始终使用文件存储凭据(`~/.codex/auth.json`),`sandbox create` 时自动将宿主机的 `auth.json` 复制到沙箱配置目录,容器内可直接使用
244+
无需手工同步 Dockerfile 中的 `npm install -g` 包列表
224245

225246
## 高级配置
226247

@@ -281,6 +302,7 @@ docker/sandbox/
281302
└── src/
282303
├── cli.ts # Commander 子命令分发
283304
├── constants.ts # 共享常量 + 工具函数
305+
├── tools.ts # AI 工具注册表(声明式,新增工具只改这里)
284306
├── shell.ts # 安全的命令执行封装
285307
└── commands/ # 各子命令实现
286308
├── create.ts

docker/sandbox/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ program
2929
// ========== rm ==========
3030
program
3131
.command('rm')
32-
.description('删除沙箱(容器 + worktree + Claude 配置)')
32+
.description('删除沙箱(容器 + worktree + AI 工具配置)')
3333
.argument('[branch]', '分支名(省略时需搭配 --all)')
3434
.option('--all', '删除所有沙箱')
3535
.action(async (branch: string | undefined, opts) => {

docker/sandbox/src/commands/create.ts

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import pc from 'picocolors';
66
import {
77
IMAGE_NAME, MAIN_REPO, DOCKERFILE, SCRIPTS_DIR,
88
containerName, containerNameCandidates,
9-
worktreeDirCandidates, claudeConfigDirCandidates, codexConfigDirCandidates,
9+
worktreeDirCandidates,
1010
sanitizeBranchName, detectHostResources, assertValidBranchName,
1111
parsePositiveIntegerOption,
1212
} from '../constants.js';
13+
import { AI_TOOLS, toolConfigDirCandidates, toolNpmPackagesArg } from '../tools.js';
1314
import { run, runOk, runSafe } from '../shell.js';
1415

1516
interface CreateOptions {
@@ -27,13 +28,15 @@ export async function create(branch: string, base: string | undefined, opts: Cre
2728
const safeName = sanitizeBranchName(branch);
2829
const container = containerName(branch);
2930
const worktreeCandidates = worktreeDirCandidates(branch);
30-
const claudeDirCandidates = claudeConfigDirCandidates(branch);
31-
const codexDirCandidates = codexConfigDirCandidates(branch);
3231
const worktree = worktreeCandidates.find((dir) => fs.existsSync(dir)) ?? worktreeCandidates[0];
33-
const claudeDir = claudeDirCandidates.find((dir) => fs.existsSync(dir)) ?? claudeDirCandidates[0];
34-
const codexDir = codexDirCandidates.find((dir) => fs.existsSync(dir)) ?? codexDirCandidates[0];
3532
const baseBranch = base ?? runSafe('git', ['-C', MAIN_REPO, 'branch', '--show-current']);
3633

34+
// Resolve per-branch config directory for each AI tool
35+
const resolvedTools = AI_TOOLS.map((tool) => {
36+
const candidates = toolConfigDirCandidates(tool, branch);
37+
return { tool, dir: candidates.find((d) => fs.existsSync(d)) ?? candidates[0] };
38+
});
39+
3740
p.intro(pc.cyan('AI Coding Sandbox (Colima)'));
3841
p.log.info(`Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch)} | VM: ${vmCpu} CPU / ${vmMemory} GB`);
3942

@@ -81,6 +84,7 @@ export async function create(branch: string, base: string | undefined, opts: Cre
8184
'build', '-t', IMAGE_NAME,
8285
'--build-arg', `HOST_UID=${hostUid}`,
8386
'--build-arg', `HOST_GID=${hostGid}`,
87+
'--build-arg', `AI_TOOL_PACKAGES=${toolNpmPackagesArg()}`,
8488
'-f', DOCKERFILE, '.',
8589
], { cwd: SCRIPTS_DIR });
8690
return 'Image built';
@@ -122,20 +126,25 @@ export async function create(branch: string, base: string | undefined, opts: Cre
122126
}
123127
}
124128

125-
const envArgs: string[] = [];
126-
envArgs.push('-e', 'CLAUDE_CONFIG_DIR=/home/devuser/.claude');
127-
128-
// Ensure config dirs exist
129-
fs.mkdirSync(claudeDir, { recursive: true });
130-
fs.mkdirSync(codexDir, { recursive: true });
131-
132-
// Pre-seed Codex auth from host if available
133-
const hostCodexAuth = path.join(process.env.HOME!, '.codex', 'auth.json');
134-
const sandboxCodexAuth = path.join(codexDir, 'auth.json');
135-
if (fs.existsSync(hostCodexAuth) && !fs.existsSync(sandboxCodexAuth)) {
136-
fs.copyFileSync(hostCodexAuth, sandboxCodexAuth);
129+
// Ensure config dirs exist + pre-seed auth from host
130+
for (const { tool, dir } of resolvedTools) {
131+
fs.mkdirSync(dir, { recursive: true });
132+
if (tool.hostAuthFile && tool.authFileName) {
133+
const sandboxAuth = path.join(dir, tool.authFileName);
134+
if (fs.existsSync(tool.hostAuthFile) && !fs.existsSync(sandboxAuth)) {
135+
fs.copyFileSync(tool.hostAuthFile, sandboxAuth);
136+
}
137+
}
137138
}
138139

140+
// Build env args and volume mounts from tool registry
141+
const envArgs = resolvedTools.flatMap(({ tool }) =>
142+
Object.entries(tool.envVars ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`])
143+
);
144+
const toolVolumes = resolvedTools.flatMap(({ tool, dir }) =>
145+
['-v', `${dir}:${tool.containerMount}`]
146+
);
147+
139148
run('docker', [
140149
'run', '-d',
141150
'--name', container,
@@ -145,8 +154,7 @@ export async function create(branch: string, base: string | undefined, opts: Cre
145154
'-v', `${worktree}:/workspace`,
146155
'-v', `${MAIN_REPO}/.git:${MAIN_REPO}/.git`,
147156
'-v', `${process.env.HOME}/.ssh:/home/devuser/.ssh:ro`,
148-
'-v', `${claudeDir}:/home/devuser/.claude`,
149-
'-v', `${codexDir}:/home/devuser/.codex`,
157+
...toolVolumes,
150158
...envArgs,
151159
'-w', '/workspace',
152160
IMAGE_NAME,
@@ -168,12 +176,20 @@ export async function create(branch: string, base: string | undefined, opts: Cre
168176
{ name: 'Container running', ok: runningContainers.includes(container) },
169177
{ name: 'Java', ok: runOk('docker', ['exec', container, 'java', '-version']) },
170178
{ name: 'Maven', ok: runOk('docker', ['exec', container, 'mvn', '--version']) },
171-
{ name: 'Claude Code', ok: runOk('docker', ['exec', container, 'bash', '-lc', 'claude --version']) },
172-
{ name: 'Codex', ok: runOk('docker', ['exec', container, 'bash', '-lc', 'codex --version']) },
173179
];
180+
const toolChecks = AI_TOOLS.map((tool) => ({
181+
tool,
182+
ok: runOk('docker', ['exec', container, 'bash', '-lc', tool.versionCmd]),
183+
}));
174184
for (const c of checks) {
175185
p.log.info(` ${c.ok ? pc.green('✓') : pc.yellow('?')} ${c.name}`);
176186
}
187+
for (const c of toolChecks) {
188+
p.log.info(` ${c.ok ? pc.green('✓') : pc.yellow('?')} ${c.tool.name}`);
189+
if (!c.ok) {
190+
p.log.warn(` ${c.tool.name} 未安装或不可用(期望 npm 包:${c.tool.npmPackage}),可运行 sandbox rebuild`);
191+
}
192+
}
177193

178194
// Result summary
179195
p.log.success(pc.green('Ready!'));
@@ -187,6 +203,13 @@ export async function create(branch: string, base: string | undefined, opts: Cre
187203
const maxCmdLen = Math.max(...mgmtCmds.map(([c]) => c.length));
188204
const mgmtLines = mgmtCmds.map(([cmd, comment]) => ` ${cmd.padEnd(maxCmdLen + 4)}${comment}`).join('\n');
189205

206+
// Tool credential hints
207+
const toolHints = resolvedTools.map(({ tool, dir }) => {
208+
const hasAuth = tool.authFileName && fs.existsSync(path.join(dir, tool.authFileName));
209+
const hint = hasAuth ? '已从宿主机预植入认证凭据,可直接使用。' : tool.noAuthHint;
210+
return `${pc.cyan(`${tool.name}:`)}\n ${hint}\n 凭据持久化:${dir}/`;
211+
}).join('\n\n');
212+
190213
console.log(`
191214
${pc.cyan('进入沙箱:')}
192215
docker exec -it ${container} bash
@@ -200,13 +223,7 @@ ${pc.cyan('沙箱信息:')}
200223
${pc.cyan('管理命令:')}
201224
${mgmtLines}
202225
203-
${pc.cyan('Claude Code:')}
204-
首次使用需在容器内运行 claude 完成一次 OAuth 登录,之后免登录。
205-
凭据持久化:${claudeDir}/
206-
207-
${pc.cyan('Codex:')}
208-
${fs.existsSync(path.join(codexDir, 'auth.json')) ? '已从宿主机预植入认证凭据,可直接使用。' : '首次使用需在容器内运行 codex,按 Esc 选择 Device Code 方式登录。'}
209-
凭据持久化:${codexDir}/
226+
${toolHints}
210227
`);
211228
}
212229

docker/sandbox/src/commands/ls.ts

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import fs from 'node:fs';
22
import * as p from '@clack/prompts';
33
import pc from 'picocolors';
4-
import { WORKTREE_BASE, CLAUDE_SANDBOX_BASE, CODEX_SANDBOX_BASE } from '../constants.js';
4+
import { WORKTREE_BASE } from '../constants.js';
5+
import { AI_TOOLS } from '../tools.js';
56
import { runSafe } from '../shell.js';
67

78
export function ls() {
@@ -37,34 +38,21 @@ export function ls() {
3738
p.log.warn(' 没有 worktree 目录');
3839
}
3940

40-
// Claude config dirs
41-
p.log.step('Claude 配置:');
42-
if (fs.existsSync(CLAUDE_SANDBOX_BASE)) {
43-
const entries = fs.readdirSync(CLAUDE_SANDBOX_BASE);
44-
if (entries.length > 0) {
45-
for (const entry of entries) {
46-
console.log(` ${entry} -> ${CLAUDE_SANDBOX_BASE}/${entry}`);
41+
// AI tool config dirs
42+
for (const tool of AI_TOOLS) {
43+
p.log.step(`${tool.name} 配置:`);
44+
if (fs.existsSync(tool.sandboxBase)) {
45+
const entries = fs.readdirSync(tool.sandboxBase);
46+
if (entries.length > 0) {
47+
for (const entry of entries) {
48+
console.log(` ${entry} -> ${tool.sandboxBase}/${entry}`);
49+
}
50+
} else {
51+
p.log.warn(` 没有 ${tool.name} 沙箱配置`);
4752
}
4853
} else {
49-
p.log.warn(' 没有 Claude 沙箱配置');
54+
p.log.warn(` 没有 ${tool.name} 沙箱配置`);
5055
}
51-
} else {
52-
p.log.warn(' 没有 Claude 沙箱配置');
53-
}
54-
55-
// Codex config dirs
56-
p.log.step('Codex 配置:');
57-
if (fs.existsSync(CODEX_SANDBOX_BASE)) {
58-
const entries = fs.readdirSync(CODEX_SANDBOX_BASE);
59-
if (entries.length > 0) {
60-
for (const entry of entries) {
61-
console.log(` ${entry} -> ${CODEX_SANDBOX_BASE}/${entry}`);
62-
}
63-
} else {
64-
p.log.warn(' 没有 Codex 沙箱配置');
65-
}
66-
} else {
67-
p.log.warn(' 没有 Codex 沙箱配置');
6856
}
6957

7058
}

docker/sandbox/src/commands/rebuild.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as p from '@clack/prompts';
22
import pc from 'picocolors';
33
import { IMAGE_NAME, DOCKERFILE, SCRIPTS_DIR } from '../constants.js';
4+
import { toolNpmPackagesArg } from '../tools.js';
45
import { run, runSafe, runVerbose } from '../shell.js';
56

67
interface RebuildOptions {
@@ -31,6 +32,7 @@ export function rebuild(opts: RebuildOptions) {
3132
'build', '-t', IMAGE_NAME,
3233
'--build-arg', `HOST_UID=${hostUid}`,
3334
'--build-arg', `HOST_GID=${hostGid}`,
35+
'--build-arg', `AI_TOOL_PACKAGES=${toolNpmPackagesArg()}`,
3436
'-f', DOCKERFILE, '.',
3537
];
3638

0 commit comments

Comments
 (0)