Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/en/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ docker info

Add the `DOCKER_HOST` export to your shell startup file after validation.

When rootless Docker is detected, agent-infra builds the sandbox image with `HOST_UID=0` and `HOST_GID=0`. Inside the container the sandbox user can read bind mounts such as `~/.ssh` without relaxing host file permissions. On the host, the daemon and container processes still run under the current user, so this does not grant host root privileges.
When rootless Docker is detected, agent-infra builds the sandbox image with `HOST_UID=0` and `HOST_GID=0`. Inside the container the sandbox user can read normal bind mounts such as the worktree and `/share/*` directories without relaxing host file permissions. On the host, the daemon and container processes still run under the current user, so this does not grant host root privileges.

Known rootless differences:

Expand All @@ -111,7 +111,7 @@ Known rootless differences:
Troubleshooting:

- If `docker info` fails, check `systemctl --user status docker` and confirm `DOCKER_HOST` points at `$XDG_RUNTIME_DIR/docker.sock`.
- If SSH files are still unreadable inside the sandbox, confirm the shell has not overridden `DOCKER_HOST` or Docker build arguments.
- If expected bind-mounted project or share files are still unreadable inside the sandbox, confirm the shell has not overridden `DOCKER_HOST` or Docker build arguments.

### Known limitations on Linux

Expand Down
13 changes: 8 additions & 5 deletions docs/en/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The default sandbox image also installs the agent-infra CLI npm package, exposin

The sandbox image also preinstalls `gh`. When `gh auth token` succeeds on the host, `ai sandbox create` injects the token into the container as `GH_TOKEN`, so `gh` commands work inside the sandbox without extra setup.

Host `~/.ssh` is not bind-mounted into the sandbox. GitHub access is expected to use the `gh` / HTTPS token path above; `git@github.com:*` SSH workflows need a separate, explicit setup outside the default sandbox boundary.

`ai sandbox rebuild` keeps Docker's build cache by default, so it quickly retags the sandbox image without refreshing every package. Use `ai sandbox rebuild --refresh` when you want to upgrade the image: it passes `--no-cache --pull` to Docker, pulls the current Ubuntu base image, and reruns the apt, tmux build, and global npm install layers. Claude Code updates are disabled inside the container, and OpenCode startup update checks are disabled; `--refresh` is the routine upgrade path for sandbox-managed tools. Manual `opencode upgrade` remains outside this guard. The default `python3` provided by the Ubuntu 24.04 sandbox base is Python 3.12, so scripts that hard-code Python 3.10 paths may need adjustment.

`ai sandbox exec` also forwards a small terminal-detection whitelist (`TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LC_TERMINAL`, `LC_TERMINAL_VERSION`) into the container. This keeps interactive TUIs aligned with the host terminal for behaviors such as Claude Code's Shift+Enter newline support, without passing through the full host environment.
Expand Down Expand Up @@ -50,8 +52,8 @@ to this, `--all` meant the full teardown that `--purge` now performs.
Use `ai sandbox prune --dry-run` to inspect orphaned per-branch state dirs left
behind by older versions or interrupted cleanup, then `ai sandbox prune` to
remove only dirs without an active sandbox container.
Existing sandboxes pick up these mounts after `ai sandbox rm <branch>` and
`ai sandbox create <branch>`.
Existing sandboxes pick up mount changes, including removed mounts, after
`ai sandbox rm <branch>` and `ai sandbox create <branch>`.

On first `ai sandbox create`, agent-infra writes a bilingual `README.md` into
`~/.agent-infra/share/<project>/common/` and each `branches/<branch>/`
Expand Down Expand Up @@ -108,15 +110,16 @@ target.
> inside the container, but the full preference tree is linked into every
> project sandbox. Do not place `.ssh/`, `.aws/credentials`, `.netrc`,
> `.gnupg/`, `.npmrc` files containing `_authToken`, AI tool OAuth/access token
> files, or `.gitconfig` there. Use the dedicated SSH and credential channels,
> and prefer `.gitconfig.local` with `[include]` for local Git preferences.
> files, or `.gitconfig` there. Use the dedicated credential channels; GitHub
> access uses the `gh` / HTTPS token path. Prefer `.gitconfig.local` with
> `[include]` for local Git preferences.

**Protected paths** are ignored by the hook even if they appear under
`~/.agent-infra/dotfiles/`:

| Path pattern | Reason |
|---|---|
| `.ssh/*` | Host SSH credentials are managed by the read-only SSH mount. |
| `.ssh/*` | Host SSH material is protected and is not imported through the default sandbox. |
| `.gnupg/*` | GPG private material is managed by `gpg-agent`. |
| `.claude/*`, `.codex/*`, `.gemini/*` | AI tool credentials use dedicated bind mounts. |
| `.config/opencode/*`, `.local/share/opencode/*` | OpenCode credentials and data use dedicated bind mounts. |
Expand Down
4 changes: 2 additions & 2 deletions docs/zh-CN/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ docker info

验证通过后,请把 `DOCKER_HOST` export 写入 shell 启动文件。

agent-infra 检测到 rootless Docker 后,会用 `HOST_UID=0` 和 `HOST_GID=0` 构建 sandbox 镜像。这样容器内 sandbox 用户可以读取 `~/.ssh` 等 bind mount,无需放宽宿主文件权限。在宿主侧,daemon 和容器进程仍以当前用户身份运行,不会获得宿主 root 权限。
agent-infra 检测到 rootless Docker 后,会用 `HOST_UID=0` 和 `HOST_GID=0` 构建 sandbox 镜像。这样容器内 sandbox 用户可以读取 worktree 和 `/share/*` 目录等普通 bind mount,无需放宽宿主文件权限。在宿主侧,daemon 和容器进程仍以当前用户身份运行,不会获得宿主 root 权限。

Rootless 模式的已知差异:

Expand All @@ -111,7 +111,7 @@ Rootless 模式的已知差异:
排障:

- 如果 `docker info` 失败,请检查 `systemctl --user status docker`,并确认 `DOCKER_HOST` 指向 `$XDG_RUNTIME_DIR/docker.sock`。
- 如果 sandbox 内仍无法读取 SSH 文件,请确认 shell 没有覆盖 `DOCKER_HOST` 或 Docker build args。
- 如果 sandbox 内仍无法读取预期的项目或 share bind mount 文件,请确认 shell 没有覆盖 `DOCKER_HOST` 或 Docker build args。

### Linux 已知限制

Expand Down
8 changes: 5 additions & 3 deletions docs/zh-CN/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

沙箱镜像也会预装 `gh`。如果宿主机上的 `gh auth token` 能成功返回 token,`ai sandbox create` 会把它以 `GH_TOKEN` 环境变量注入容器,让你在沙箱里直接使用 `gh`,无需额外登录配置。

宿主机 `~/.ssh` 不会 bind mount 到沙箱中。GitHub 访问默认走上面的 `gh` / HTTPS token 路径;`git@github.com:*` 这类 SSH workflow 需要在默认沙箱边界之外另行显式配置。

`ai sandbox rebuild` 默认保留 Docker build cache,因此会快速重打沙箱镜像,不会刷新每个软件包。需要升级镜像时使用 `ai sandbox rebuild --refresh`:它会向 Docker 传入 `--no-cache --pull`,重新拉取当前 Ubuntu 基础镜像,并重跑 apt、tmux 编译和全局 npm 安装层。容器内 Claude Code 更新已关闭,OpenCode 启动时更新检查也已关闭;`--refresh` 是沙箱托管工具的常规升级入口。手动 `opencode upgrade` 不受该保护覆盖。Ubuntu 24.04 沙箱基础镜像提供的默认 `python3` 是 Python 3.12,因此硬编码 Python 3.10 路径的脚本可能需要调整。

`ai sandbox exec` 也会向容器透传一小组终端检测白名单变量(`TERM_PROGRAM`、`TERM_PROGRAM_VERSION`、`LC_TERMINAL`、`LC_TERMINAL_VERSION`)。这样可以让交互式 TUI 保持与宿主终端一致的行为,例如 Claude Code 的 `Shift+Enter` 换行支持,同时避免把整个宿主环境灌入容器。
Expand All @@ -36,7 +38,7 @@

这两条路径硬编码,不暴露 `.airc.json` 配置项。首次 `create` 时会自动创建宿主目录;执行 `ai sandbox rm <branch>` 删除时会附带询问是否清理(默认 yes)。`ai sandbox rm --all` 批量删除所有**未绑定 active 任务**的沙箱(即 `ai sandbox ls` 中短号为 `-` 的行);可加 `--dry-run` 预览,或 `--yes` 跳过确认(非交互 shell 中必须显式传 `--yes`)。`ai sandbox rm --purge` 则拆除项目的**全部**沙箱(容器、worktree、镜像、VM)。**破坏性变更**:此前 `--all` 的语义即现在 `--purge` 的全量拆除。
可先用 `ai sandbox prune --dry-run` 查看旧版本或异常中断遗留的孤儿 per-branch 状态目录,再用 `ai sandbox prune` 只删除没有活跃 sandbox 容器对应的目录。
已有沙箱需要执行 `ai sandbox rm <branch>` 后再执行 `ai sandbox create <branch>`,才能加载新的挂载点
已有沙箱需要执行 `ai sandbox rm <branch>` 后再执行 `ai sandbox create <branch>`,才能加载挂载点变更,包括已移除的挂载

首次执行 `ai sandbox create` 时,agent-infra 会在
`~/.agent-infra/share/<project>/common/` 以及每个 `branches/<branch>/`
Expand Down Expand Up @@ -81,13 +83,13 @@ dotfiles 树解引用到

悬空符号链接会被跳过并在 stderr 输出警告。符号链接循环以及超过 32 层的深层目录也会被跳过并输出警告。指向 `$HOME` 之外的符号链接可以使用,只要 host 用户能读取目标。

> **不要往 `~/.agent-infra/dotfiles/` 放任何凭证。** 容器内是只读挂载,但整棵偏好树会链入所有项目沙箱。不要放 `.ssh/`、`.aws/credentials`、`.netrc`、`.gnupg/`、包含 `_authToken` 的 `.npmrc`、任何 AI 工具 OAuth/access token 文件,也不要放 `.gitconfig`。SSH 和工具凭证请使用专用通道;本地 Git 偏好建议用 `.gitconfig.local` 配合 `[include]`。
> **不要往 `~/.agent-infra/dotfiles/` 放任何凭证。** 容器内是只读挂载,但整棵偏好树会链入所有项目沙箱。不要放 `.ssh/`、`.aws/credentials`、`.netrc`、`.gnupg/`、包含 `_authToken` 的 `.npmrc`、任何 AI 工具 OAuth/access token 文件,也不要放 `.gitconfig`。请使用专用凭证通道;GitHub 访问走 `gh` / HTTPS token 路径。本地 Git 偏好建议用 `.gitconfig.local` 配合 `[include]`。

**受保护路径**即使出现在 `~/.agent-infra/dotfiles/` 下,也会被钩子忽略:

| 路径模式 | 原因 |
|---|---|
| `.ssh/*` | host SSH 凭证由只读 SSH 挂载管理。 |
| `.ssh/*` | host SSH 材料受保护,不会通过默认沙箱导入。 |
| `.gnupg/*` | GPG 私钥由 `gpg-agent` 管理。 |
| `.claude/*`, `.codex/*`, `.gemini/*` | AI 工具凭证使用专用 bind mount。 |
| `.config/opencode/*`, `.local/share/opencode/*` | OpenCode 凭证和数据使用专用 bind mount。 |
Expand Down
2 changes: 0 additions & 2 deletions lib/sandbox/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1579,8 +1579,6 @@ export async function create(args: string[]): Promise<void> {
path.join(effectiveConfig.repoRoot, '.git'),
`${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`
),
'-v',
volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
...dotfilesMount,
...toolVolumes,
...tmpfsArgs,
Expand Down
54 changes: 54 additions & 0 deletions tests/integration/cli/sandbox-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,60 @@ test("sandbox create warns and continues past missing Claude credentials", () =>
}
});

test("sandbox create does not mount the host ssh directory", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-sandbox-create-no-ssh-"));

try {
const fixture = writeSandboxEngineFixture(tmpDir, { project: "demo" });
fs.writeFileSync(path.join(fixture.repoDir, "README.md"), "# demo\n", "utf8");
const addResult = spawnSync("git", ["add", "README.md"], {
cwd: fixture.repoDir,
env: gitSafeEnv()
});
assert.equal(addResult.status, 0, addResult.stderr?.toString());
const commitResult = spawnSync("git", [
"-c",
"user.name=Sandbox Test",
"-c",
"user.email=sandbox-test@example.com",
"commit",
"-m",
"initial"
], {
cwd: fixture.repoDir,
env: gitSafeEnv()
});
assert.equal(commitResult.status, 0, commitResult.stderr?.toString());

const result = spawnSandboxCli(
fixture,
tmpDir,
["create", "feature/no-ssh-mount"],
{
AGENT_INFRA_CLAUDE_CREDENTIALS_FILE: path.join(tmpDir, "missing-claude-credentials.json"),
DOCKER_EXIT_FOR_RUN: "1"
}
);

assert.equal(result.signal, null);
assert.notEqual(result.status, 0);

const calls = fixture.readDockerCalls();
const runCall = calls.find((call) => call[0] === "run");
assert.ok(runCall, "expected sandbox create to invoke docker run");
assert.equal(
runCall.some((arg) => arg.includes("/home/devuser/.ssh")),
false,
"sandbox create must not mount host SSH material into the container"
);
assert.ok(runCall.some((arg) => arg.includes(":/workspace")));
assert.ok(runCall.some((arg) => arg.includes(":/share/common")));
assert.ok(runCall.some((arg) => arg.includes(":/share/branch")));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

test("assertBranchAvailable allows branches that are not checked out in any worktree", async () => {
const sandboxCreate = await loadFreshEsm<SandboxCreateModule>("lib/sandbox/commands/create.js");

Expand Down
8 changes: 4 additions & 4 deletions tests/integration/cli/sandbox-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,8 @@ test("volumeArg converts host mount paths for WSL2", async () => {
"/mnt/f/repo/.agents/workspace:/workspace/.agents/workspace"
);
assert.equal(
wsl2Paths.volumeArg("native", "/repo/.ssh", "/home/devuser/.ssh", ":ro"),
"/repo/.ssh:/home/devuser/.ssh:ro"
wsl2Paths.volumeArg("native", "/repo/.cache", "/home/devuser/.cache-demo", ":ro"),
"/repo/.cache:/home/devuser/.cache-demo:ro"
);
});

Expand Down Expand Up @@ -368,12 +368,12 @@ test("volumeArg adds shared selinux labels on native enforcing hosts", async ()
"/repo:/workspace:z"
);
assert.equal(
wsl2Paths.volumeArg("native", "/repo/.ssh", "/home/devuser/.ssh", ":ro", {
wsl2Paths.volumeArg("native", "/repo/.cache", "/home/devuser/.cache-demo", ":ro", {
platform: "linux",
fs: fsImpl,
env: {}
}),
"/repo/.ssh:/home/devuser/.ssh:ro,z"
"/repo/.cache:/home/devuser/.cache-demo:ro,z"
);
});

Expand Down
Loading