/** * [ref] —— 沙箱 lane(e2b/k8s/ssh/adb/local-docker)写门的**真 env 裁决**接法。 * * ## 缺口与它的形状 * * core 的 `createFsWriteGatePolicy` / `createSensitivePathPolicy` 把每一个写目标交给 * `canonicalizeTarget(env, …)` 去问 fs 要真身(`absolutePath` → `exists` → `canonicalPath` → * `fileInfo`/`readLink` 逐跳,dist 亲读)。host lane 上那个 env 就是 hand 工具真正写的那块盘。 * 沙箱 lane 不是:per-task 的沙箱 env 由 core 的 `executionEnvFactory` 在 spec **之后**才铸, * 而写门 wiring 必须在 spec 期就交出去。 * * 出路不是把裁决搬去 hook 层(那要在本仓复刻 `canonicalizeTarget` 的逐级 exists / symlink 解析 / * 段匹配 / 折叠序——复制 core 的裁决逻辑=同源谎),而是利用一个部署事实:**那个 per-task 沙箱 env * 本来就是本仓铸的**。于是本模块给写门一个 ExecutionEnv **代理**:它在**调用时刻**(runToolGate 裁决, * 必在 prepare 之后)去 slot 里取该 task 的真 env 并转发 fs 读原语。call-time 转发消除了时序问题, * 与「从调用上下文读 env」是同一性质。 * * ## 三条不可动的裁定 * * 1. **`absolutePath` 一面不转发**,沿用词法规则(POSIX 绝对形归一 / NUL 拒 / 非绝对形 err)。 * 真 env 的 `absolutePath` 会按 **env 自己的 cwd** 解析相对路径,而工具真写处按 engine 跟踪的 * cwd 解析——两个基准不同,转发它等于拿错基准铸 canonical key,而**错误的 allow 比没有门更糟**。 * 相对形 / `~` / 盘符形因此照旧 err ⇒ `canonicalizeTarget` 失败 ⇒ 写门 `ask`(fail-closed)。 * 「不猜沙箱工作目录」这条公理不随真 env 到场而松。 * 2. **slot 空 = fail-closed 且响亮**:转发面返回错误 ⇒ core 把 `exists` 报错读成 * `unresolvedSymlink` ⇒ `createSensitivePathPolicy` 判 deny、写门判 ask,折叠后 deny 恒赢。 * 保护型缺席的失败方向必须与能力语义同向。留痕走结构化 `warn`(每实例一次)而**不**走 * `observability/fail-open.ts`:那个词表按其模块头是 `F`(体验/缓存回退)与 `P-DEBT`(明知方向 * 不对的保护型欠账)两类的准入面,本臂方向是**对的**(fail-closed),登记进去会把「已收口的 * 保护臂」混进「待还的债」计数里。 * 3. **slot 只认「当前活腿」**:同一 session 同时只有一条活腿(HTTP `conflict.session_active_run` + * core session acquire 双重串行化;委派子代另用一个 sessionId),所以同键改铸即顶替旧登记—— * 那些旧登记来自 park-only 之类「不 destroy 也不 suspend」的终局,留着只会让 resume 腿失去真身裁决。 * * ## slot 的键 = sessionId * * 工厂 ctx 只有 `{sessionId, taskId?, isolation?, parentCwd?}`(core `ExecutionEnvFactoryContext`), * 而 `/v1/runs` 会在 resolveSpec **之后**铸自己的持久 taskId 覆盖 `spec.taskId`,所以 taskId 在主路径 * 上对不上——sessionId 是两侧都稳的那一个(per-task 镜像登记簿是同一个先例)。 * 委派子任务(Agent / workflow 子代)拿的是**另一个** sessionId,所以父的 slot 不会被子代覆盖(「不许 * 最后写赢」由此成立)。**残余面**:子代的写仍按**父**的 env 裁决——子代的 policy 就是父的那一个实例 * (core `inheritedGateForChildren` 把它当 parentConstraint 继承),而 policy 收到的只有 * `{toolName, args, toolCallId}`,读侧根本没有腿身份可键控。方向上它逼近真相而非远离(过渡形是「谁的盘 * 都不看」),但有一条**新的松面**:父盘上一个名叫守卫段、真身良性的软链,会让子代在自己盘上指向真守卫段 * 的同名路径拿到父盘的宽松答案。收口需要一条「按当前工具调用的 env 裁决」的引擎缝,属设计件; * 该面由 test/task-settings.test.ts 的「真身胜过名字」特征化钉机器可见。 */ import { FileError, StubExecutionEnv, type ExecutionEnv, type ExecutionEnvFactory, type FileInfo, type Result } from "@sema-agent/core"; /** 哪些 lane 用本模块的代理裁决写门 —— `REMOTE_EXEC` 未设(进程内 host)与显式 `host` 之外的全部。 * 与 resolve-spec 的 `hostSemanticsLane` 是同一判别式的两面,取值处**只此一个**(两处各写一份正是漂移的成因)。 */ export declare function isSandboxPathAdjudicationLane(provider: string | undefined): boolean; /** slot 取值的三态。`ok:false` 的两支都是 fail-closed,分开只为让留痕说得出**哪一种**缺席。 */ export type SandboxPathEnvLookup = { readonly ok: true; readonly env: ExecutionEnv; } | { readonly ok: false; readonly reason: "no_session_id" | "unbound"; }; /** * per-session 的真 env 登记簿 —— 工厂装饰器写,写门代理读。 * * **同键改铸 = 顶替**(不排队、不判歧义)。依据是一条被两道门执法的不变量:一个 session 同时只有一条 * 活腿(HTTP 层 `conflict.session_active_run` + core 的 session acquire),而委派子代用的是**另一个** * sessionId。所以工厂为某 session 铸出新 env 的那一刻,同键上还留着的登记按构造已经是死腿——最典型的 * 产地是 **park-only 车道**(非 suspendable 的远程 env:durable park 既不 `suspendVM` 也不 `destroy`, * core 的 `teardownOwnedEnv` 在 checkpoint token 在场时整条跳过)。留着它只会让 resume 腿整轮拿不到 * 真 env(写门恒 fail-closed);顶替按**实例身份**收口,被顶掉那一方迟到的注销全部落空,不误伤后继。 */ export declare class SandboxPathEnvSlots { /** **弱持有**:登记簿只在「别人还用着这个 env」期间指向它。跑着的任务由 core 强持有(`prepared.ownedEnv`), * 所以在场腿的裁决永远解得出;而一条 park 后再没人来 resume 的死腿,其 env 一旦无人引用即可被回收, * 条目在下一次清扫时消失。强持有会把它们连同各自的 adapter 连接一起滞留到进程结束,并在 4096 条之后 * 让**每一个**新 session 拿不到真 env(守卫集下 = 每一次结构化写都 deny)—— 那是一道会自己关上的门。 */ private readonly bySession; /** 登记一个刚铸出的 env(同键顶替),返回**幂等**的注销闭包;到达上限而拒收时返回 `undefined`。 * 注销按**实例身份**摘除,所以一个迟到的 destroy 不会误伤同键的后继 env。 */ bind(sessionId: string, env: ExecutionEnv): (() => void) | undefined; resolve(sessionId: string | undefined): SandboxPathEnvLookup; /** 登记簿规模(上限行为的可观测面;测试与运维探针用)。含尚未清扫的死条目。 */ get boundSessions(): number; /** 清掉 env 已被回收的条目。只在触到上限时跑一遍(O(n) 的代价换掉一次拒收)。 */ private sweepCollected; } /** 进程内唯一的登记簿:工厂装饰器(boot/execution-env.ts)与写门 wiring(boot/resolve-spec.ts)分处 * 装配链两端,而中间的 core 只肯传 `ExecutionEnvFactoryContext`——两端共享同一个实例是它们唯一的会合点。 * 测试要隔离时自建 {@link SandboxPathEnvSlots} 实例注入即可(两个消费点都收可选参)。 */ export declare const sandboxPathEnvSlots: SandboxPathEnvSlots; /** 结构化留痕面(与服务 `logger` 同形,调用处 optional-chain —— 与 remote-scratchpad 装饰器一致)。 */ export interface SandboxPathEnvLogger { warn?(event: string, fields?: Record): void; } /** * 装饰工厂:每铸出一个 env 就按 `ctx.sessionId` 登记(同键顶替,见 {@link SandboxPathEnvSlots}), * 终态时注销。 * * 挂**最外层**(装配链尾):内层装饰器可能**换掉**env 实例(worktree 隔离的 `rootEnvAt`)或改写它的 * 方法(scratchpad 的 exec/canonicalPath 前置),写门要裁决的是 core 最终拿到手的那一个。 * * 生命周期三面(顺序即语义): * · `destroy` ⇒ 注销。 * · `suspendVM` 成功 ⇒ 注销。core 在挂起时**跳过** destroy(runtask 留着 env 做快照),所以这是可挂起 * 车道唯一的及时腾位点:挂起期该 session 没有活腿,登记留着只是白占登记簿容量。 * · `resumeVM` 成功 ⇒ **重新登记**。挂起并不必然终结这一轮:`commitSuspendSaga` 的 checkpoint 写失败 * 臂会 `resumeVM` + `postResumeInit` 把**同一个实例**复活,然后作废本次挂起让 run 继续跑 * (core dist 亲读)。只注销不复登记,一次瞬时 checkpoint 故障就会让这条 run 之后每一次写都失去真身 * 裁决(方向 fail-closed,但整轮写面被毒死)。复登记幂等:仍在场时是 no-op,所以正常 resume 腿 * (新 env 在工厂处已登记、随后被 core `resumeVM` 复原快照)照旧不动;后继腿已接管该 session 时也不抢回。 * * 两个终态面都缺席的 env(五条沙箱 adapter 都不是这一形)照样登记——正确性优先于回收:不登记等于 * 让那条 lane 的写门恒 fail-closed。回收兜底=登记簿自己的上限。 */ export declare function withSandboxPathEnvSlot(factory: ExecutionEnvFactory, slots: SandboxPathEnvSlots, logger?: SandboxPathEnvLogger): ExecutionEnvFactory; export interface DeferredSandboxPathEnvOptions { /** slot 键。缺席(= 部署没接 authorizer,`main.ts` 不产生这种形)⇒ 转发面恒 fail-closed。 */ sessionId: string | undefined; slots: SandboxPathEnvSlots; logger?: SandboxPathEnvLogger; } /** * 写门用的 ExecutionEnv 代理(见文件头)。fs **读**原语转发 slot 里的真 env,`absolutePath` 保留词法形, * 其余一切(写面 / shell 面 / listDir …)继承 `StubExecutionEnv` 的 `not_supported`。 * * **继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会随 `StubExecutionEnv` * 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。 */ export declare class DeferredSandboxPathEnv extends StubExecutionEnv { private readonly sessionId; private readonly slots; private readonly logger; /** 每实例一次的留痕闸:裁决面每个写目标要走 3-4 次转发,逐次 warn 会把日志刷成噪声。 */ private warned; constructor(opts: DeferredSandboxPathEnvOptions); /** 取真 env;取不到就地铸 fail-closed 错误(并留痕一次)。 */ private bound; /** 词法一面,**不**转发 —— 理由见文件头裁定 1。 */ absolutePath(path: string): Promise>; exists(path: string, abortSignal?: AbortSignal): Promise>; canonicalPath(path: string, abortSignal?: AbortSignal): Promise>; fileInfo(path: string, abortSignal?: AbortSignal): Promise>; /** `readLink` 在 `ExecutionEnv` 上是**可选**面。本代理恒定义它,于是 core 永远走不到自己的 * 「env 没有 readLink」腿——真 env 缺这一面时由此处答错误,落到 core 同一条 `unresolvedSymlink` 出口, * 裁决结果与那条腿完全一致。 */ readLink(path: string, abortSignal?: AbortSignal): Promise>; } //# sourceMappingURL=deferred-sandbox-path-env.d.ts.map