Documentation
¶
Overview ¶
Package wire 执行沙箱的**跨进程调用协议**。
它解决什么 ¶
沙箱档位(Level)答的是「能力面多强」,预算支持面(Capable)答的是「资源兜底 兑现哪几项」。但这两者都还缺一块:**程序一旦出宿主进程,工具回调就够不着了**。
进程内执行(goja VM 跑在宿主进程里)时,程序里的 `host.<tool>()` 是一次普通 Go 函数调用;程序出宿主进程后,它变成一次**跨进程 IPC**。这段 IPC 若由每个 后端各写一遍,就会有一堆互不兼容的私有协议——而协议恰恰是最不该私有的东西 (它决定了「子进程里的程序怎么跟父侧说话」,换个后端就得换一套)。
故本包把协议**收成一份**:帧格式、请求/响应语义、父侧服务端循环、子侧客户端。 后端只实现「怎么起这个进程」与「怎么执行这个程序」。
帧格式 ¶
长度前缀 + JSON:
┌───────────────┬──────────────────────────────┐ │ 4B 大端长度 N │ N 字节 JSON 负载 │ └───────────────┴──────────────────────────────┘
**不用行分隔**——工具输出可能含换行,行分隔会被内容切断(一条多行的日志就能 把后面的帧全毁掉)。长度前缀对负载内容零假设。
六条不变量 ¶
- **版本响亮失败**:`v` 不匹配即拒,不静默错配。错配的症状是「字段读成了 另一个意思」——那是最难查的一类,故不静默。
- **失败分类跨进程同源**:`kind` 取 `guardx/toolfail` 的分类,**不是**自由 字符串。分类一旦退化成字符串,父侧就分不清「可重试」与「永久拒绝」, 重试策略随之瞎掉。`toolfail` 与 `sandbox` 同在 guardx——**同层引用, 无跨仓依赖**。
- **预算的权威在父侧**:调用数与输出字节数由**父侧**记账。子进程只是执行者 ——若子进程自己记账,它可以谎报自己没超预算。
- **在途调用有硬上限**:总量预算(MaxCalls)拦不住「瞬间发起十万次并发 调用」——那时它们都还没返回,父侧已被 goroutine 淹没。故父侧另设**在途 并发上限**,超出即回「超限」。这是**可用性**约束,与 MaxCalls 的**配额** 约束是两件事。
- **取消传播**:父侧 ctx 取消 → 取消在途调用 + 通知子进程终止(否则子进程 继续跑,父侧却已经返回了)。
- **响应按 `id` 关联,不按到达顺序**:程序里多个 await 是并发的,返回顺序与 请求顺序无关。
谁该用它 ¶
后端实现方(如 agent-harness 的 ptc 子进程后端):起进程、连管道、跑程序; **协议本身用本包的 Transport / Server / Client**。
宿主:只声明「这次执行要哪一档 + 哪些预算项」,不碰协议。
Index ¶
Constants ¶
const DefaultMaxInflight = 64
DefaultMaxInflight 在途调用数默认硬上限。
取 64:既能吃住「程序内并发几十次工具调用」的常见形态,又不至于让单个失控 程序把父侧的 goroutine 堆爆。宿主可调。
const MaxFrameBytes = 16 << 20
MaxFrameBytes 单帧负载上限(字节)。
长度前缀是 4 字节,理论上限 4GB——但那是**攻击面**:一个坏的长度前缀就能让 接收方预分配几 GB。故设上限,超限即拒。
默认 16MB 覆盖了「工具返回一个大结果」的常见情形(宿主侧另有 MaxBytes 预算 逐次把关,见不变量 3)。
const Version = 1
Version 协议版本。
**不匹配即响亮失败**(不变量 1):静默错配的症状是「字段读成了另一个意思」—— 那是最难查的一类错误,故版本对不上直接拒,不试图「兼容」。
Variables ¶
var ErrInflightLimit = errors.New("wire: 在途调用数超过硬上限")
ErrInflightLimit 在途调用数超过硬上限(不变量 4)。
与「配额超限」(MaxCalls)是**两件事**:配额是「总共能调多少次」,在途上限是 「同时能挂多少个」。后者拦的是「瞬间发起十万次并发调用」——那时它们都还没 返回,父侧已被 goroutine 淹没。
Functions ¶
Types ¶
type Budget ¶
type Budget struct {
// MaxCalls 总调用数上限。0 = 不限。
MaxCalls int
// MaxBytes 累计输出字节上限。0 = 不限。
MaxBytes int
// contains filtered or unexported fields
}
Budget 父侧记账的预算(不变量 3)。
与 `sandbox.Budget` 的区别:那是「宿主对**后端**的要求」(哪些项要兜住), 这里是「父侧**实际记着**的账」(已用掉多少)。两者不是一回事——要求的是 上限,实际的是消耗。
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client 子侧客户端:程序在子进程里用它向父侧请求工具调用。
与 Transport 的方向相反 ¶
Transport 是**父侧**用的(父侧发起调用、收响应);Client 是**子侧**用的 (子侧发起调用、收响应)。两者共用同一套帧格式与「按 id 关联」语义—— 协议只有一份,两个方向只是角色不同。
取消传播(不变量 5) ¶
父侧 ctx 取消 → 父侧关闭传输端 → 子侧读到 EOF → 本客户端的在途调用全部失败。 子侧不需要额外的取消通道:连接断本身就是最强的取消信号。
func (*Client) CallWithID ¶
CallWithID 用指定 id 发起调用。
type Handler ¶
Handler 父侧处理一次工具调用。
返回的 Response 若 OK=false,**Kind 必须是 toolfail 的分类**(不变量 2)—— 分类一旦退化成自由字符串,子进程侧就分不清「可重试」与「永久拒绝」。
Handler 返回的 error 会被转成「分类 = unknown」的失败帧;能归类的失败请自己填 Response.Kind(宿主最清楚那次失败属于哪一类)。
type Request ¶
type Request struct {
// V 协议版本。
V int `json:"v"`
// ID 调用标识。响应按它关联(不变量 6),故**必须非空且在一次执行内唯一**。
ID string `json:"id"`
// Tool 工具名。
Tool string `json:"tool"`
// Args 工具参数原文(宿主侧按自己的工具面解析)。
Args string `json:"args"`
// DeadlineMS 本次调用的期限(毫秒)。0 = 不限期。
//
// 由**子进程**声明、**父侧**执行:父侧是记账与取消的权威(不变量 3/5),
// 子进程说了不算,父侧必须自己兜住。
DeadlineMS int64 `json:"deadline_ms,omitempty"`
}
Request 一次工具调用请求帧(子进程 → 父侧)。
Args 是**原始参数串**(通常是 JSON 文本),不解析成结构体——协议不该替宿主 决定工具参数长什么样(那是宿主的工具面知识)。
type Response ¶
type Response struct {
// V 协议版本。
V int `json:"v"`
// ID 对应的请求标识(不变量 6)。
ID string `json:"id"`
// OK 本次调用是否成功。
OK bool `json:"ok"`
// Value 成功时的结果文本(编码由宿主工具面定)。
Value string `json:"value,omitempty"`
// Kind 失败时的分类(**toolfail.Class**,不变量 2)。
//
// 空串只允许在「不是工具失败」的场合(如协议层错误、父侧拒绝)——那类错误由
// 父侧直接终止执行域,不走正常响应路径。
Kind string `json:"kind,omitempty"`
// Error 失败时的可读原因(给人看,不参与判定)。
Error string `json:"error,omitempty"`
}
Response 一次调用响应帧(父侧 → 子进程)。
type Result ¶
type Result struct {
// V 协议版本。
V int `json:"v"`
// ResultID 对应 Start.ResultID。
ResultID string `json:"result_id"`
// OK 执行是否成功。
OK bool `json:"ok"`
// Value 成功时的结果文本(编码由后端定)。
Value string `json:"value,omitempty"`
// Kind 失败时的分类(**toolfail.Class**,不变量 2)。
Kind string `json:"kind,omitempty"`
// Error 失败时的可读原因(给人看,不参与判定)。
Error string `json:"error,omitempty"`
// Calls 本次执行实际发起的工具调用次数(父侧用于对账)。
Calls int `json:"calls,omitempty"`
// Bytes 本次执行实际产生的输出字节数(父侧用于对账)。
Bytes int `json:"bytes,omitempty"`
}
Result 子侧 → 父侧:一次执行的结果。
与 Response 的区别:Response 是一次**工具调用**的结果,Result 是**整次执行** 的结果(程序跑完了没有、跑了什么、错在哪)。
type Runner ¶
type Runner struct {
// contains filtered or unexported fields
}
Runner 子侧启动通道的对端:收父侧下发的 Start、回报执行结果。
与 Client 的分工(两条通道,两个方向) ¶
启动通道:父侧 Starter ←→ 子侧 Runner (一次执行:1 个 Start + 1 个 Result) 调用通道:父侧 Server ←→ 子侧 Client (N 次工具调用)
**两条通道必须是两条连接**。若共用一条,「读到的这帧是 Start 还是 Response」 就得靠猜——而两者的 JSON 形状不同(Request/Response 有 tool/id,Start 有 program/bindings),猜错的后果是「字段读成了另一个意思」,那是最难查的一类 协议 bug。故本包把两个方向的四种角色各给一个类型。
func (*Runner) AwaitStart ¶
AwaitStart 等父侧下发一次执行的启动帧。
子侧的典型流程:连上启动通道 → AwaitStart(父侧告诉它跑什么)→ 跑程序 (期间用**另一条**调用通道的 Client 请求工具)→ SendResult 回报结果。
**只等一帧**——一次执行一个 Start。
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server 父侧服务端:读子进程发来的调用请求,执行后回帧。
父侧是**应答方**(谁发起、谁应答) ¶
程序在子进程里(`host.<tool>()` 由程序发起),故**子侧是发起方**(Client), 父侧是应答方(Server)。方向搞反会导致记账记在错误的一侧—— 配额该在**接受调用**时扣,不是谁发起谁扣。
它同时是**预算的权威**(不变量 3) ¶
调用数与输出字节数由**父侧**记账。子进程只是执行者——若子进程自己记账, 它可以谎报自己没超预算。故 Budget 挂在 Server 上,由父侧逐次扣减。
并发处理 + 在途硬上限(不变量 4) ¶
每个请求在独立 goroutine 里处理,故一次慢调用不阻塞其他调用。总量预算 (MaxCalls)拦不住「瞬间发起十万次并发调用」——那时它们都还没返回,父侧已被 goroutine 淹没。故另设**在途并发上限**,超出即回「超限」帧,**不排队等**。
这是**可用性**约束,与 MaxCalls 的**配额**约束是两件事。
取消传播(不变量 5) ¶
父侧 ctx 取消 → 关闭连接 → 子侧读到 EOF → 子侧全部在途调用失败。 不需要单独的取消帧:**管道关闭本身就是最强的取消信号**。
func NewServer ¶
func NewServer(rw io.ReadWriter, h Handler, opts ...ServerOption) *Server
NewServer 构造父侧服务端。
type ServerOption ¶
type ServerOption func(*Server)
ServerOption Server 的构造选项。
func WithMaxInflight ¶
func WithMaxInflight(n int) ServerOption
WithMaxInflight 设在途调用硬上限(不变量 4)。<=0 用默认值。
type Start ¶
type Start struct {
// V 协议版本。
V int `json:"v"`
// Program 被执行的程序体。
//
// **由后端自己解释**(不变量:内核不规定程序语言)——子侧知道这段程序
// 对自己意味着什么(脚本?DSL?),父侧只负责把它送到。
Program string `json:"program"`
// Bindings 程序可用的绑定清单。
//
// 父侧把绑定**清单**(名字与调用形态)交给子侧,而不是把绑定**实现**交出去
// ——实现留在父侧,子侧通过工具调用帧请求执行。这正是跨进程的意义:
// 宿主工具只在宿主进程里存在。
Bindings []StartBinding `json:"bindings"`
// Budget 这次执行的要求(父侧对后端的要求)。
//
// 与父侧记账用的 Budget 不同:那是「实际记着多少」,这是「要求多少」。
Budget StartBudget `json:"budget"`
// ResultID 本次执行的结果标识(子侧回报结果时带回)。
ResultID string `json:"result_id"`
}
Start 父侧 → 子侧:启动一次执行。
为什么它与 Request 是两类帧 ¶
Request 是「子侧向父侧要一次工具调用」,Start 是「父侧告诉子侧跑什么」。 两者层次不同:一次执行里可能有**零到很多**次工具调用。
混在一类帧里的代价是「帧的含义要靠上下文猜」——那是最难查的一类协议 bug。 故分成两个类型,各自有明确的发起方向。
type StartBinding ¶
type StartBinding struct {
// Name 工具名(父侧的原始名)。
Name string `json:"name"`
// Signature JS 调用形态,如 "get_logs(host, count)"。
//
// 空 = 退化为 `host.<name>(argsJSON)`。
Signature string `json:"signature,omitempty"`
}
StartBinding 绑定清单项:名字与调用形态。
**不含实现**——实现留在父侧(子侧通过工具调用帧请求执行)。
type StartBudget ¶
type StartBudget struct {
// WallClock 墙钟期限。
WallClock time.Duration `json:"wall_clock,omitempty"`
// MaxCalls 调用数上限。
MaxCalls int `json:"max_calls,omitempty"`
// MaxBytes 输出字节上限。
MaxBytes int `json:"max_bytes,omitempty"`
// MaxMemory 内存上限(字节)。0 = 不要求。
//
// 子侧**兜不住**这一项时应当拒绝(而不是假装兜住了)——这与
// `sandbox.Capable` 的缺省语义同源:漏声明得到的是「兜不住」。
MaxMemory int64 `json:"max_memory,omitempty"`
}
StartBudget 父侧对这次执行的要求。
type Starter ¶
type Starter struct {
// contains filtered or unexported fields
}
Starter 父侧启动器:把一次执行的启动信息发给子侧,并收回结果。
为什么单独一个类型(不塞进 Server) ¶
Server 是**应答方**(子侧发起工具调用,父侧应答);Starter 是**发起方** (父侧告诉子侧跑什么,子在回报结果)。两者方向相反、职责不同, 塞进一个类型会让「谁在等谁」变得要看实现才知道。
启动通道与调用通道是**两条** ¶
启动是一次性的(发一帧 Start、收一帧 Result),工具调用是多次的 (N 次 Request/Response)。混在一条连接上则「读到的那帧是哪种」要靠猜。 故 Starter 用自己的连接;后端起子进程时开两条管道(启动 + 调用)。