1. 从 Matt Pocock 的新玩具说起:Sandcastle 到底想解决什么问题
第一次看到 Sandcastle 这个名字,是在 Matt Pocock 的一条动态里。如果你长期混 TypeScript 圈子,对这个名字应该不陌生——Total TypeScript 的作者,TypeScript 类型体操领域最会讲课的人之一,社区里管他叫“类型魔法师”也不为过。他做的很多工具都带着强烈的个人风格:类型安全做到极致、API 设计追求“用起来像在写声明式代码”、文档和示例永远比源码还长。Sandcastle 这个项目延续了这套审美,但它的野心比一个类型工具大得多——它想解决的是AI 编码代理(AI Coding Agent)在真实项目里“乱跑”的问题。
先说清楚 Sandcastle 是什么。用一句话概括:它是一个用 TypeScript 编写的沙箱编排框架,专门用来给 AI 编码代理提供一个受控、可复现、可观测的执行环境。你可以把它理解成“给 AI 代理准备的虚拟机管理器”,但比传统虚拟机轻得多,也比单纯的容器封装更贴近开发者的日常心智模型。它不负责训练模型,也不负责生成代码,它负责的是:当 AI 代理要执行一段代码、跑一次测试、改一个文件、装一个依赖时,这些动作应该发生在哪里、以什么权限发生、发生之后怎么回滚、整个过程怎么记录下来。
为什么这件事值得单独做一个框架?因为过去一年里,几乎所有认真用 AI 代理写代码的人都踩过同一类坑。你让代理帮你重构一个模块,它顺手把package.json里的依赖版本全升了一遍;你让它跑个测试,它在你的主分支上直接git commit;你让它修一个 bug,它把.env文件读出来打印到日志里。这些不是模型“笨”,而是代理的执行边界没有被工程化地约束住。Sandcastle 的核心主张就是:代理的智能归模型,代理的边界归框架。模型可以天马行空,但落地执行必须经过沙箱这一层“海关”。
这个定位决定了它的目标用户不是“随便玩玩 AI 写代码”的人,而是那些已经把 AI 代理纳入日常开发流程、并且被它的不可控性折磨过的人。比如你在做一个基于 Vue3 + Three.js + TypeScript 的机房可视化项目,代理需要频繁改 shader、调场景参数、跑构建验证,一旦它把vite.config.ts改坏,整个项目就起不来。Sandcastle 要做的就是让这类操作被隔离、被记录、被限制。它适合的读者是:有 TypeScript 基础、用过至少一种 AI 编码工具、并且对“代理执行安全”这件事有真实痛感的开发者。如果你只是偶尔让 AI 补全几行代码,这个框架对你来说可能偏重;但如果你已经在搭代理工作流,它值得你花一个周末认真读一遍。
2. 沙箱编排框架的核心设计思路拆解
2.1 为什么是“编排”而不是“隔离”
很多人第一反应会问:沙箱不就是隔离吗,Docker 不就干这个的?这个理解只对了一半。传统沙箱解决的是“不让坏代码影响宿主”,而 Sandcastle 要解决的是“让代理的每一步操作都可被编排”。这两个目标的差别很大。隔离是被动的、静态的,你起一个容器,把代码丢进去,跑完销毁,完事。编排是主动的、动态的,它关心的是:这一步该不该执行、执行前要不要快照、执行后要不要验证、失败了怎么回退、成功了怎么合并。
我举个具体场景你就明白了。假设代理要执行一个“升级依赖并跑测试”的任务。纯隔离方案是:起容器、跑命令、看结果。而 Sandcastle 的编排方案是:先对package.json和 lock 文件做快照,然后在沙箱内执行升级,接着跑测试,如果测试通过则把变更“提升”回主工作区,如果失败则丢弃沙箱并恢复快照,整个过程生成一份结构化报告。你看,这里的关键不是“隔离得好不好”,而是“每一步都有明确的准入、验证、回滚策略”。这就是编排的含义——它把代理的一次任务拆解成一系列受控的阶段,每个阶段都有明确的边界和契约。
Matt Pocock 在类型工具上的经验在这里体现得很明显:Sandcastle 的编排策略是用 TypeScript 的类型系统来描述的。你可以定义一个任务流水线,每个阶段的输入输出类型都被约束住,阶段之间的数据传递如果类型不匹配,在编译期就会报错,而不是等到运行时才发现代理传了个乱七八糟的东西过来。这个设计思路很“Matt”——把运行时的不确定性尽量前移到编译期。
2.2 类型安全如何贯穿整个执行链路
这是 Sandcastle 最值得细看的部分。大多数沙箱工具的配置是 YAML 或者 JSON,写错了要到运行时才炸。Sandcastle 把整个配置和执行链路都做成了类型驱动的。具体来说,它有几个关键的类型抽象:
- TaskDefinition:描述一个代理任务,包含任务名、输入 schema、允许的操作集合、超时策略。
- SandboxPolicy:描述沙箱的权限边界,比如允许写哪些目录、允许执行哪些命令、网络访问白名单。
- StageContract:描述流水线中每个阶段的输入输出类型,阶段之间靠这个契约衔接。
- Artifact:描述阶段产出的可验证物,比如构建产物、测试报告、diff 补丁。
这些类型不是摆设。当你定义一个任务时,如果你在SandboxPolicy里声明只允许写src/目录,但某个阶段的StageContract输出类型里包含了要写config/的意图,类型检查会直接告诉你冲突。这种“策略即类型”的做法,好处是把安全约束从“运行时检查”变成了“编译期约束”。代理再聪明,也没法绕过编译器的检查。
我实测下来,这套类型系统对新手有一点门槛,但一旦你理解了它的心智模型,写配置的速度反而比 YAML 快,因为编辑器会给你完整的补全和错误提示。这跟 Matt 一贯的教学理念一致:类型不是为了炫技,是为了让错误在最早的地方暴露。
2.3 与常见方案的取舍对比
为了说清楚 Sandcastle 的定位,我把它和几种常见方案做了个对比。需要说明的是,这里的对比是基于我自己的使用经验和对框架设计的理解,不是官方结论。
| 方案 | 隔离粒度 | 编排能力 | 类型安全 | 回滚机制 | 适用场景 |
|---|---|---|---|---|---|
| 裸跑代理 | 无 | 无 | 无 | 手动 | 玩具项目 |
| Docker 容器 | 进程级 | 弱 | 无 | 镜像重建 | 一次性任务 |
| Git worktree | 文件级 | 中 | 无 | git 操作 | 分支隔离 |
| Sandcastle | 操作级 | 强 | 强 | 快照+契约 | 代理工作流 |
从表里能看出来,Sandcastle 的差异化在于“操作级隔离”和“强编排”。Git worktree 能隔离文件,但隔离不了命令执行和网络访问;Docker 能隔离进程,但编排能力弱,回滚成本高。Sandcastle 走的是中间路线:它不追求最强的隔离(那会牺牲速度),而是追求“隔离 + 编排 + 类型安全”三者的平衡。这个取舍很务实,因为代理工作流的特点是高频、短任务、需要快速反馈,如果每次任务都起一个完整容器,开发体验会很差。
3. 核心细节解析与实操要点
3.1 环境准备与依赖安装
Sandcastle 是纯 TypeScript 项目,运行在 Node 环境。我建议用 Node 20 LTS 以上,因为框架内部用到了较新的node:fs和node:child_processAPI。安装本身很简单:
npm install -D sandcastle # 或者 pnpm add -D sandcastle但这里有个坑我要提前说:Sandcastle 依赖一个“执行后端”来真正跑沙箱。它默认支持两种后端,一种是基于本地进程的轻量后端(适合开发阶段),一种是基于容器运行时的强隔离后端(适合 CI 和不可信任务)。开发阶段用轻量后端就够了,启动快、调试方便。但如果你要让代理执行来自外部的、不完全可信的代码,一定要切到强隔离后端。这个切换在配置里是一个字段的事,但很多人会忽略,直接用默认配置跑所有任务,这是有风险的。
提示:轻量后端和强隔离后端的 API 是一致的,所以你可以开发时用轻量、上线时切强隔离,代码不用改。但两者的行为有细微差别,比如文件系统快照的实现方式不同,建议在切换后重新跑一遍测试用例。
3.2 定义第一个受控任务
我拿一个真实场景来演示:让代理帮我升级一个依赖并验证。这个任务看起来简单,但涉及文件修改、命令执行、结果验证三个环节,正好能体现 Sandcastle 的编排能力。
第一步是定义任务。你需要声明任务的输入、允许的操作、以及流水线阶段。下面是一个简化后的示例结构:
import { defineTask, definePolicy, defineStage } from 'sandcastle'; const policy = definePolicy({ writablePaths: ['package.json', 'pnpm-lock.yaml'], allowedCommands: ['pnpm install', 'pnpm test'], network: { allow: ['registry.npmjs.org'] }, timeoutMs: 120_000, }); const task = defineTask({ name: 'upgrade-dependency', policy, stages: [ defineStage('snapshot', async (ctx) => { return ctx.snapshot(['package.json', 'pnpm-lock.yaml']); }), defineStage('upgrade', async (ctx) => { await ctx.exec('pnpm install some-package@latest'); }), defineStage('verify', async (ctx) => { const result = await ctx.exec('pnpm test'); return ctx.artifact('test-report', result); }), ], });这段代码里,policy是关键。它明确告诉框架:这个任务只能写两个文件、只能跑两条命令、只能访问 npm registry、最多跑两分钟。代理在这个任务里无论怎么“发挥”,都突破不了这个边界。这就是“操作级隔离”的实际含义——不是把整个环境封死,而是精确地划定代理能碰什么。
3.3 快照与回滚的实现细节
快照机制是 Sandcastle 编排能力的核心。它的实现思路不是对整个文件系统做镜像(那太慢),而是对“策略声明的可写路径”做内容快照。也就是说,只有writablePaths里列出的文件才会被快照,其他文件即使被代理意外修改,也不会进入回滚范围——但反过来说,这也意味着你的策略声明必须准确,漏掉一个可写路径,回滚就不完整。
快照的存储用的是内容寻址的方式,相同内容只存一份,所以即使你跑很多次任务,磁盘占用也不会爆炸。回滚的时候,框架会把快照内容写回原路径,并清理掉任务期间新增的文件。这里有个细节值得注意:回滚是“恢复到快照时刻”,不是“撤销变更”。如果你在任务开始前手动改了文件但没提交,快照会包含你的改动,回滚后你的改动还在。这个行为是合理的,但新手容易误解,以为回滚会回到 git 的某个状态。
注意:快照不包含 git 历史。如果你的任务涉及 git 操作(比如代理自己 commit),回滚文件内容不会撤销 commit。所以策略里最好禁止代理执行 git 写操作,或者把 git 操作也纳入编排阶段单独处理。
3.4 阶段契约与数据传递
阶段之间的数据传递是很多人写流水线时容易乱的地方。Sandcastle 用StageContract来约束这个传递。每个阶段声明自己的输入类型和输出类型,框架在运行时校验实际数据是否符合契约。如果不符合,任务会在该阶段失败,而不是把脏数据传给下一个阶段。
这个设计的好处是“失败点前移”。假设你的“升级”阶段输出了一个非预期的结构,而“验证”阶段期望的是标准结构,契约校验会在阶段切换时立刻报错,你能马上定位到是升级阶段的问题,而不是等到验证阶段跑出一堆莫名其妙的错误再去回溯。我在实际项目里把每个阶段的输出都定义得比较严格,虽然写的时候多花几分钟,但排查问题时省的时间远不止这几分钟。
4. 实操过程与核心环节实现
4.1 完整流程:从任务定义到结果合并
我把上面那个依赖升级任务完整跑一遍,把每个环节的实际操作和观察记录下来。整个流程分六步:任务注册、策略校验、沙箱启动、阶段执行、结果验证、变更合并。
任务注册阶段,框架会先做一次静态检查,确认任务定义里的策略和阶段契约没有类型冲突。这一步是编译期行为,如果你用tsc或者编辑器的类型检查,在写代码的时候就能看到错误。我建议把任务定义放在独立的.ts文件里,让类型检查覆盖到。
策略校验阶段,框架会检查策略本身是否自洽。比如你声明了allowedCommands里有pnpm test,但writablePaths里没有测试可能产生的快照目录,框架会给出警告。这个校验不是强制的,但强烈建议开启,它能帮你发现策略里的逻辑漏洞。
沙箱启动阶段,轻量后端会创建一个隔离的工作目录,把策略声明的可写路径复制进去,并设置好命令执行的环境变量。这个过程通常在几百毫秒内完成,比起容器快得多。启动后,框架会记录一个沙箱 ID,后续所有操作都关联到这个 ID。
阶段执行阶段,每个阶段按顺序跑。我实测下来,snapshot阶段几乎瞬间完成,upgrade阶段取决于网络和包管理器,verify阶段取决于测试套件。整个任务在我的机器上跑了大约 40 秒,其中大部分时间花在pnpm install上。框架会在每个阶段结束时输出一份阶段报告,包含耗时、退出码、标准输出摘要。
结果验证阶段,框架会检查所有声明的 artifact 是否产出。如果某个阶段声明了要产出test-report但实际没产出,任务会被标记为“未完成”。这个机制能防止代理“假装成功”——它必须真的产出可验证的东西。
变更合并阶段,如果所有阶段都成功,框架会把沙箱内的变更“提升”回主工作区。提升的方式是逐文件对比,只写回真正变化的文件。如果任何阶段失败,框架会触发回滚,把主工作区恢复到任务开始前的状态。这个“全成功才合并,任一失败就回滚”的策略是默认行为,你也可以配置成“部分成功也合并”,但我不建议,因为那会让状态变得难以预测。
4.2 参数选择与超时策略的计算
超时策略看起来是个小配置,但设不好会直接影响体验。设太短,正常任务被误杀;设太长,卡住的任务浪费资源。我的经验是按阶段分别设置超时,而不是给整个任务一个总超时。因为不同阶段的耗时特征差别很大:快照是毫秒级,安装是十秒到分钟级,测试是秒到分钟级。
具体怎么算?我的做法是:先跑几次任务,记录每个阶段的 P95 耗时,然后在这个基础上乘以 2 作为超时值。比如upgrade阶段 P95 是 30 秒,那超时设 60 秒。这样既能容忍网络波动,又不会让卡死的任务拖太久。Sandcastle 支持在阶段级别覆盖策略级别的超时,这个粒度很实用。
网络访问的白名单也要仔细配。默认情况下,我建议只允许包管理器的 registry 域名,其他一律禁止。代理如果尝试访问其他域名,会被框架拦截并记录。这个记录很有价值,它能告诉你代理在“想”做什么——有时候代理会尝试访问一些你根本没预期的服务,这些尝试本身就是信号。
4.3 与 AI 编码代理的对接方式
Sandcastle 本身不绑定任何特定的 AI 代理,它提供的是一个执行层。对接方式通常有两种:一种是把 Sandcastle 作为代理的“工具执行器”,代理生成的操作请求先经过 Sandcastle 的策略校验再执行;另一种是把 Sandcastle 作为“任务运行器”,你手动或通过脚本触发任务,代理只在任务内部生成代码。
我两种都试过。第一种更自动化,但对代理的集成度要求高,需要代理支持自定义工具执行器。第二种更可控,适合把代理纳入现有 CI 流程。如果你用的是支持工具调用的代理框架,第一种体验更好,因为代理能实时得到沙箱的反馈并调整行为。如果你只是想给代理加一层保护,第二种改动最小。
提示:无论哪种对接方式,都建议把 Sandcastle 的任务报告接入你的日志系统。报告里包含每个阶段的详细记录,出问题时这是第一手排查材料。
5. 常见问题与排查技巧实录
5.1 任务失败但看不出原因怎么办
这是最常见的问题。代理任务失败,报告里只有一句“stage verify failed”,没有更多信息。我的排查顺序是这样的:先看阶段报告里的退出码和标准错误,大部分问题在这里就能定位;如果标准错误是空的,检查 artifact 是否产出,有时候是产出路径写错了;如果还找不到,把策略里的allowedCommands临时放宽,手动在沙箱目录里跑一遍命令,看真实报错。
这里有个技巧:Sandcastle 支持“保留失败沙箱”选项。开启后,任务失败时沙箱不会被销毁,你可以进去手动检查。这个选项在开发阶段非常有用,但记得在 CI 里关掉,否则失败任务会堆积沙箱占磁盘。
5.2 回滚不完整或回滚后状态异常
回滚出问题,九成是策略声明不完整。代理修改了某个文件,但那个文件不在writablePaths里,快照就没覆盖它,回滚自然恢复不了。排查方法是:对比任务前后的文件系统差异,找出所有被修改但不在策略里的路径,把它们补进策略。如果某个路径你不想让代理写,那就应该在策略里明确禁止,而不是靠“没声明”来隐式禁止——因为隐式禁止不会阻止写入,只会让回滚失效。
另一个可能是快照时机不对。如果你的任务在快照之前就有其他进程在改文件,快照会包含那些改动,回滚后它们还在。这种情况要确保任务执行期间没有其他写操作。
5.3 类型报错但运行时正常
这是 TypeScript 项目常见的困惑。Sandcastle 的类型系统比较严格,有时候你的代码逻辑上没问题,但类型不匹配。遇到这种情况,先别急着用as any绕过,仔细看类型报错的位置。大多数时候是阶段契约的输入输出类型定义得太窄,或者策略类型和实际使用有偏差。修正类型定义往往能发现潜在的逻辑问题。
我踩过的一个坑是:阶段输出用了unknown类型,下游阶段期望具体类型,类型检查报错。我一开始想直接断言,后来发现是上游阶段确实可能输出非预期结构,修正上游的产出逻辑后,类型自然就匹配了。这个经历让我更信任这套类型系统——它报错的地方,往往真的有隐患。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 任务超时 | 超时设置过短 | 查看阶段耗时报告 | 按 P95 的 2 倍调整 |
| 回滚不完整 | 策略遗漏可写路径 | 对比任务前后文件差异 | 补全 writablePaths |
| 网络被拦截 | 白名单未包含目标域名 | 查看拦截日志 | 按需添加域名 |
| 类型报错 | 契约类型不匹配 | 检查阶段输入输出定义 | 修正类型而非断言 |
| 沙箱启动慢 | 用了强隔离后端 | 查看后端配置 | 开发阶段切轻量后端 |
| artifact 缺失 | 产出路径错误 | 检查阶段产出逻辑 | 修正路径或产出方式 |
5.5 几个我踩过的坑和独家心得
第一个坑是策略写得太宽。我一开始图省事,把writablePaths设成整个src/,结果代理在里面乱改,回滚虽然能恢复,但每次任务都产生大量 diff,审查成本很高。后来我把策略收窄到具体文件,代理的行为反而更聚焦了。这让我意识到,策略不只是安全边界,也是给代理的“行为提示”——你限制得越精确,代理越不容易跑偏。
第二个坑是忽略阶段报告的细节。阶段报告里有个字段叫“命令执行轨迹”,记录了每条命令的完整参数和环境变量。我一开始不看这个,后来发现代理有时候会传一些奇怪的参数,比如给pnpm install加--force,这个参数不在白名单里但被 shell 解析成了别的东西。看轨迹才发现问题。现在我养成了定期审查轨迹的习惯。
第三个心得是把 Sandcastle 的任务定义当成文档来写。因为它是类型化的、结构清晰的,一个任务定义读起来就像一份操作说明书。我把项目里所有代理任务都定义在tasks/目录下,新人接手时读这些文件就能理解代理能做什么、不能做什么。这比写一堆 Markdown 文档管用得多,因为文档会过时,类型定义不会——它过不了编译。
6. 这套框架适合什么样的项目,以及后续可以怎么扩展
聊到这里,我想说说适用边界。Sandcastle 不是银弹,它解决的是“代理执行可控性”问题,不解决“代理生成质量”问题。如果你的代理本身生成质量就不行,加再多沙箱也白搭。它最适合的场景是:代理已经能完成大部分任务,但偶尔会“手滑”造成破坏,你需要一层工程化的保险。这种场景下,它的价值立竿见影。
对于小型项目或者个人玩具项目,它的配置成本可能偏高。但如果你在做团队协作的项目,尤其是那种多人共用一套代理工作流的场景,它的价值会被放大——因为策略定义是共享的,所有人都用同一套边界,行为一致性有保障。我见过一些团队用口头约定来约束代理行为,效果很差,因为约定没法强制执行。Sandcastle 把约定变成了代码,这是本质区别。
后续扩展方面,我觉得有几个方向值得关注。一是策略的“继承与组合”,让不同任务可以复用基础策略再叠加差异,减少重复定义。二是任务报告的“可视化”,现在报告是结构化的 JSON,如果能生成直观的执行时间线和 diff 视图,审查效率会更高。三是和更多代理框架的原生集成,降低对接成本。这些方向不一定都由官方做,社区完全可以基于现有 API 扩展。
我个人在实际操作中的体会是:这类框架的价值不在于它帮你省了多少事,而在于它帮你避免了多少“事后救火”。代理跑得越快,出错的代价就越高,边界就越重要。Sandcastle 把边界做成了类型、做成了契约、做成了可回滚的快照,这套组合拳打下来,代理工作流才算真正能上生产。如果你正在被代理的不可控性困扰,花点时间读它的类型定义,比读十篇教程都有用。