首页
/ opencode CodeMode:受限代码执行引擎的设计边界与实现原则

opencode CodeMode:受限代码执行引擎的设计边界与实现原则

2026-09-06 14:39:44作者:虞亚竹Luna

本文围绕 @opencode-ai/codemode 包的设计约束文档 packages/codemode/AGENTS.md 展开,系统讲解这个受限执行引擎的四个核心授权原则、OpenAPI 适配器的五条设计红线,以及面向未来能力扩展(输出通道、错误分类、二进制边界、宿主能力)的设计笔记。结合 README设计文档 与源码实现,读者将理解"模型生成代码"在 opencode 中如何被安全地限定在宿主显式提供的工具树内运行,以及如何在自研 Agent 中正确地为它设置边界。

包定位:谁拥有什么权力

packages/codemode/AGENTS.md 开篇即给出本包的四条总纲,它们是理解 CodeMode 所有 API 设计的前提:

  1. 本包只拥有"对显式 schema 描述工具的受限执行"(confined execution over explicit schema-described tools)。认证、授权、持久化、外部权限、工具特定投递语义全部归宿主应用所有。
  2. 不要添加臆测性的通用权限/审批策略。宿主应通过"不暴露某工具"来表达限制,并在每个提供的工具内部执行领域授权,而不是依赖 CodeMode 提供一层通用审批。
  3. 保持 CodeMode 对宿主会话、频道、会话模型无知。由宿主应用在它外围提供可信的执行作用域。
  4. 工具 schema 就是面向模型的接口。参数应保持最小且贴合操作本身,绝不要塞入无关 ID 当作"环境能力令牌"(ambient capability token)使用。

这些原则在 README 的 Authority Boundary 一节 中被展开为一张责任对照表:宿主负责认证与授权、工具选择与不可变作用域、凭据与网络客户端、持久化/幂等/审批/持久副作用、日志与脱敏策略;CodeMode 只负责不用 eval 地解析并解释执行受支持子集、工具调用周围的 schema 边界、纯数据复制与原型成员屏蔽、资源限制与调用计数、面向模型的工具发现与指令。README 还特别强调了一条反直觉但关键的结论:"程序不能通过散文或生成的代码获得权力,它只能行使所提供的工具中已存在的权力。不要暴露宽泛工具再指望提示词去限制它。"

从源码结构看,这个定位是"硬编码"的而非口号。入口 src/index.ts 只导出 CodeModeToolOpenAPI 命名空间与 ToolError/toolError——没有任何会话、上下文或权限服务的抽象泄漏进公共 API。工具的唯一定义入口 Tool.make 接收 descriptioninput、可选 outputrun 四个字段,run 是一个返回 Effect 的函数,其类型参数 R 允许工具声明自己的服务需求(如 HttpClient.HttpClient),CodeMode 会原样透传这些服务需求而不去"替宿主决策":

const lookupOrder = Tool.make({
  description: "Look up an order by ID",
  input: Schema.Struct({ id: Schema.String }),
  output: Schema.Struct({ id: Schema.String, status: Schema.String }),
  run: ({ id }) => Effect.succeed({ id, status: "open" }),
})

const runtime = CodeMode.make({ tools: { orders: { lookup: lookupOrder } } })
const result =
  yield * runtime.execute(`
  const order = await tools.orders.lookup({ id: "order_42" })
  return { id: order.id, needsAttention: order.status !== "complete" }
`)

程序里通过 tools.<namespace>.<tool>(...) 调用工具,能拿到的就是宿主放入这棵对象树的东西——这就是第 1 条原则的最小实现形态。

OpenAPI 适配器的五条设计红线

AGENTS.md 的 ## OpenAPI 小节把 OpenAPI 适配器(把 OpenAPI 3.x 文档转成 CodeMode 工具子树)的纪律浓缩为五条规则,每一条都对应一类真实事故模式:

  1. 只有传输语义被支持时才生成操作,否则返回精确的 skipped 原因。src/openapi/types.tsSkipped 类型被定义为 { method, path, reason }fromSpec 同步返回 { tools, skipped }——被跳过的操作不是静默消失,而是携带可审计的理由。
  2. 绝不猜测参数序列化或畸形的 security 语义。不支持的序列化走 skipped,畸形的安全定义则"fail closed"(直接失败而非降级)。
  3. 无法解析的 schema 构造渲染为 unknown,绝不发明 TypeScript 名字——避免模型基于虚构类型生成错误调用。
  4. 网络读取必须有界,并把预期的编码、传输、解码失败映射为对模型安全的 ToolError 值。
  5. 测试直接覆盖支持的行为,不要在测试中复刻适配器算法本身——防止测试与实现"同步腐烂"。

README 的 OpenAPI 工具一节 给出了这些规则下的实际能力面:OpenAPI.fromSpec({ spec, auth, baseUrl?, headers? }) 接受已解析的 JSON 文档(YAML 需宿主自行解析),一个 operation 生成一个工具;带点的 operationId(如 v2.session.get)形成命名空间,缺失 ID 时回退到 getUsersById 这类 method/path 扁平命名并做去重。首个适配器支持 query 的 form/deepObject、path/header 的 simple 风格、JSON 请求体、JSON 与文本响应;不支持的参数编码、非 JSON 请求体、二进制响应、流式操作全部落入 skipped。操作级与路径级 server 优先于文档级 server,除非 baseUrl 显式覆盖全部。

认证语义值得单独展开。openapi/types.ts 中凭据材料是宿主解析器在调用时通过 auth.resolve 提供的联合类型(bearer / basic / apiKey / header),解析器返回 undefined 表示"该方案不可用、尝试下一个 OR 备选",而解析失败会中止调用而不是静默跳过;cookie 认证的备选被直接丢弃,某操作若无任何受支持的备选则整体进入 skipped。这正体现了红线第 2 条:"认证绝不进入模型可见面",凭据存储、OAuth 流程、令牌刷新永远留在编译器之外。响应限制在 50 MiB,非 2xx 响应变成携带状态码与截断 body 摘要的安全工具失败;生成的工具在执行期需要 HttpClient.HttpClient(来自 effect/unstable/http),宿主提供 FetchHttpClient.layer 或自定义测试客户端,重定向策略归客户端所有——带凭据的宿主应当拒绝重定向或在源变化时剥离凭据。未完成的适配能力记录在 src/openapi/TODO.md

import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"

const api = OpenAPI.fromSpec({
  spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
  auth: {
    resolve: ({ name, scopes, operation }) =>
      name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
  },
})

const runtime = CodeMode.make({ tools: { opencode: api.tools } })
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))

未来设计笔记:六条尚未动工的设计储备

AGENTS.md 的 ## Future Design Notes 是文档中最容易被忽视、但最能反映设计者思考深度的部分。它们不是待办清单,而是"如果将来要做 X,必须遵守的前提":

  • 输出通道(output channel):若未来恢复用户可见的输出通道(早期 output.text/output.file/output.image API 已在 v1 中移除),必须保持 output 这个名字,并与程序返回值严格区分:return 永远是给模型的结构化结果,output.* 描述宿主在执行后可能渲染进会话或 UI 的产物;保持宿主中立,v1 的做法是宿主在沙箱外自行收集媒体。这与 codemode.md 中"文件与附件内容留在解释器之外,宿主可在子工具执行期间收集并挂到外层结果"的现状一致。
  • 改进沙箱失败分类:区分解析/编译错误、不支持语法、用户抛出错误、非法返回数据、工具拒绝、工具内部故障、超时与真正的运行时缺陷,让 Agent 能精确恢复,而不是把所有东西都当成泛化执行失败。当前 DiagnosticKind 已经实现了十个稳定类别(ParseErrorUnsupportedSyntaxUnknownToolInvalidToolInputInvalidToolOutputInvalidDataValueToolCallLimitExceededTimeoutExceededToolFailureExecutionFailure),该笔记指向进一步细化的方向。
  • 保持公共/私有错误分割:工具作者应能返回一条对模型安全的可见消息,同时保留一个私有 cause 供宿主诊断;未知宿主故障必须默认被消毒。这正是 toolError 的实现契约——toolError(message, cause?) 创建的 ToolError 中只有 message 是模型可见的,cause 永远不会出现在 CodeMode.Result 里:
import { toolError } from "@opencode-ai/codemode"

run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
  • 二进制边界要审慎设计:在允许 BlobFileArrayBuffer、流或类型化数组越过边界之前,先用显式的 tagged 数据形态和清晰的尺寸限制,而不是依赖宿主运行时的环境序列化。当前程序结果与工具参数是 JSON 风格数据:Date 在宿主边界变成 ISO 字符串、RegExp/Map/Set/URLSearchParams 变成 {}(与 JSON.stringify 一致),Promise 值永远不能跨边界——未 await 的 promise 出现在结果或工具参数里会得到一条"请 await 它"的诊断。
  • 宿主能力必须显式化fetchcrypto、文件句柄、额外模块或网络客户端应当是带明显策略默认值的 opt-in 运行时能力,而不是环境权力;默认不可用,除非宿主刻意提供。
  • fetch 若落地则建模为宿主提供的出站能力,带策略控制:允许的源、方法、头、响应大小、超时,以及响应体是返回、发出还是仅通过工具摘要。

设计文档中的 Intentionally Unsupported 一节 与这些笔记互为镜像:环境文件系统、进程、环境、网络、凭据或应用访问;模块、动态导入、eval、任意宿主全局、npm 包、原型变更;通用权限提示、持久暂停/恢复、重放、恰好一次外部副作用——这些被明确定义为"产品边界而非 DSL 待办"。

源码佐证:原则如何落成实现

把 AGENTS.md 的四条总纲与实现对照,可以看到每条原则都有具体的落点:

受限执行通过自有的树遍历解释器实现。 interpreter/runtime.ts 的 parseProgram 先用 TypeScript 编译器 API 把源码包进一个 async function 转译掉 TS 语法(失败即 ParseError 诊断),再用 Acorn 解析出 AST,随后由自有的树遍历解释器执行——全程没有 eval。受支持子集是有意的编排语言:字面量、属性访问、解构、if/switch/各类循环、带闭包的箭头函数与函数声明、可选链与空值合并、模板串、spread、try/catch,以及数组/字符串/数字/Object/Math/JSON 的常用操作、Date、正则、Map/SetURL 工具与一等 Promise(Promise.all/allSettled/race,最多 8 个工具调用并发,未 await 的调用在程序完成前排空)。不支持的语法返回带源码位置的 UnsupportedSyntax 诊断。

结果与失败都是数据。 CodeMode.execute/CodeMode.make(...).execute 返回的 CodeMode.Result 是一个联合 schema:成功为 { ok: true, value, logs?, truncated?, toolCalls },失败为 { ok: false, error: Diagnostic, logs?, truncated?, toolCalls }(见 Success/Failure schema 定义)。程序失败、校验失败、限额失败、工具失败都作为诊断返回而非使 Effect 失败;只有宿主中断保持为 Effect 中断。toolCalls 在失败时也被保留,让宿主可以审计部分执行而不暴露输入或宿主故障。

资源预算没有默认值,是刻意为之。 codemode.ts 的 validateLimitmake/execute 调用时对 timeoutMs(至少 1)、maxToolCalls(可 0)、maxOutputBytes(可 0)做安全整数校验,非法配置抛 RangeError。三个旋钮都没有默认值,因为"执行预算是宿主策略而非库策略":能中断执行 fiber 的宿主(如 OpenCode 在用户取消时所做)可以不设超时,有自己工具输出截断的宿主可以不设 maxOutputBytes——但两者都没有的宿主应当设置 maxOutputBytes,否则超大结果会无声地灌满模型上下文。超限的 maxOutputBytes 不会使执行失败:值被替换为截断的序列化文本加解释标记、日志从头部保留到预算耗尽、结果携带 truncated: true

工具发现是预算化的渐进披露。 tool-runtime.ts 定义了保留命名空间 $codemode(宿主不得占用)、默认 2,000 估算 token 的目录预算(字符数除以 4)、默认搜索页大小 10。指令按"每个命名空间始终列出其工具计数 → 在预算内尽量多地内联完整带 JSDoc 的签名(跨命名空间轮转,避免大命名空间饿死小命名空间)→ 显式声明列表是 COMPLETE 还是 PARTIAL"的结构组织。tools.$codemode.search 始终注册(即使在目录全量内联时),保证模型的投机式搜索调用永远不会以"未知工具"失败;它做确定性加权匹配(精确路径 20 分、路径子串 8 分、描述子串 4 分、可搜索文本 2 分),支持命名空间浏览、精确路径查找与分页。

公开契约以"定律"形式固化。 README 的 Laws 一节 列出了五条等价式:CodeMode.execute({ ...options, code }) 等价于 CodeMode.make(options).execute(code);工具实现只在输入成功解码后才被调用;工具结果只在输出解码并越过纯数据边界后才对程序可见;未知宿主失败不会变成模型可见诊断(ToolError 是显式的安全消息通道);宿主中断保持为中断而非 CodeMode.Failure

验证方式

AGENTS.md 对 OpenAPI 测试的要求("直接测试支持的行为,不要复刻适配器算法")与 README 的 Testing 一节共同给出验证入口。在包目录下运行:

bun test
bun run typecheck

直接测试套件(packages/codemode/test/)覆盖公共投影、发现机制、schema 边界、诊断消毒、资源限额、工具调用观察与中断;openapi.test.tsparity.test.ts 则分别对应适配器行为与跨投影一致性。需要注意的前提:该包目前是工作区私有包(workspace:* 依赖),宿主需自行依赖 effect 并在执行期提供 HttpClient 等工具实现所需的服务层。

小结

packages/codemode/AGENTS.md 的价值在于它用极短的篇幅划定了三层边界:对宿主——CodeMode 只拥有受限执行,授权、持久化、投递语义归应用;对模型——工具 schema 即接口,失败以分类诊断数据的形式返回且保持可恢复;对适配器——不确定就 skipped 或 fail closed,绝不猜测。结合 README 的 API 文档、codemode.md 的决策表与 src/ 下的实现源码,这套"边界即契约"的设计可以作为构建 Agent 受限代码执行层时的参照:先把权力收敛到显式工具树,再把预算、诊断与发现机制建立在它之上。

登录后查看全文
热门项目推荐
相关项目推荐