☰
Sandcastle 自定义基础镜像:为什么放弃抽象层,让用户直接拥有 Dockerfile
2026/9/26 15:40:24 网站建设 项目流程

【免费下载链接】sandcastle

Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()

项目地址:https://gitcode.com/gh_mirrors/sandcastl/sandcastle
点击查看免费下载

导读:Sandcastle 是一个用 TypeScript 编排沙箱化编码 Agent 的库(sandcastle.run()),在init脚手架与镜像定制这件事上,它做出了一个明确的反向决策:不提供任何用于"程序化组合 Dockerfile / 管理基础镜像"的抽象层(如ISandboxEnvironment/IAgentHarness接口),而是把.sandcastle/Dockerfile直接 scaffold 进用户项目并完全交给用户所有。读完本文你将掌握:这个决策背后的四条理由与"控制反转"原则、sandcastle init实际生成 Dockerfile 的源码机制、内置镜像模板的结构与 UID/GID 构建参数,以及在不借助抽象层的前提下自定义基础镜像的完整实操路径。

决策核心:不做镜像抽象层,做"可编辑的脚手架"

custom-base-image-abstraction.md记录的是 Sandcastle 项目早期(对应 issue#283)一次被否决的提案。提案设想为沙箱环境与 Agent 运行时各引入一层程序化接口(ISandboxEnvironment/IAgentHarness),以便在代码里组合 Dockerfile、统一管理基础镜像。最终结论是:

Sandcastle 不提供用于组合 Dockerfile 或程序化管理基础镜像的抽象层。

取而代之的模型非常简单:sandcastle init把一份完整、可运行、带注释的 Dockerfile 模板写进用户项目的.sandcastle/目录,从那一刻起它就是"用户自己的文件"——用户可以直接改基础镜像、增删系统包、调整目录结构、替换ENTRYPOINT,想怎么改就怎么改。Sandcastle 的职责止步于"给出一个能工作的起点",绝不越界去猜测用户的技术栈。

四条理由:为什么"直接改 Dockerfile"优于抽象层

理由一:Dockerfile 在 init 时被 scaffold,之后完全归用户所有

src/InitService.ts的scaffold()函数完整实现了这一模型(InitService.ts):

  1. 在项目根目录创建.sandcastle/配置目录(若已存在则直接报错,防止覆盖用户的定制成果);
  2. 根据用户选择的 Agent(claude-code、pi、codex、cursor、opencode、copilot)写入对应的 Dockerfile 模板;
  3. 根据用户选择的沙箱 provider(Docker / Podman)决定文件名是Dockerfile还是Containerfile;
  4. 同时生成.env.example(Agent + issue tracker 的 token 占位符)、.gitignore和模板文件(prompt.md、main.mts/main.ts)。

关键点在于:init 只写入一次,之后 Sandcastle 不再触碰、不覆盖、不"同步"这份 Dockerfile。任何镜像层面的需求(换FROM基础镜像、预装语言运行时、写入 CA 证书、定制USER)都通过直接编辑这份文件完成,改动即刻生效于下一次sandcastle docker build-image。这正是文档所说的"fully user-owned from that point"。

理由二:抽象层为"已经能做好的事"引入了复杂度

提案中的ISandboxEnvironment/IAgentHarness接口本质上是把 Dockerfile 的能力重新建模成一组编程接口。文档的判断是:这层抽象"为一件直接编辑 Dockerfile 就能完成的事增加了复杂度"。

复杂度并非只多不少。一旦引入抽象,就需要定义:

  • 基础镜像如何表示(字符串?对象?解析后的指令树?);
  • 追加系统包与运行时如何表达(方法链?声明式配置?);
  • 与现有 Dockerfile 语法(多阶段构建、ARG、COPY --from)如何对齐;
  • 抽象产生的配置如何再落回真实的 Dockerfile 文本。

而从源码角度看,Sandcastle 已经为镜像定制保留了最自然的入口:镜像模板本身就是普通的 Dockerfile 文本,存放在 InitService.ts 的CLAUDE_CODE_DOCKERFILE、PI_DOCKERFILE、CODEX_DOCKERFILE、CURSOR_DOCKERFILE、OPENCODE_DOCKERFILE、COPILOT_DOCKERFILE常量中,并在 scaffold 时原样写入。用户编辑文件,走的就是与模板作者相同的路径——不存在两套模型需要同步。

理由三:Docker 只是多个沙箱 provider 之一,组合系统会过度耦合 init 层

决策文档特别点出:Sandcastle 不只支持 Docker,还支持Daytona与E2B等其他沙箱 provider。从当前仓库 src/sandboxes/ 目录也能看到这一多 provider 格局:docker.ts、podman.ts、daytona.ts、vercel.ts、no-sandbox.ts并列存在,而 SandboxProvider.ts 用tag判别联合("bind-mount"/"isolated"/"none")统一了三种 provider 形态:

  • Bind-mount(Docker、Podman):把宿主机 worktree 目录挂载进容器,Agent 直接写宿主文件系统;
  • Isolated(Vercel、Daytona 类):沙箱与宿主机隔离,通过copyIn/copyFileOut传输文件;
  • None(noSandbox()):直接在宿主机上运行,无容器隔离。

如果 init 层内置一套"Dockerfile 组合系统",它天然假设了"镜像构建"是所有 provider 的公共能力——但 Vercel 的 Firecracker 微 VM、Daytona 的环境供给方式与 Docker 的docker build完全不同。文档的判断很直接:把 Dockerfile 组合系统焊进 init 层,等于把 init 层与 Docker 这一个 provider 紧紧绑死,破坏 provider 的可插拔性。所以镜像定制被刻意保留为"provider 相关的文件编辑",而不是跨 provider 的统一抽象。

理由四:init 的职责是"可工作的起点",不是"覆盖所有技术栈"

init脚手架的目标是让用户尽快跑起来:模板 Dockerfile 预装了git、curl、jq和对应的 Agent CLI(见下文模板解析),足以支撑开箱即用;而用户项目的真实技术栈(Node 版本、Python 环境、数据库客户端、私有源……)千差万别,任何"覆盖所有可能性"的抽象尝试都会变成一份又厚又脆的配置面。因此文档给出的边界是:init 只负责给出一份能工作的起点,剩下的交给用户。

源码级解析:内置 Dockerfile 模板长什么样

以CLAUDE_CODE_DOCKERFILE(InitService.ts)为例,模板的结构完整展示了"可编辑脚手架"的设计:

FROM node:22-bookworm # Install system dependencies RUN apt-get update && apt-get install -y \ git \ curl \ jq \ && rm -rf /var/lib/apt/lists/* {{ISSUE_TRACKER_TOOLS}} # Build-args for UID/GID alignment: sandcastle docker build-image # defaults these to the host user's UID/GID so image-built files # and bind-mounted files share an owner without runtime chown. ARG AGENT_UID=1000 ARG AGENT_GID=1000 # Rename the base image's "node" user to "agent" and align UID/GID. RUN groupmod -o -g $AGENT_GID node && usermod -o -u $AGENT_UID -g $AGENT_GID -d /home/agent -m -l agent node USER ${AGENT_UID}:${AGENT_GID} # Install Claude Code CLI RUN curl -fsSL https://claude.ai/install.sh | bash # Add Claude to PATH ENV PATH="/home/agent/.local/bin:$PATH" WORKDIR /home/agent # In worktree sandbox mode, Sandcastle bind-mounts the git worktree at ${SANDBOX_REPO_DIR} # and overrides the working directory to ${SANDBOX_REPO_DIR} at container start. # Structure your Dockerfile so that ${SANDBOX_REPO_DIR} can serve as the project root. ENTRYPOINT ["sleep", "infinity"]

几个值得注意的设计点:

  • {{ISSUE_TRACKER_TOOLS}}是模板占位符:scaffold 时由 InitService.ts 的substituteTemplateArgs()替换为所选 issue tracker 的 CLI 安装步骤(GitHub CLI 或 Beads 工具链)。也就是说,模板文件在 init 阶段被"当场物化"成最终文本,用户拿到手的是完全展开、可逐行理解的 Dockerfile——没有任何运行时动态拼接。
  • ARG AGENT_UID/AGENT_GID与 UID 对齐:sandcastle docker build-image在 Linux/macOS 上默认把这两个构建参数设为宿主机用户的 UID/GID(process.getuid()/process.getgid()),配合groupmod -o/usermod -o(--non-unique)把镜像内node用户改名为agent并对齐到宿主 UID/GID。这样镜像构建产物与 bind-mount 进来的 worktree 文件同属一个 owner,避免运行时chown(ADR-0014,docs/adr/0014-docker-uid-alignment-via-build-arg.md)。USER指令使用数字形式${AGENT_UID}:${AGENT_GID},便于运行时 pre-flight 用docker image inspect --format '{{.Config.User}}'校验镜像内 UID 与实际执行 UID 是否一致(docker.ts 中的containerUid/containerGid选项)。
  • ENTRYPOINT ["sleep", "infinity"]+ 注释:模板用睡眠进程保持容器存活,Sandcastle 再通过exec()在容器内执行命令;注释明确告知用户在 worktree 模式下SANDBOX_REPO_DIR会被 bind-mount 并作为工作目录,提醒用户"把你的 Dockerfile 结构设计得让该目录能充当项目根目录"——这是模板把关键运行约束直接写进用户可见文件的做法,而非藏在抽象层里。

实战:不借助抽象层自定义基础镜像的完整路径

理解了"文件归用户所有"后,自定义基础镜像就是纯文件编辑 + 重建镜像的流程:

  1. 运行npx @ai-hero/sandcastle init生成.sandcastle/目录(Docker 场景下包含Dockerfile;选择 Podman 则生成Containerfile)。所有交互提示都有对应的--flag(--agent、--model、--sandbox、--template、--issue-tracker、--build-image等),因此整个 init 可以在 CI 中非交互执行。
  2. 编辑.sandcastle/Dockerfile:更换FROM基础镜像、追加RUN apt-get install系统包、切换 Node 运行时版本、加入COPY证书/私有源配置等。注意保持模板末尾的ENTRYPOINT ["sleep", "infinity"]与 UID/GID 对齐段,除非你明确知道自己在做什么。
  3. 重建镜像:sandcastle docker build-image(Podman 对应sandcastle podman build-image)。该命令从现有.sandcastle/目录重建镜像,支持--image-name与--dockerfile参数(指定自定义 Dockerfile 时构建上下文为当前工作目录)。在 Linux/macOS 上它自动传入宿主机 UID/GID 作为AGENT_UID/AGENT_GID构建参数。
  4. 运行验证:npx tsx .sandcastle/main.ts(或main.mts,取决于项目package.json是否声明"type": "module")按 blank 模板 的方式调用run()启动 Agent。

若你手上有自己维护的镜像(非模板构建),无需重建即可通过docker({ containerUid: <uid>, containerGid: <gid> })声明镜像内已烘焙的 UID/GID;pre-flight 检查发现不匹配时会明确提示两种补救方案(重建镜像,或传containerUid对齐镜像),见 docker.ts 与 ADR-0014。

这套路径的关键优势在于:每一步改动都是对一份普通 Dockerfile 的普通编辑,没有需要学习的新 DSL、没有与底层文件保持同步的抽象配置、没有版本漂移问题。用户最终拥有的是一个完全可控、可读、可 grep、可 review 的文件。

控制反转原则:脚手架给默认值,用户拥有结果

决策文档最后提炼的原则是整个设计的锚点:

控制权向用户反转(Control is inverted towards the user)。Sandcastle 脚手架出一个合理的默认值,用户拥有最终结果。

这与 Sandcastle 的总体定位一脉相承:prompt 体系(README.md)同样"不施加任何关于工作流、任务管理或上下文来源的意见",镜像定制则更进一步——连"配置的载体"都直接选用用户最熟悉的 Dockerfile 本身。放弃ISandboxEnvironment/IAgentHarness抽象层,换来的是更低的认知负担、更强的可移植性(不绑定 Docker)和永不落后的定制能力(Dockerfile 语法演进,Sandcastle 无需跟随)。

边界说明

  • 该提案对应 issue#283,在.out-of-scope/目录中归档为"被否决的范围外提案"(custom-base-image-abstraction.md)。同目录还归档了docker-provider-bespoke-options.md等相邻边界决策,可一并阅读了解 Sandcastle 的取舍脉络。
  • 决策文档提及的 provider 集合(Docker、Daytona、E2B)是当时(#283 时期)的表述;就当前仓库而言,src/sandboxes/ 目录实际包含docker、podman、daytona、vercel、no-sandbox等 provider 实现,README 中列出的内置选项为 Docker、Podman、Vercel 与 no-sandbox。无论 provider 集合如何演进,"镜像定制不进入抽象层、只通过用户拥有的 Dockerfile 完成"这一决策边界始终保持不变。
  • 一个自然的推论:如果你需要"多个项目共享同一份定制镜像",最佳做法也不是引入抽象层,而是维护一份自己的基础镜像(或构建脚本),然后在各项目的.sandcastle/Dockerfile中把FROM指向它——控制权始终留在用户一侧。

<输出文章>

【免费下载链接】sandcastle

Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()

项目地址:https://gitcode.com/gh_mirrors/sandcastl/sandcastle
点击查看免费下载

相关推荐

上一篇:终极指南:如何用Python自动化工具轻松搞定B站会员购抢票难题
下一篇:终极修复指南:解决RetroArch UWP版Slang着色器失效问题,从编译错误到完美渲染

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询