Help us shape the future of Go! Take the 2026 Go Developer Survey

wire

package
v0.13.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package wire 执行沙箱的**跨进程调用协议**。

它解决什么

沙箱档位(Level)答的是「能力面多强」,预算支持面(Capable)答的是「资源兜底 兑现哪几项」。但这两者都还缺一块:**程序一旦出宿主进程,工具回调就够不着了**。

进程内执行(goja VM 跑在宿主进程里)时,程序里的 `host.<tool>()` 是一次普通 Go 函数调用;程序出宿主进程后,它变成一次**跨进程 IPC**。这段 IPC 若由每个 后端各写一遍,就会有一堆互不兼容的私有协议——而协议恰恰是最不该私有的东西 (它决定了「子进程里的程序怎么跟父侧说话」,换个后端就得换一套)。

故本包把协议**收成一份**:帧格式、请求/响应语义、父侧服务端循环、子侧客户端。 后端只实现「怎么起这个进程」与「怎么执行这个程序」。

帧格式

长度前缀 + JSON:

┌───────────────┬──────────────────────────────┐
│ 4B 大端长度 N  │ N 字节 JSON 负载             │
└───────────────┴──────────────────────────────┘

**不用行分隔**——工具输出可能含换行,行分隔会被内容切断(一条多行的日志就能 把后面的帧全毁掉)。长度前缀对负载内容零假设。

六条不变量

  1. **版本响亮失败**:`v` 不匹配即拒,不静默错配。错配的症状是「字段读成了 另一个意思」——那是最难查的一类,故不静默。
  2. **失败分类跨进程同源**:`kind` 取 `guardx/toolfail` 的分类,**不是**自由 字符串。分类一旦退化成字符串,父侧就分不清「可重试」与「永久拒绝」, 重试策略随之瞎掉。`toolfail` 与 `sandbox` 同在 guardx——**同层引用, 无跨仓依赖**。
  3. **预算的权威在父侧**:调用数与输出字节数由**父侧**记账。子进程只是执行者 ——若子进程自己记账,它可以谎报自己没超预算。
  4. **在途调用有硬上限**:总量预算(MaxCalls)拦不住「瞬间发起十万次并发 调用」——那时它们都还没返回,父侧已被 goroutine 淹没。故父侧另设**在途 并发上限**,超出即回「超限」。这是**可用性**约束,与 MaxCalls 的**配额** 约束是两件事。
  5. **取消传播**:父侧 ctx 取消 → 取消在途调用 + 通知子进程终止(否则子进程 继续跑,父侧却已经返回了)。
  6. **响应按 `id` 关联,不按到达顺序**:程序里多个 await 是并发的,返回顺序与 请求顺序无关。

谁该用它

后端实现方(如 agent-harness 的 ptc 子进程后端):起进程、连管道、跑程序; **协议本身用本包的 Transport / Server / Client**。

宿主:只声明「这次执行要哪一档 + 哪些预算项」,不碰协议。

Index

Constants

View Source
const DefaultMaxInflight = 64

DefaultMaxInflight 在途调用数默认硬上限。

取 64:既能吃住「程序内并发几十次工具调用」的常见形态,又不至于让单个失控 程序把父侧的 goroutine 堆爆。宿主可调。

View Source
const MaxFrameBytes = 16 << 20

MaxFrameBytes 单帧负载上限(字节)。

长度前缀是 4 字节,理论上限 4GB——但那是**攻击面**:一个坏的长度前缀就能让 接收方预分配几 GB。故设上限,超限即拒。

默认 16MB 覆盖了「工具返回一个大结果」的常见情形(宿主侧另有 MaxBytes 预算 逐次把关,见不变量 3)。

View Source
const Version = 1

Version 协议版本。

**不匹配即响亮失败**(不变量 1):静默错配的症状是「字段读成了另一个意思」—— 那是最难查的一类错误,故版本对不上直接拒,不试图「兼容」。

Variables

View Source
var ErrInflightLimit = errors.New("wire: 在途调用数超过硬上限")

ErrInflightLimit 在途调用数超过硬上限(不变量 4)。

与「配额超限」(MaxCalls)是**两件事**:配额是「总共能调多少次」,在途上限是 「同时能挂多少个」。后者拦的是「瞬间发起十万次并发调用」——那时它们都还没 返回,父侧已被 goroutine 淹没。

Functions

func ReadFrame

func ReadFrame(r io.Reader, v any) error

ReadFrame 读一帧:读 4 字节大端长度,再读该长度的 JSON 负载。

长度超上限即拒(不预分配)——长度前缀是外部输入,不能信。 读到一半(EOF / 连接断)即报错,不返回「半个帧」。

func WriteFrame

func WriteFrame(w io.Writer, v any) error

WriteFrame 写一帧:4 字节大端长度 + JSON 负载。

一次 Write 发出整帧(长度前缀与负载合并进同一个缓冲)——分两次写会让 对端在两次之间读到半个帧,虽然不至于错乱,但会让并发场景下的帧交错变得可能。

Types

type Budget

type Budget struct {
	// MaxCalls 总调用数上限。0 = 不限。
	MaxCalls int
	// MaxBytes 累计输出字节上限。0 = 不限。
	MaxBytes int
	// contains filtered or unexported fields
}

Budget 父侧记账的预算(不变量 3)。

与 `sandbox.Budget` 的区别:那是「宿主对**后端**的要求」(哪些项要兜住), 这里是「父侧**实际记着**的账」(已用掉多少)。两者不是一回事——要求的是 上限,实际的是消耗。

func (*Budget) Used

func (b *Budget) Used() (calls, bytes int)

Used 返回已用的调用数与字节数。

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client 子侧客户端:程序在子进程里用它向父侧请求工具调用。

与 Transport 的方向相反

Transport 是**父侧**用的(父侧发起调用、收响应);Client 是**子侧**用的 (子侧发起调用、收响应)。两者共用同一套帧格式与「按 id 关联」语义—— 协议只有一份,两个方向只是角色不同。

取消传播(不变量 5)

父侧 ctx 取消 → 父侧关闭传输端 → 子侧读到 EOF → 本客户端的在途调用全部失败。 子侧不需要额外的取消通道:连接断本身就是最强的取消信号。

func NewClient

func NewClient(rw io.ReadWriter) *Client

NewClient 构造子侧客户端。启动即有读循环 goroutine。

func (*Client) Call

func (c *Client) Call(ctx context.Context, tool, args string) (Response, error)

Call 向父侧发起一次工具调用并等待响应。

id 省略时自动生成(保证一次执行内唯一)。

func (*Client) CallWithID

func (c *Client) CallWithID(ctx context.Context, id, tool, args string) (Response, error)

CallWithID 用指定 id 发起调用。

func (*Client) Close

func (c *Client) Close() error

Close 关闭客户端(幂等)。

type Handler

type Handler func(ctx context.Context, req Request) (Response, error)

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 NewRunner

func NewRunner(rw io.ReadWriter) *Runner

NewRunner 构造子侧启动通道对端。

func (*Runner) AwaitStart

func (r *Runner) AwaitStart(ctx context.Context) (*Start, error)

AwaitStart 等父侧下发一次执行的启动帧。

子侧的典型流程:连上启动通道 → AwaitStart(父侧告诉它跑什么)→ 跑程序 (期间用**另一条**调用通道的 Client 请求工具)→ SendResult 回报结果。

**只等一帧**——一次执行一个 Start。

func (*Runner) SendResult

func (r *Runner) SendResult(res Result) error

SendResult 回报一次执行的结果。

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 构造父侧服务端。

func (*Server) Close

func (s *Server) Close() error

Close 关闭服务端(幂等)。

关闭只停**接受新调用**;已在途的调用由各自 goroutine 收尾。

func (*Server) Serve

func (s *Server) Serve(ctx context.Context) error

Serve 读-执行-回帧,直到 EOF 或出错。

**协议层错误即终止**:版本不匹配、帧损坏——这些不是「某次调用失败」, 是「两侧说的不是同一种话」,继续回帧只会产出更难查的错。

type ServerOption

type ServerOption func(*Server)

ServerOption Server 的构造选项。

func WithBudget

func WithBudget(b Budget) ServerOption

WithBudget 设父侧记账的预算(不变量 3)。零值 = 不限。

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 用自己的连接;后端起子进程时开两条管道(启动 + 调用)。

func NewStarter

func NewStarter(rw io.ReadWriter) *Starter

NewStarter 构造父侧启动器。

func (*Starter) Start

func (s *Starter) Start(ctx context.Context, start Start) (Result, error)

Start 发送启动帧并等待子侧回报结果。

一次性语义:一次 Start 对应一次执行。子侧跑完(成功、失败、或被内核杀掉 都没回帧)时,父侧会在 ctx 超时或连接断开时拿到错误——**「子进程被 OOM 杀掉」 正是这条路径**:管道 EOF,不是「程序返回空」。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL