Documentation
¶
Overview ¶
诊断面:出错位置与面向模型的修复建议。
为什么诊断要带位置 ¶
本包的调用方是模型。模型写的程序出了错,只回一句「invalid result」对它几乎没有 可用信息——它不知道是哪一行,也不知道该改什么,只能原样重写一遍碰运气。 而「第 12 行」这一个数字,能把重试从盲猜变成定点修。
对照 OpenCode packages/codemode:它的 Diagnostic 是 `{ kind, message, location{line,column}, suggestions[] }`,把位置与建议列为 契约的一部分。本包此前只有 ErrorKind 一个类别串,是这条差距的另一半。
位置怎么来 ¶
语法错误一条来源、运行期一条来源,两条的可得性**不一样**,这是事实不是缺陷:
语法错误:goja.CompilerSyntaxError 嵌的 CompilerError 带 File 与 Offset, 而 (*goja.File).Position 是 goja/file 包里的**导出**方法,直接得行列。 拿到后折算回程序内行号(包裹只多一行)。
运行期异常:只有当错误经 (*Runtime).RunProgram 直接返回时, 才会拿到 *goja.Exception,其 Stack() 里有程序帧可定位。
但本包的程序体是 **async IIFE**:函数体里抛出的异常会变成 Promise rejection, RunProgram 本身正常返回,rejection 原因只是一个值,拿不到 goja 侧的历史栈。 goja 未提供「rejection 处回溯调用栈」的公开面。
所以运行期异常的 Location **经常是空的**。这是工具能力边界,如实空着—— 编一个位置出来会让模型去改一行没问题的代码,比没有位置更坏。 宿主若有更强的定位手段(如绑定调用自带行列),可在包装层补齐 Location。
Package ptc 程序化工具编排运行时(批三百零五,deepseek-harness ptc-runtime 对标 P1 试点,设计档 docs/PTC_RUNTIME_DESIGN_20261011.md):模型写一段程序,进程内 goja VM 执行,程序内经 bindings 调用宿主工具,只把归并后的小结果带回上下文——N 轮 ReAct 往返塌缩成 1 轮,原始数据不过模型面。
与 dsh 的取舍(spec §2):goja 进程内 VM(纯 Go 零 CGO,无 Node 分发面)/保守 JS 子集(无 fetch/fs/require,能力只来自 bindings)/VM 边界+三重预算替代 OS 沙盘:
- 墙钟 deadline(vm.Interrupt 中断运行中的代码);
- 绑定调用次数上限(MaxCalls);
- 绑定输出累计字节上限(MaxBytes)。
契约(dsh 同款):程序作为 async 函数体执行(顶层 await/return 可用),完成值即 结果且必须 JSON 可序列化;**失败是结果字段不是异常**(Result.Error/ErrorKind, kind 含 abort 语义)——报告失败是调用方的事,不是崩溃路径。沙盘事实(预算余量/ 中断原因/调用清单)与程序成败分开陈述——程序跑通不等于限制都被执行了。
返回值超限不在本包治理:run_program 是装饰链一员,budgetTool 输出预算+spill 存档 +read_spill 取回同一套预算语义自动生效(spec §2「不另造面」)。
执行点与守卫的分工 ¶
程序里的 host.<tool>(...) 与普通工具调用**走同一套 invoke.Stage 判定环** (见 Options.Stages)。此前两者是分开的,于是本包成了整条链上唯一绕过披露门、 审计门的路径——而本包恰恰是对外推荐的那条路,于是「看起来更安全的路径反而最不安全」。
**出站守卫(netguard)不在判定环上,这是有意的。** 判定环判的是「这次调用要不要 放行」,netguard 判的是「这个 URL 能不能发出去」,是两个执行点。把 URL 守卫塞进 逐调用的判定环,就得去猜「哪个参数字段是 URL」——那是领域知识,内核不该有。
两者能这么分工,靠的是一条沙箱性质:**程序本身够不着网络**(无 fetch / XMLHttpRequest / WebSocket / require / process …),出站只可能发生在宿主绑定的实现 里,而那是宿主自己的代码、由宿主自己接 netguard。这条性质由 sandbox_test.go 的 用例钉住:谁给 VM 加了 fetch,谁就得重新回答「netguard 该接在哪」。
子进程执行后端:把程序放到**宿主进程之外**跑,用内核兜住资源。
它解决什么 ¶
进程内 goja VM(见 ptc.go)跑在宿主进程里,而三闸(墙钟 / 调用数 / 输出字节) **没有内存闸**——Go 运行时没有「只给一个 VM 限内存」的开关,goja 公开 API 也没有内存限制(只有 SetMaxCallStackSize,那是栈深)。故一个快速分配的程序 能在 deadline 到点**之前**把宿主进程撑爆,而宿主里还跑着编排引擎、事件总线、 别的环节。
本后端把执行搬出宿主进程:子进程里跑 goja,用**内核的地址空间上限** (RLIMIT_AS)兑现内存上限。程序被内核杀掉时,父侧从管道 EOF 认出 「执行域异常终止」——不是「程序返回空」。
两条通道 × 四个单向管道 ¶
启动通道:父发 Start → 子;子回 Result → 父 调用通道:子发 Request → 父;父回 Response → 子
**两条通道必须是两条连接**(见 guardx/wire 的包注释)。又因为 `os.Pipe` 是**单向**的,故每条通道用两条单向管道——共 4 条。 用 socketpair 能省成 2 条,但那是 Unix 专有;`os.Pipe` 跨平台。
文件描述符分配:
子进程:fd0=启动读 fd1=启动写 fd3=调用请求写 fd4=调用响应读 父进程:启动写 / 启动读 / 调用请求读 / 调用响应写
判定环留在父侧 ¶
程序里的 host.<tool>() 与普通工具调用走**同一套** invoke.Stage 判定环—— 而判定环与宿主工具都在父侧。子进程里只有一个「发请求」的桩,判定在父侧做。 故本后端**不弱化**门禁:子进程形态下判定环照走。
Index ¶
- Constants
- Variables
- func MaybeRunChild()
- func MemoryLimitSupported() bool
- func WithEnv(ctx context.Context, env RunEnv) context.Context
- type Binding
- type CallRecord
- type Discovered
- type Discoverer
- type ExecutionAborted
- type Options
- type Position
- type Provider
- type Result
- type RunEnv
- type SubprocessOptions
- type SubprocessProvider
- type ToolTableEntry
Constants ¶
const ( StatusOK = "ok" // 正常返回 StatusError = "error" // 执行体报错 StatusDenied = "denied" // 被判定环拒绝,**未触达执行体** )
CallRecord 的 Status 取值。
const ( DefaultTimeout = 30 * time.Second DefaultMaxCalls = 50 DefaultMaxBytes = 256 << 10 // 256KB:程序可归并的原始数据合理上限(超限=该缩小采集) )
三预算缺省(conf ptc.* 覆盖;run_program 构造时注入)。
const ProviderID = "ptc-goja"
ProviderID 本包执行面在 sandbox 登记簿里的标识。
const SubprocessProviderID = "ptc-subprocess"
SubprocessProviderID 子进程后端的标识(登记在 policy 的 sandbox_providers 段)。
Variables ¶
var ErrInvalidSignature = errors.New("绑定调用形态非法")
ErrInvalidSignature 形态串非法(装配期缺陷,非程序问题)。
var ErrMemoryLimitUnsupported = errors.New("ptc: 本环境无法施加内存上限")
ErrMemoryLimitUnsupported 本环境无法施加内存上限。
它是**独立**于「执行域异常终止」的一类失败:前者是「这个环境做不到」 (宿主该换环境或降要求),后者是「执行域中途没了」(可能是兜底生效了)。 合成一类的话,宿主只能一律当故障处理。
Functions ¶
func MaybeRunChild ¶
func MaybeRunChild()
MaybeRunChild 若本进程是执行沙箱的子进程,则跑完子进程主循环并退出; 否则直接返回,宿主继续正常启动。
**必须放在宿主 main 的最前面**:命中时它会 os.Exit,宿主其余启动逻辑不执行。
func MemoryLimitSupported ¶
func MemoryLimitSupported() bool
MemoryLimitSupported 报告**当前环境**能否施加内存上限(cgroup v2 的 memory.max)。
它是**探测**出来的,不是按平台猜的:同一个 Linux 机器,有 cgroup v2 且可写的 就能;容器里没委派 memory 控制器就不能;非 Linux 一律不能。
宿主为什么需要它 ¶
宿主要做「档位登记」——它得知道**当前环境**这个后端兜不兜得住, 否则会在登记簿里选中一个兜不住的后端,然后在运行期收到 「选了它却用不了」这种最难排查的失败。
这与「按平台猜」的写法的差别是实测出来的:同一台 Linux 机器, 换个容器跑(有没有委派 memory 控制器)结论就不同。
Types ¶
type Binding ¶
type Binding struct {
Name string
// Signature JS 调用形态,如 "get_logs(host, count)"。由宿主用自己的工具目录
// 翻译而来——ptc 不认识任何 schema 类型。
Signature string
Call func(ctx context.Context, argsJSON string) (string, error)
}
Binding 一个宿主函数绑定(工具即绑定):Call 收程序传来的 JSON 参数文本,回工具 输出原文(不截断——与模型直读同一信任级;注入嫌疑扫描在装饰链上照常生效)。
Signature 可选,给出后模型按普通函数调用而不必手写 JSON:`get_logs(host, count)` 会被挂成 `host.get_logs(host, count)`,ptc 负责把形参打包成 JSON 再交给 Call。 为空则退化为 `host.<name>(argsJSON)`,与既有行为逐字一致。详见 signature.go。
type CallRecord ¶
type CallRecord struct {
Name string `json:"name"`
Status string `json:"status"` // ok|error
Bytes int `json:"bytes"`
MS int64 `json:"ms"`
Error string `json:"error,omitempty"`
}
CallRecord 一次绑定调用记账(ptc_run 明细事件与审计的账目面:程序把大输出「洗」成 小结果时,调用与返回字节数在此不缺)。
type Discovered ¶
type Discovered struct {
Name string `json:"name"`
Signature string `json:"signature,omitempty"`
Description string `json:"description,omitempty"`
}
Discovered 程序内发现到的一个工具。
type Discoverer ¶
type Discoverer func(query string) []Discovered
Discoverer 程序内的工具发现入口。
为什么需要它 ¶
渐进披露(tooldisclose 那种检索换 schema)与程序化执行此前是**两半没接上**的: 检索在模型面(下一轮对话里查),程序在程序面(只能用装配时给的那张表)。 模型想在程序里换个工具,得退回一轮 ReAct——Code Mode 收益被抵掉一半。
宿主把自己的检索器接进来,程序内即可 discover("重启")。ptc 不依赖任何目录包: 「工具目录长什么样」是宿主的模型,不是内核的。
type ExecutionAborted ¶
type ExecutionAborted struct {
// Err 底层错误(管道 EOF 等)。
Err error
// Exit 子进程退出状态描述(如 "signal: killed" / "exit status 1")。
Exit string
// Signaled 子进程是否被**信号**终止(被内核杀掉)。
//
// Unix 上被信号终止时 `ExitCode()` 返回 -1;Windows 上退出码恒为实际值。
// 故这个判据跨平台成立,不需要按平台分实现。
Signaled bool
// Stderr 子进程 stderr 的尾部(崩溃原因就在这里),**已脱敏**。
//
// 没有它,宿主只看到一个退出码——而退出码分不清「被内核杀掉(兜底生效)」
// 与「自己 fatal 退出(启动失败或 runtime 报错)」。这是**死因**,不是日志。
//
// 为什么是脱敏后的:子进程常把连接串、API key、token 打进 stderr
// (MCP server 启动时的配置回显最常见),而这段文本会**原样**进诊断报告
// 与错误消息——那等于凭据泄漏面。脱敏用上游 guardx/logredact(凭据形态打码),
// 本仓 pii 管的是个人信息,两者互补不重叠。
//
// 脱敏发生在**写入这个字段时**(不是 Error() 渲染时):字段是公开的,
// 宿主可以直接读 e.Stderr——只在渲染面脱敏等于给宿主留了原文出口。
Stderr string
}
ExecutionAborted 执行域异常终止:子进程没给出结果就结束了。
为什么把它做成结构化错误,而不是一句文案 ¶
「子进程没了」有**两种完全不同的原因**,宿主对它们的处置也不同:
- **被内核杀掉**(信号终止):多半是它申请了超限资源——内存兜底**生效了**。 这是**预期行为**,不是故障;宿主该记一笔「兜底起作用了」。
- **自己退出**(非零退出码):子进程**启动失败**——设不上地址空间上限、 fd 无效、协议不匹配。这是**故障**,宿主该报「这个环境不支持这种执行形态」。
只回一句「执行域异常终止」的话,宿主分不出这两者,只能一律当故障处理—— 于是「兜底生效」这种正常结局会被误报成故障,而真正该修的启动失败被淹没。
为什么父侧不能先 Kill 再取状态 ¶
我们自己 Kill 掉的子进程与被内核杀掉的子进程,退出状态**都是**「信号终止」。 只有当子进程**自己先结束**时,取到的才是真死因。故取状态必须**先于**收刀 (见 subprocess.go 的收尾顺序)。
func (*ExecutionAborted) Unwrap ¶
func (e *ExecutionAborted) Unwrap() error
Unwrap 让调用方能取到底层错误(管道 EOF)。
type Options ¶
type Options struct {
Timeout time.Duration
MaxCalls int
MaxBytes int
// Discover 可选:暴露程序内工具发现入口 discover(query)。
// 为 nil 则**根本不暴露**该全局——没装配的东西不该以空壳形式出现在沙箱里,
// 那会让模型以为「没有别的工具了」而放弃检索。
Discover Discoverer
// Stages 每次绑定调用前必过的判定环,与普通工具调用走的是**同一套** Stage。
//
// # 为什么这条是必需的而不是可选优化
//
// 程序里的 host.<tool>(...) 直达宿主工具。若这里不接判定环,本包就成了
// 「整条链上唯一绕过所有门禁的路径」:披露门不判、审计门不判、SSRF 出站门不判。
// 而本包恰恰是对外推荐的那条路(N 轮 ReAct 塌缩成 1 轮、原始数据不过模型面),
// 于是**看起来更安全的路径反而最不安全**——这正是 OpenCode 2.0 #52333 的形状:
// 信任边界移动了,沙箱没跟着移动。
//
// 为 nil 时不接任何判定,行为与本包引入 Stages 之前逐字一致(不破坏既有调用方),
// 但那条路径因此**不带门禁**——接入方须自行确认这是有意的。
Stages []invoke.Stage
}
Options 三预算闸(≤0=各自缺省)。
type Position ¶
Position 程序内的出错位置。Line/Column 均为 1-based;零值表示位置不可得。
type Provider ¶
type Provider struct {
// Bindings 挂给程序的宿主绑定。
Bindings []Binding
// Discover 程序内工具发现入口(nil = 不暴露该全局)。
Discover Discoverer
// Stages 每次绑定调用前必过的判定环(nil = 不接判定)。
Stages []invoke.Stage
}
Provider 把本包的执行面适配成 sandbox.Provider。
为什么适配器在本包而不是 sandbox 包 ¶
适配器要调 Run,而 Run 在本包。放进 sandbox 就成了 sandbox → ptc → sandbox 的 环(Go 的 import 环直接编译不过)。方向只能是 ptc → sandbox: **沙箱机制不认识任何后端,后端认识沙箱**。
档位为什么是 LevelProcess 而不是 LevelOS ¶
goja 是**进程内解释器**:能力面靠 JS 子集 + 绑定白名单裁剪,资源面靠三重预算, 但这层约束由解释器自己执行,不是内核强制的。解释器自身的缺陷可以越过它。
故诚实的档位是 LevelProcess。把它标成 LevelOS 就是让档位表说谎—— 而档位表是宿主做风险判断的依据,说谎的成本落在宿主身上。
type Result ¶
type Result struct {
OK bool
Value string // return 值的 JSON 文本(成功时恒合法 JSON,undefined→"null")
Error string
ErrorKind string
// Location 出错位置(1-based 行列)。取不到时为零值——不猜:假的行号会让模型
// 去改一行根本没问题的代码,比没有行号更坏。
Location Position
// Suggestions 面向模型的修复建议,由 ErrorKind 唯一决定(成功时为 nil)。
Suggestions []string
Calls int
Bytes int
CallLog []CallRecord
// contains filtered or unexported fields
}
Result 执行结果:失败是结果字段不是异常。ErrorKind: ""=成功 / deadline / budget_calls / budget_bytes / runtime(JS 异常或拒绝)/ pending(await 永不落定——VM 无定时器,此类程序永不完成)/ bad_result(return 值 不可 JSON 序列化)/ syntax(程序体语法错误,预检期即拒,带 goja 行号)/ binding_collision(绑定撞名,见 findBindingCollision)/ stage_denied(判定环判定拒绝)/ stage_failed(判定环自身故障,见 stageOutcome)。
stage_denied 与 stage_failed 分开不是分类洁癖:前者是**正常裁决**,程序改不掉它, 只能换工具或走审批;后者是**宿主门禁自己坏了**,程序更不该改它。合成一个 runtime 的话,调用方会去改那段本来没问题的程序,而真正该修的是守卫代码。
Location 与 Suggestions 是给**模型**读的诊断面:调用方是模型,只回一个类别串 对它几乎不可用——它不知道该改哪一行,也不知道该改成什么。详见 diagnostic.go。
func Run ¶
Run 执行一段 PTC 程序:program 作 async 函数体(顶层 await/return 可用)。 ctx 取消=中断(ErrorKind=runtime 包 ctx 错误语义);deadline 到点=中断。 恒返回 Result 不 panic——失控循环由墙钟闸治理(spec §5)。
⚠️ 本函数是**裸执行入口**:它不经 `sandbox` 登记簿 ¶
直调它会**同时**跳过三层强制:
- **档位下限**——`Registry.Resolve` 选不到满足要求的后端就拒绝;
- **预算可兑现性**——兜不住内存的后端不被选中;
- **审批门**——`gatecheck.Managed` 那层(将来接上时)。
声明还在、强制没了,而**门禁全绿**——说谎的不是档位表,是**执行路径**。
故**生产代码请走登记簿**:
sandbox.Execute(ctx, reg, 要求的档位, sandbox.Request{Program: program, Budget: b})
其中后端是 `ptc.Provider`(本包的执行面适配成 `sandbox.Provider` 协议)。 本仓的门禁 `harness-sandbox-execution-path` 会把生产代码里直调本函数的 调用点报出来(判定面:限定符调用;改 import 别名绕不过去)。
本函数**仍然公开**:它是「不经登记簿的进程内执行」这个能力本身, 测试与一次性脚本可以直接用;只是**生产路径**不该走它。
⚠️ **内存不受三闸治理**(此处此前写的是「失控循环/内存风险由三闸治理」,是错的): 三闸是墙钟 / 绑定调用数 / 输出字节数,**没有内存闸**;而 goja 的公开 API 也没有任何内存限制(只有 SetMaxCallStackSize,那是调用栈深度)。 故一个快速分配的程序能在 deadline 到点**之前**把宿主进程撑爆——宿主里还跑着 编排引擎与别的环节。兜住它只能把执行搬出宿主进程(跨域执行), 设计见 docs/SANDBOX-DESIGN-20261021.md。
绑定调用 deadline 语义(批三百零七,P2):阻塞中的 Go 绑定调用(如变更类工具的 审批等待)vm.Interrupt 打不断——中断驱动 goroutine 先 cancel 运行期 ctx 再 Interrupt,绑定调用带 runCtx 出去:墙钟到点阻塞调用被 ctx 断掉(CallRecord 留 error 痕),程序随后被 Interrupt 收口。审批挂起=终止+已执行步骤留痕(spec §4 P2 取舍:暂停恢复复杂度不值)。
func (Result) Describe ¶
Describe 把结果渲染为**模型可读**的诊断文本(宿主回喂面)。
本包的调用方是模型:失败只回一个 ErrorKind 类别串,模型不知道改哪、怎么改。 本方法把 Result 的诊断面折成一段纯文本,供宿主在「程序修复回喂环」里原样 塞回下一轮程序合成(诊断措辞与 harness 的分类口径单源,宿主不再自写渲染—— argus internal/qa 的 describePtcFailure 是首笔收编)。
各段:
- 失败:类别、错误原文、位置(可得时才有此行,见 Position)、修复建议、 调用链摘要(哪些绑定调用失败——归因「程序的问题还是工具的问题」的账面);
- 成功但 Value=="null":点破「没有 return 值」(undefined 折算为 null 是 本包契约)——「忘写 return」是模型侧高频缺陷,不点破它会把白跑当完成;
- 其余成功:一行摘要。
建议优先取 Result.Suggestions(Run 已按 ErrorKind 填好,宿主可增补自己的 上下文建议);为空时按 ErrorKind 补查建议表——手工构造的 Result 也能渲染出 完整建议,两个来源同一张表,措辞不会漂移。
文本面向模型,不面向运维:预算余量、耗时等沙盘事实不在其中(Result 字段 本身就是审计面)。本方法不携带宿主策略(是否重试、是否回退),那是调用方 的裁决。
type RunEnv ¶
type RunEnv struct {
Table []ToolTableEntry
OnRun func(Result)
}
RunEnv 一次运行的 PTC 环境(批三百零五 ctx 缝):runner 在装配完成点(全部工具追加 之后)把本环节模型可见的最终工具表快照进 ctx——「程序内可达=本环节模型可达」恒等式 由快照时点保证,不在组合根持全局表(per-run 表只活在当次 Run 里)。run_program 于 调用期取出构绑定;OnRun=ptc_run 明细事件回调(nil=不发)。
type SubprocessOptions ¶
type SubprocessOptions struct {
// Program 被执行的程序体(由子进程里的 goja 解释)。
Program string
// Bindings 宿主工具绑定。**实现留在父侧**——子进程只拿到名字与调用形态。
Bindings []Binding
// Stages 每次绑定调用前必过的判定环(与普通工具调用同一套)。
Stages []invoke.Stage
// Timeout 墙钟期限。<=0 用 DefaultTimeout。
Timeout time.Duration
// MaxCalls 调用数上限。<=0 用 DefaultMaxCalls。
MaxCalls int
// MaxBytes 输出字节上限。<=0 用 DefaultMaxBytes。
MaxBytes int
// MaxMemory 内存上限(字节)。<=0 = 不要求。
MaxMemory int64
// Discover 可选:程序内工具发现入口。
Discover Discoverer
// Egress 子进程的出口要求(零值 = 不限制,行为与旧版逐字一致)。
//
// 为什么这个字段必须在**配置**上而不是靠子进程自己读环境:子进程起来之后
// 它的出站流量完全在本进程 netguard 之外,父侧唯一能施加影响的时点就是
// spawn 之前。装配(谁决定这一档)归宿主;兑现与判据归 egress。
Egress egress.Spec
// EgressBackend 兑现 Egress 声明的那个对象(通常是宿主起的强制代理)。
//
// # 为什么必须有这一格
//
// Egress 只写「要求哪一档」,它本身不会跑到任何地方去。而在 spawn 之前,
// 父侧唯一能做的事就是问一句「**你**能兑现这一档吗」——没有这一问,
// 声明与执行之间就没有任何信号:宿主写了「只经代理」,子进程照样裸奔出网,
// 而报告里那一条显示「已声明」。
//
// # nil 的含义是「没人声明」,不是「不限制」
//
// 不填它而 Egress 又要求了限制 ⇒ **拒绝执行**(fail closed)。安全判据里
// 沉默必须按最弱处理:「没人反对」不等于「做得到」。不要求限制时它不被问。
//
// 判据在 egress.Spec.SatisfiedBy,本字段只负责把兑现者带到位——判定与
// 声明面同源,不在本包另立一套。
EgressBackend egress.Enforcer
// ExePath 子进程可执行文件路径。空 = 用宿主自己的可执行文件(re-exec)。
//
// **re-exec 宿主自己的二进制**是默认:随包发小二进制要宿主额外安装,
// 接入成本高一个量级。代价是宿主要在 main 里加一行 `ptc.MaybeRunChild()`。
ExePath string
}
SubprocessOptions 子进程后端的配置。
type SubprocessProvider ¶
type SubprocessProvider struct {
// contains filtered or unexported fields
}
SubprocessProvider 子进程执行后端,实现 sandbox.Provider。
档位仍是 **LevelProcess**:能力面没变(还是 JS 子集 + 绑定白名单), 变的是**资源面**——子进程 + 内核上限比进程内强。这正是 Capable 存在的理由: 档位(能力面)与支持面(资源兜底兑现哪几项)是**两个正交维度**。
func NewSubprocessProvider ¶
func NewSubprocessProvider(opts SubprocessOptions) *SubprocessProvider
NewSubprocessProvider 构造子进程后端。
func (*SubprocessProvider) Exec ¶
func (p *SubprocessProvider) Exec(ctx context.Context, req sandbox.Request) (sandbox.Outcome, error)
Exec 实现 sandbox.Provider:起子进程、跑一次程序、收结果。
func (*SubprocessProvider) Level ¶
func (p *SubprocessProvider) Level() sandbox.Level
Level 实现 sandbox.Provider。
**LevelProcess 而不是 LevelOS**:本后端没有内核级的文件/网络裁剪—— 能力面与进程内解释器同级,强的只是资源兜底那一维。标成 LevelOS 就是让 档位表说谎,而档位表是宿主做风险判断的依据。
func (*SubprocessProvider) Supports ¶
func (p *SubprocessProvider) Supports(b sandbox.Budget) bool
Supports 实现 sandbox.Capable。
本后端**能兑现内存上限**(子进程 + cgroup v2 的 memory.max),故 MaxMemory > 0 时也支持。 进程内后端**不实现** Capable(兜不住),两者在登记簿里由此区分。
**判据是「当前环境兜不兜得住」,不是「配没配上限」**: 要求了内存上限而当前环境建不出带 memory.max 的 cgroup(如 macOS—— 它没有 cgroup,seatbelt 裁的是能力面不裁内存)⇒ **不支持**。 若这里报支持,登记簿就会选中本后端,然后 Exec 里因建不出 cgroup 而失败—— 宿主等到的却是「选了它却用不了」这种最难排查的形态。
方向是**保守的**:兜不住就报兜不住,不报「大概能兜」。