Skip to content

Commit 5d965ce

Browse files
committed
Merge branch '3.6.x'
2 parents 4dca33d + ade9201 commit 5d965ce

4 files changed

Lines changed: 179 additions & 50 deletions

File tree

docker/sandbox/DEVELOPMENT.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# 沙箱开发者指南
2+
3+
> 本文档面向维护者和开发者,说明 AI 工具注册表的设计原理和扩展方法。使用指南请参阅 [README.md](README.md)
4+
5+
## 为什么每个沙箱使用独立的 AI 工具配置?
6+
7+
Claude Code、Codex、OpenCode、Gemini CLI 都在各自的配置目录(`~/.claude/``~/.codex/``~/.local/share/opencode/``~/.gemini/`)中存储会话历史、项目记忆、锁文件等状态数据。如果多个沙箱容器共享同一个配置目录,会导致:
8+
9+
1. **并发写入冲突** — 多个容器同时写入 `history.jsonl``session-env/` 等文件会导致数据竞争和文件损坏
10+
2. **会话/记忆交叉污染** — 不同分支的项目上下文和会话历史会互相干扰
11+
3. **清理困难**`sandbox rm` 无法从共享目录中安全地只删除某个沙箱的数据
12+
4. **宿主机风险** — 容器内的破坏性操作可能损坏宿主机的凭据和配置
13+
14+
因此每个沙箱拥有独立的配置目录(`~/.{tool}-sandboxes/{branch}/`),实现完全隔离。
15+
16+
## AI 工具认证机制
17+
18+
各 AI 工具在宿主机上使用不同的凭据存储方式,导致沙箱内的认证体验有所差异:
19+
20+
| | 宿主机凭据存储 | 沙箱认证方式 | 首次使用 |
21+
|---|---|---|---|
22+
| **Codex** | 文件(`~/.codex/auth.json`| 自动从宿主机预植入 `auth.json` | 无需登录,直接使用 |
23+
| **OpenCode** | 文件(`~/.local/share/opencode/auth.json`| 自动从宿主机预植入 `auth.json` | 无需登录,直接使用 |
24+
| **Gemini CLI** | 文件(`~/.gemini/oauth_creds.json`| 自动从宿主机预植入 `oauth_creds.json` + `settings.json` | 无需登录,直接使用 |
25+
| **Claude Code** | macOS Keychain(`Claude Code-credentials`| 容器内 OAuth 登录,凭据存入 `.credentials.json` | 需在容器内登录一次 |
26+
27+
### 为什么 Claude Code 不能预植入?
28+
29+
Claude Code 在 macOS 上将 OAuth token 存储在系统 Keychain 中,宿主机的 `~/.claude/` 目录内没有凭据文件。Docker 容器无法访问 macOS Keychain,因此 Claude Code 在容器内会回退到基于文件的凭据存储(`~/.claude/.credentials.json`),需要首次在容器内完成 OAuth 登录。
30+
31+
登录后凭据持久化在 `~/.claude-sandboxes/{branch}/` 中,后续使用**无需再次登录**
32+
33+
### Codex / OpenCode / Gemini CLI 为什么可以?
34+
35+
Codex、OpenCode 和 Gemini CLI 始终使用文件存储凭据(分别为 `~/.codex/auth.json``~/.local/share/opencode/auth.json``~/.gemini/oauth_creds.json`),`sandbox create` 时自动将宿主机的凭据文件复制到沙箱配置目录,容器内可直接使用。Gemini CLI 还会额外预植入 `settings.json``google_accounts.json`,确保容器内的模型选项和用户设置与宿主机一致。
36+
37+
## AI 工具注册表
38+
39+
AI 工具的安装与运行配置以 `src/tools.ts` 中的 `AI_TOOLS` 注册表为唯一来源:
40+
41+
- `sandbox create` / `sandbox rebuild` 自动把注册表中的 `npmPackage` 列表作为 `AI_TOOL_PACKAGES` 传给 Docker build
42+
- `sandbox create` 把注册表中的 `envVars` 作为 `docker run -e` 注入容器
43+
- `Dockerfile.runtime-only` 不需要硬编码工具包名,只消费 `AI_TOOL_PACKAGES`
44+
45+
### 字段参考
46+
47+
| 字段 | 类型 | 必填 | 说明 |
48+
|---|---|---|---|
49+
| `name` | `string` || 显示名称,如 `"Claude Code"` |
50+
| `npmPackage` | `string` || npm 包名,用于 `npm install -g` |
51+
| `sandboxBase` | `string` || 宿主机上的沙箱配置根目录,如 `~/.codex-sandboxes` |
52+
| `containerMount` | `string` || 容器内挂载路径(绝对路径),如 `/home/devuser/.codex` |
53+
| `versionCmd` | `string` || 验证安装的命令,通过 `bash -lc` 执行 |
54+
| `noAuthHint` | `string` || 未预植入认证时的提示信息 |
55+
| `hostAuthFile` | `string` || 宿主机认证文件路径,与 `authFileName` 成对使用 |
56+
| `authFileName` | `string` || 沙箱内认证文件名(相对于 `sandboxBase/{branch}/`|
57+
| `hostPreSeedFiles` | `Array<{hostPath, sandboxName}>` || 额外需要预植入的宿主机文件(如设置、账户信息) |
58+
| `postSetupCmds` | `string[]` || 容器启动后执行的 shell 命令(如创建符号链接) |
59+
| `envVars` | `Record<string, string>` || 注入容器的额外环境变量 |
60+
61+
**校验规则**`validateTools` 启动时检查):
62+
- `name` 不能重复
63+
- `npmPackage` 不能为空
64+
- `containerMount` 必须是绝对路径
65+
- `hostAuthFile``authFileName` 必须同时存在或同时缺省
66+
67+
### `sandbox create` 执行流程
68+
69+
```
70+
1. 创建沙箱配置目录 sandboxBase/{branch}/
71+
2. 预植入认证文件 hostAuthFile → sandboxBase/{branch}/authFileName
72+
3. 预植入额外配置文件 hostPreSeedFiles[].hostPath → sandboxBase/{branch}/sandboxName
73+
4. 挂载配置目录到容器 sandboxBase/{branch}/ → containerMount
74+
5. 注入环境变量 envVars → docker run -e
75+
6. 容器启动后执行命令 postSetupCmds → docker exec bash -lc
76+
7. 验证安装 versionCmd → docker exec bash -lc
77+
```
78+
79+
所有预植入操作遵循"仅首次"策略:宿主机文件存在且沙箱中不存在时才复制,不会覆盖已有配置。
80+
81+
## 添加新工具
82+
83+
以 Gemini CLI 为例,说明添加一个新工具的完整步骤:
84+
85+
### 步骤 1:在注册表中追加描述符
86+
87+
编辑 `src/tools.ts`,在 `AI_TOOLS` 数组末尾追加:
88+
89+
```typescript
90+
{
91+
name: 'Gemini CLI',
92+
npmPackage: '@google/gemini-cli',
93+
sandboxBase: path.join(HOME, '.gemini-sandboxes'),
94+
containerMount: '/home/devuser/.gemini',
95+
versionCmd: 'gemini --version',
96+
hostAuthFile: path.join(HOME, '.gemini', 'oauth_creds.json'),
97+
authFileName: 'oauth_creds.json',
98+
noAuthHint: '首次使用需在容器内运行 gemini 完成认证。',
99+
hostPreSeedFiles: [
100+
{ hostPath: path.join(HOME, '.gemini', 'settings.json'), sandboxName: 'settings.json' },
101+
{ hostPath: path.join(HOME, '.gemini', 'google_accounts.json'), sandboxName: 'google_accounts.json' },
102+
],
103+
},
104+
```
105+
106+
### 步骤 2:编译验证
107+
108+
```bash
109+
cd docker/sandbox
110+
npm run build # 确认无语法错误
111+
```
112+
113+
### 步骤 3:重建镜像 + 重建沙箱
114+
115+
```bash
116+
sandbox rebuild # 重建镜像(安装新工具的 npm 包)
117+
sandbox rm <branch> # 删除旧沙箱
118+
sandbox create <branch> # 重新创建(触发预植入和 postSetupCmds)
119+
```
120+
121+
### 步骤 4:验证
122+
123+
```bash
124+
sandbox exec <branch>
125+
gemini --version # 确认工具可用
126+
```
127+
128+
**通常只需修改 `src/tools.ts` 一个文件。** 只有当工具需要注册表尚未支持的新能力时,才需要扩展 `AiTool` 接口和 `create.ts`

docker/sandbox/README.md

Lines changed: 14 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# macOS AI 编程沙箱环境
22

3-
> 基于 Colima + Docker + Git Worktree,将 AI TUI 工具(Claude Code、Codex、OpenCode 等)运行在容器内,物理隔离保护宿主机。
3+
> 基于 Colima + Docker + Git Worktree,将 AI TUI 工具(Claude Code、Codex、OpenCode、Gemini CLI 等)运行在容器内,物理隔离保护宿主机。
44
> 支持多容器并发,每个容器工作在独立分支上互不干扰。
55
66
## 架构
@@ -20,12 +20,14 @@
2020
│ │ ┌────────────────────────────┐ │ │
2121
│ │ │ fit-dev-feat-xxx │ │ │
2222
│ │ │ claude / codex / opencode │ ← 挂载 feat-xxx worktree
23+
│ │ │ gemini │ │ │
2324
│ │ │ java / mvn / python │ │ │
2425
│ │ └────────────────────────────┘ │ │
2526
│ │ │ │
2627
│ │ ┌────────────────────────────┐ │ │
2728
│ │ │ fit-dev-fix-bug-123 │ │ │
2829
│ │ │ claude / codex / opencode │ ← 挂载 fix-bug-123 worktree
30+
│ │ │ gemini │ │ │
2931
│ │ │ java / mvn / python │ │ │
3032
│ │ └────────────────────────────┘ │ │
3133
│ │ │ │
@@ -90,14 +92,15 @@ sandbox exec feat-xxx
9092
# 进入容器后,直接使用
9193
claude # Claude Code(首次需容器内 OAuth 登录)
9294
codex # OpenAI Codex(自动预植入宿主机凭据)
93-
opencode # OpenCode
95+
opencode # OpenCode(自动预植入宿主机凭据)
96+
gemini # Gemini CLI(自动预植入宿主机凭据)
9497

9598
# 也可以直接开发
9699
mvn clean install
97100
python3 script.py
98101
```
99102

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

@@ -188,6 +191,10 @@ sandbox vm start --cpu 6 --memory 8 # 自定义资源启动
188191
├── feat-xxx/ # → 挂载到容器 /home/devuser/.local/share/opencode
189192
└── fix-bug-123/
190193
194+
~/.gemini-sandboxes/ # Gemini CLI 沙箱配置(每分支独立)
195+
├── feat-xxx/ # → 挂载到容器 /home/devuser/.gemini
196+
└── fix-bug-123/
197+
191198
主仓库: ~/projects/.../fit-framework/ (不变,不被容器挂载)
192199
```
193200

@@ -196,52 +203,9 @@ sandbox vm start --cpu 6 --memory 8 # 自定义资源启动
196203
- 路径简短清晰
197204
- 不在项目目录内,天然被 `.gitignore` 排除
198205

199-
### 为什么每个沙箱使用独立的 AI 工具配置?
200-
201-
Claude Code、Codex、OpenCode 都在各自的配置目录(`~/.claude/``~/.codex/``~/.local/share/opencode/`)中存储会话历史、项目记忆、锁文件等状态数据。如果多个沙箱容器共享同一个配置目录,会导致:
202-
203-
1. **并发写入冲突** — 多个容器同时写入 `history.jsonl``session-env/` 等文件会导致数据竞争和文件损坏
204-
2. **会话/记忆交叉污染** — 不同分支的项目上下文和会话历史会互相干扰
205-
3. **清理困难**`sandbox rm` 无法从共享目录中安全地只删除某个沙箱的数据
206-
4. **宿主机风险** — 容器内的破坏性操作可能损坏宿主机的凭据和配置
207-
208-
因此每个沙箱拥有独立的配置目录,实现完全隔离。
209-
210-
### AI 工具认证机制
211-
212-
各 AI 工具在宿主机上使用不同的凭据存储方式,导致沙箱内的认证体验有所差异:
213-
214-
| | 宿主机凭据存储 | 沙箱认证方式 | 首次使用 |
215-
|---|---|---|---|
216-
| **Codex** | 文件(`~/.codex/auth.json`| 自动从宿主机预植入 `auth.json` | 无需登录,直接使用 |
217-
| **OpenCode** | 文件(`~/.local/share/opencode/auth.json`| 自动从宿主机预植入 `auth.json` | 无需登录,直接使用 |
218-
| **Claude Code** | macOS Keychain(`Claude Code-credentials`| 容器内 OAuth 登录,凭据存入 `.credentials.json` | 需在容器内登录一次 |
219-
220-
**为什么 Claude Code 不能预植入?**
221-
222-
Claude Code 在 macOS 上将 OAuth token 存储在系统 Keychain 中,宿主机的 `~/.claude/` 目录内没有凭据文件。Docker 容器无法访问 macOS Keychain,因此 Claude Code 在容器内会回退到基于文件的凭据存储(`~/.claude/.credentials.json`),需要首次在容器内完成 OAuth 登录。
223-
224-
登录后凭据持久化在 `~/.claude-sandboxes/{branch}/` 中,后续使用**无需再次登录**
225-
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` 重建镜像
206+
每个沙箱拥有独立的 AI 工具配置目录(如 `~/.codex-sandboxes/{branch}/`),避免并发冲突和会话污染。Codex、OpenCode、Gemini CLI 创建沙箱时自动从宿主机预植入认证凭据;Claude Code 首次需在容器内 OAuth 登录一次。
243207

244-
无需手工同步 Dockerfile 中的 `npm install -g` 包列表
208+
> 详细的认证机制说明、注册表字段参考和添加新工具指南请参阅 [DEVELOPMENT.md](DEVELOPMENT.md)
245209
246210
## 高级配置
247211

@@ -294,7 +258,8 @@ EOF
294258

295259
```
296260
docker/sandbox/
297-
├── README.md # 本文件
261+
├── README.md # 用户指南(使用、管理、故障排查)
262+
├── DEVELOPMENT.md # 开发者指南(注册表、认证机制、添加新工具)
298263
├── sandbox.sh # CLI 入口(thin wrapper)
299264
├── Dockerfile.runtime-only # 镜像定义(运行时 + AI 工具)
300265
├── package.json # Node.js 依赖(commander + clack)

docker/sandbox/src/commands/create.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,23 @@ export async function create(branch: string, base: string | undefined, opts: Cre
126126
}
127127
}
128128

129-
// Ensure config dirs exist + pre-seed auth from host
129+
// Ensure config dirs exist + pre-seed auth & config from host
130130
for (const { tool, dir } of resolvedTools) {
131131
fs.mkdirSync(dir, { recursive: true });
132132
if (tool.hostAuthFile && tool.authFileName) {
133133
const sandboxAuth = path.join(dir, tool.authFileName);
134134
if (fs.existsSync(tool.hostAuthFile) && !fs.existsSync(sandboxAuth)) {
135+
fs.mkdirSync(path.dirname(sandboxAuth), { recursive: true });
135136
fs.copyFileSync(tool.hostAuthFile, sandboxAuth);
136137
}
137138
}
139+
for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
140+
const dest = path.join(dir, sandboxName);
141+
if (fs.existsSync(hostPath) && !fs.existsSync(dest)) {
142+
fs.mkdirSync(path.dirname(dest), { recursive: true });
143+
fs.copyFileSync(hostPath, dest);
144+
}
145+
}
138146
}
139147

140148
// Build env args and volume mounts from tool registry
@@ -164,6 +172,13 @@ export async function create(branch: string, base: string | undefined, opts: Cre
164172
message('Syncing git config...');
165173
syncGitConfig(container);
166174

175+
// Run post-setup commands inside container
176+
for (const { tool } of resolvedTools) {
177+
for (const cmd of tool.postSetupCmds ?? []) {
178+
runSafe('docker', ['exec', container, 'bash', '-lc', cmd]);
179+
}
180+
}
181+
167182
return 'Container started';
168183
},
169184
},

docker/sandbox/src/tools.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export interface AiTool {
2626
authFileName?: string;
2727
/** Hint shown when auth is NOT pre-seeded */
2828
noAuthHint: string;
29+
/** Additional host files to pre-seed into sandbox (e.g. settings, account info) */
30+
hostPreSeedFiles?: Array<{ hostPath: string; sandboxName: string }>;
31+
/** Shell commands to run inside the container after setup (e.g. symlink prompts) */
32+
postSetupCmds?: string[];
2933
/** Extra environment variables to pass to the container */
3034
envVars?: Record<string, string>;
3135
}
@@ -74,6 +78,9 @@ export const AI_TOOLS: readonly Readonly<AiTool>[] = [
7478
hostAuthFile: path.join(HOME, '.codex', 'auth.json'),
7579
authFileName: 'auth.json',
7680
noAuthHint: '首次使用需在容器内运行 codex,按 Esc 选择 Device Code 方式登录。',
81+
postSetupCmds: [
82+
'test -d /workspace/.codex/commands && ln -sfn /workspace/.codex/commands /home/devuser/.codex/prompts || true',
83+
],
7784
},
7885
{
7986
name: 'OpenCode',
@@ -85,6 +92,20 @@ export const AI_TOOLS: readonly Readonly<AiTool>[] = [
8592
authFileName: 'auth.json',
8693
noAuthHint: '首次使用需在容器内配置认证凭据。',
8794
},
95+
{
96+
name: 'Gemini CLI',
97+
npmPackage: '@google/gemini-cli',
98+
sandboxBase: path.join(HOME, '.gemini-sandboxes'),
99+
containerMount: '/home/devuser/.gemini',
100+
versionCmd: 'gemini --version',
101+
hostAuthFile: path.join(HOME, '.gemini', 'oauth_creds.json'),
102+
authFileName: 'oauth_creds.json',
103+
noAuthHint: '首次使用需在容器内运行 gemini 完成认证(支持 Google 登录、API Key、Vertex AI)。',
104+
hostPreSeedFiles: [
105+
{ hostPath: path.join(HOME, '.gemini', 'settings.json'), sandboxName: 'settings.json' },
106+
{ hostPath: path.join(HOME, '.gemini', 'google_accounts.json'), sandboxName: 'google_accounts.json' },
107+
],
108+
},
88109
];
89110

90111
// Fail fast on startup if descriptors are misconfigured

0 commit comments

Comments
 (0)