@sema-agent/server 7.2.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +2 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +26 -1
  4. package/dist/approval-ask-machine.d.ts +39 -0
  5. package/dist/approval-ask-machine.js +101 -0
  6. package/dist/approval-card.d.ts +244 -0
  7. package/dist/approval-card.js +237 -0
  8. package/dist/approval-deny-reasons.d.ts +56 -0
  9. package/dist/approval-deny-reasons.js +54 -0
  10. package/dist/approval-reconciler.d.ts +174 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +39 -4
  14. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  15. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  16. package/dist/boot/execution-env.js +11 -1
  17. package/dist/boot/lexical-path-env.d.ts +10 -0
  18. package/dist/boot/lexical-path-env.js +88 -0
  19. package/dist/boot/reapers.d.ts +34 -0
  20. package/dist/boot/reapers.js +198 -23
  21. package/dist/boot/resolve-spec.js +97 -33
  22. package/dist/capabilities/center-prompts.js +4 -1
  23. package/dist/capabilities/oa-tools.d.ts +15 -0
  24. package/dist/capabilities/oa-tools.js +54 -0
  25. package/dist/config-types.d.ts +68 -1
  26. package/dist/config.d.ts +1 -0
  27. package/dist/config.js +138 -2
  28. package/dist/elicitation.d.ts +4 -0
  29. package/dist/elicitation.js +7 -3
  30. package/dist/finance/cost-taxonomy.d.ts +34 -0
  31. package/dist/finance/cost-taxonomy.js +26 -0
  32. package/dist/hooks/hook-runner.js +32 -0
  33. package/dist/http/routes/capabilities.js +14 -0
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +140 -0
  36. package/dist/http/routes/runs.d.ts +1 -0
  37. package/dist/http/routes/runs.js +548 -16
  38. package/dist/http/routes/tasks.js +175 -12
  39. package/dist/http/server.d.ts +6 -1
  40. package/dist/http/server.js +120 -4
  41. package/dist/http/sse-log.d.ts +51 -0
  42. package/dist/http/sse-log.js +64 -0
  43. package/dist/http/wire-types.d.ts +20 -5
  44. package/dist/main.js +35 -4
  45. package/dist/observability/fail-open.d.ts +98 -0
  46. package/dist/observability/fail-open.js +216 -0
  47. package/dist/observability/prompt-manifest.d.ts +13 -0
  48. package/dist/observability/prompt-manifest.js +8 -0
  49. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  50. package/dist/plugins/approval-ask-store-memory.js +299 -0
  51. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  52. package/dist/plugins/approval-ask-store-sql.js +705 -0
  53. package/dist/plugins/approval-store-sql.d.ts +116 -0
  54. package/dist/plugins/approval-store-sql.js +151 -0
  55. package/dist/plugins/background-agent-store-sql.js +20 -1
  56. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  57. package/dist/plugins/checkpoint-store-sql.js +297 -16
  58. package/dist/plugins/file-workflow-journal-store.d.ts +12 -0
  59. package/dist/plugins/file-workflow-journal-store.js +12 -0
  60. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  61. package/dist/plugins/local-checkpoint-store.js +4 -0
  62. package/dist/plugins/pg-approval-store.d.ts +9 -0
  63. package/dist/plugins/pg-approval-store.js +9 -0
  64. package/dist/plugins/pg-breaker-state.d.ts +8 -0
  65. package/dist/plugins/pg-breaker-state.js +8 -0
  66. package/dist/plugins/pg-checkpoint-store.d.ts +10 -0
  67. package/dist/plugins/pg-checkpoint-store.js +10 -0
  68. package/dist/plugins/pg-file-snapshot-store.d.ts +8 -0
  69. package/dist/plugins/pg-file-snapshot-store.js +8 -0
  70. package/dist/plugins/pg-image-bake.d.ts +12 -0
  71. package/dist/plugins/pg-image-bake.js +11 -0
  72. package/dist/plugins/pg-image-index.d.ts +12 -0
  73. package/dist/plugins/pg-image-index.js +11 -0
  74. package/dist/plugins/pg-outcome-ledger.d.ts +12 -0
  75. package/dist/plugins/pg-outcome-ledger.js +11 -0
  76. package/dist/plugins/pg-pool.js +11 -0
  77. package/dist/plugins/pg-resume-anchor-store.d.ts +7 -0
  78. package/dist/plugins/pg-resume-anchor-store.js +7 -0
  79. package/dist/plugins/pg-run-store.d.ts +9 -0
  80. package/dist/plugins/pg-run-store.js +9 -0
  81. package/dist/plugins/pg-session-policy-store.d.ts +7 -0
  82. package/dist/plugins/pg-session-policy-store.js +7 -0
  83. package/dist/plugins/pg-session-store.d.ts +12 -0
  84. package/dist/plugins/pg-session-store.js +12 -0
  85. package/dist/plugins/pg-tool-result-store.d.ts +9 -0
  86. package/dist/plugins/pg-tool-result-store.js +9 -0
  87. package/dist/plugins/pg-workflow-journal-store.d.ts +9 -0
  88. package/dist/plugins/pg-workflow-journal-store.js +9 -0
  89. package/dist/plugins/pg-workflow-run-store.d.ts +9 -0
  90. package/dist/plugins/pg-workflow-run-store.js +9 -0
  91. package/dist/plugins/store-backend.d.ts +18 -0
  92. package/dist/plugins/store-backend.js +10 -0
  93. package/dist/plugins/tidb-approval-store.d.ts +8 -0
  94. package/dist/plugins/tidb-approval-store.js +8 -0
  95. package/dist/plugins/tidb-breaker-state.d.ts +7 -0
  96. package/dist/plugins/tidb-breaker-state.js +7 -0
  97. package/dist/plugins/tidb-checkpoint-store.d.ts +9 -0
  98. package/dist/plugins/tidb-checkpoint-store.js +9 -0
  99. package/dist/plugins/tidb-file-snapshot-store.d.ts +8 -0
  100. package/dist/plugins/tidb-file-snapshot-store.js +8 -0
  101. package/dist/plugins/tidb-image-bake.d.ts +12 -0
  102. package/dist/plugins/tidb-image-bake.js +11 -0
  103. package/dist/plugins/tidb-image-index.d.ts +12 -0
  104. package/dist/plugins/tidb-image-index.js +11 -0
  105. package/dist/plugins/tidb-outcome-ledger.d.ts +12 -0
  106. package/dist/plugins/tidb-outcome-ledger.js +12 -0
  107. package/dist/plugins/tidb-pool.js +27 -4
  108. package/dist/plugins/tidb-resume-anchor-store.d.ts +7 -0
  109. package/dist/plugins/tidb-resume-anchor-store.js +7 -0
  110. package/dist/plugins/tidb-run-store.d.ts +10 -0
  111. package/dist/plugins/tidb-run-store.js +9 -0
  112. package/dist/plugins/tidb-session-policy-store.d.ts +7 -0
  113. package/dist/plugins/tidb-session-policy-store.js +7 -0
  114. package/dist/plugins/tidb-tool-result-store.d.ts +8 -0
  115. package/dist/plugins/tidb-tool-result-store.js +10 -0
  116. package/dist/plugins/tidb-workflow-journal-store.d.ts +9 -0
  117. package/dist/plugins/tidb-workflow-journal-store.js +9 -0
  118. package/dist/plugins/tidb-workflow-run-store.d.ts +10 -0
  119. package/dist/plugins/tidb-workflow-run-store.js +10 -0
  120. package/dist/plugins/workflow-journal-limits.d.ts +12 -0
  121. package/dist/plugins/workflow-journal-limits.js +12 -0
  122. package/dist/question.d.ts +21 -14
  123. package/dist/question.js +83 -34
  124. package/dist/runs.d.ts +16 -1
  125. package/dist/runs.js +61 -3
  126. package/dist/runtime-caps-resolver.d.ts +7 -1
  127. package/dist/runtime-caps-resolver.js +65 -3
  128. package/dist/sema-registry.d.ts +41 -0
  129. package/dist/sema-registry.js +40 -0
  130. package/dist/spec-fields.d.ts +4 -0
  131. package/dist/spec-fields.js +6 -0
  132. package/dist/task-settings.d.ts +36 -15
  133. package/dist/task-settings.js +19 -5
  134. package/dist/tool-approval.d.ts +296 -3
  135. package/dist/tool-approval.js +1074 -50
  136. package/dist/trace/core-keyset-guard.d.ts +2 -2
  137. package/dist/trace/ledger-sink.js +14 -1
  138. package/dist/trace/project.d.ts +90 -0
  139. package/dist/trace/project.js +188 -0
  140. package/package.json +5 -4
@@ -0,0 +1,279 @@
1
+ /**
2
+ * #165 —— 沙箱 lane(e2b/k8s/ssh/adb/local-docker)写门的**真 env 裁决**接法。
3
+ *
4
+ * ## 缺口与它的形状
5
+ *
6
+ * core 的 `createFsWriteGatePolicy` / `createSensitivePathPolicy` 把每一个写目标交给
7
+ * `canonicalizeTarget(env, …)` 去问 fs 要真身(`absolutePath` → `exists` → `canonicalPath` →
8
+ * `fileInfo`/`readLink` 逐跳,dist 亲读)。host lane 上那个 env 就是 hand 工具真正写的那块盘。
9
+ * 沙箱 lane 不是:per-task 的沙箱 env 由 core 的 `executionEnvFactory` 在 spec **之后**才铸,
10
+ * 而写门 wiring 必须在 spec 期就交出去。
11
+ *
12
+ * 出路不是把裁决搬去 hook 层(那要在本仓复刻 `canonicalizeTarget` 的逐级 exists / symlink 解析 /
13
+ * 段匹配 / 折叠序——复制 core 的裁决逻辑=同源谎),而是利用一个部署事实:**那个 per-task 沙箱 env
14
+ * 本来就是本仓铸的**。于是本模块给写门一个 ExecutionEnv **代理**:它在**调用时刻**(runToolGate 裁决,
15
+ * 必在 prepare 之后)去 slot 里取该 task 的真 env 并转发 fs 读原语。call-time 转发消除了时序问题,
16
+ * 与「从调用上下文读 env」是同一性质。
17
+ *
18
+ * ## 三条不可动的裁定
19
+ *
20
+ * 1. **`absolutePath` 一面不转发**,沿用词法规则(POSIX 绝对形归一 / NUL 拒 / 非绝对形 err)。
21
+ * 真 env 的 `absolutePath` 会按 **env 自己的 cwd** 解析相对路径,而工具真写处按 engine 跟踪的
22
+ * cwd 解析——两个基准不同,转发它等于拿错基准铸 canonical key,而**错误的 allow 比没有门更糟**。
23
+ * 相对形 / `~` / 盘符形因此照旧 err ⇒ `canonicalizeTarget` 失败 ⇒ 写门 `ask`(fail-closed)。
24
+ * 「不猜沙箱工作目录」这条公理不随真 env 到场而松。
25
+ * 2. **slot 空 = fail-closed 且响亮**:转发面返回错误 ⇒ core 把 `exists` 报错读成
26
+ * `unresolvedSymlink` ⇒ `createSensitivePathPolicy` 判 deny、写门判 ask,折叠后 deny 恒赢。
27
+ * 保护型缺席的失败方向必须与能力语义同向。留痕走结构化 `warn`(每实例一次)而**不**走
28
+ * `observability/fail-open.ts`:那个词表按其模块头是 `F`(体验/缓存回退)与 `P-DEBT`(明知方向
29
+ * 不对的保护型欠账)两类的准入面,本臂方向是**对的**(fail-closed),登记进去会把「已收口的
30
+ * 保护臂」混进「待还的债」计数里。
31
+ * 3. **slot 只认「当前活腿」**:同一 session 同时只有一条活腿(HTTP `conflict.session_active_run` +
32
+ * core session acquire 双重串行化;委派子代另用一个 sessionId),所以同键改铸即顶替旧登记——
33
+ * 那些旧登记来自 park-only 之类「不 destroy 也不 suspend」的终局,留着只会让 resume 腿失去真身裁决。
34
+ *
35
+ * ## slot 的键 = sessionId
36
+ *
37
+ * 工厂 ctx 只有 `{sessionId, taskId?, isolation?, parentCwd?}`(core `ExecutionEnvFactoryContext`),
38
+ * 而 `/v1/runs` 会在 resolveSpec **之后**铸自己的持久 taskId 覆盖 `spec.taskId`,所以 taskId 在主路径
39
+ * 上对不上——sessionId 是两侧都稳的那一个(per-task 镜像登记簿是同一个先例)。
40
+ * 委派子任务(Agent / workflow 子代)拿的是**另一个** sessionId,所以父的 slot 不会被子代覆盖(「不许
41
+ * 最后写赢」由此成立)。**残余面**:子代的写仍按**父**的 env 裁决——子代的 policy 就是父的那一个实例
42
+ * (core `inheritedGateForChildren` 把它当 parentConstraint 继承),而 policy 收到的只有
43
+ * `{toolName, args, toolCallId}`,读侧根本没有腿身份可键控。方向上它逼近真相而非远离(过渡形是「谁的盘
44
+ * 都不看」),但有一条**新的松面**:父盘上一个名叫守卫段、真身良性的软链,会让子代在自己盘上指向真守卫段
45
+ * 的同名路径拿到父盘的宽松答案。收口需要一条「按当前工具调用的 env 裁决」的引擎缝,属设计件;
46
+ * 该面由 test/task-settings.test.ts 的「真身胜过名字」特征化钉机器可见。
47
+ */
48
+ import { posix } from "node:path";
49
+ import { FileError, StubExecutionEnv, err, ok } from "@sema-agent/core";
50
+ /** 哪些 lane 用本模块的代理裁决写门 —— `REMOTE_EXEC` 未设(进程内 host)与显式 `host` 之外的全部。
51
+ * 与 resolve-spec 的 `hostSemanticsLane` 是同一判别式的两面,取值处**只此一个**(两处各写一份正是漂移的成因)。 */
52
+ export function isSandboxPathAdjudicationLane(provider) {
53
+ return provider !== undefined && provider !== "host";
54
+ }
55
+ /** 词法归一:纯字符串运算,折 `.` / `..` / 重复分隔符,不碰 fs。
56
+ * `posix.normalize` 把 `..` 在根部截断(`/../x` → `/x`),与「沙箱根之上没有东西」的语义一致。
57
+ * 尾部分隔符统一剥掉(根 `/` 除外),让同一目标只有一个 key ——前缀判域靠的就是 key 的唯一性。
58
+ * (core 内部有同形的 `normalizeAbsPathLexically`,但未从包根导出;此处是 Node 标准库的等价运算,
59
+ * 不是它的抄本——若日后 core 导出,这里应改为直接复用。) */
60
+ function normalizeAbsolutePathLexically(path) {
61
+ const collapsed = posix.normalize(path.replace(/^\/+/, "/"));
62
+ return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
63
+ }
64
+ /** 同时可登记的 session 数上限。到顶后先清死条目,仍满则**拒收新键、绝不驱逐在场者**:被拒的 session
65
+ * 只是拿不到真 env(⇒ fail-closed),而驱逐一个在场者会让一个正在跑的 task 的写门突然失去真身裁决。 */
66
+ const MAX_BOUND_SESSIONS = 4096;
67
+ /**
68
+ * per-session 的真 env 登记簿 —— 工厂装饰器写,写门代理读。
69
+ *
70
+ * **同键改铸 = 顶替**(不排队、不判歧义)。依据是一条被两道门执法的不变量:一个 session 同时只有一条
71
+ * 活腿(HTTP 层 `conflict.session_active_run` + core 的 session acquire),而委派子代用的是**另一个**
72
+ * sessionId。所以工厂为某 session 铸出新 env 的那一刻,同键上还留着的登记按构造已经是死腿——最典型的
73
+ * 产地是 **park-only 车道**(非 suspendable 的远程 env:durable park 既不 `suspendVM` 也不 `destroy`,
74
+ * core 的 `teardownOwnedEnv` 在 checkpoint token 在场时整条跳过)。留着它只会让 resume 腿整轮拿不到
75
+ * 真 env(写门恒 fail-closed);顶替按**实例身份**收口,被顶掉那一方迟到的注销全部落空,不误伤后继。
76
+ */
77
+ export class SandboxPathEnvSlots {
78
+ /** **弱持有**:登记簿只在「别人还用着这个 env」期间指向它。跑着的任务由 core 强持有(`prepared.ownedEnv`),
79
+ * 所以在场腿的裁决永远解得出;而一条 park 后再没人来 resume 的死腿,其 env 一旦无人引用即可被回收,
80
+ * 条目在下一次清扫时消失。强持有会把它们连同各自的 adapter 连接一起钉死到进程结束,并在 4096 条之后
81
+ * 让**每一个**新 session 拿不到真 env(守卫集下 = 每一次结构化写都 deny)—— 那是一道会自己关上的门。 */
82
+ bySession = new Map();
83
+ /** 登记一个刚铸出的 env(同键顶替),返回**幂等**的注销闭包;到达上限而拒收时返回 `undefined`。
84
+ * 注销按**实例身份**摘除,所以一个迟到的 destroy 不会误伤同键的后继 env。 */
85
+ bind(sessionId, env) {
86
+ if (!this.bySession.has(sessionId) && this.bySession.size >= MAX_BOUND_SESSIONS) {
87
+ this.sweepCollected();
88
+ if (this.bySession.size >= MAX_BOUND_SESSIONS)
89
+ return undefined;
90
+ }
91
+ const ref = new WeakRef(env);
92
+ this.bySession.set(sessionId, ref);
93
+ return () => {
94
+ if (this.bySession.get(sessionId) === ref)
95
+ this.bySession.delete(sessionId);
96
+ };
97
+ }
98
+ resolve(sessionId) {
99
+ if (!sessionId)
100
+ return { ok: false, reason: "no_session_id" };
101
+ const ref = this.bySession.get(sessionId);
102
+ const env = ref?.deref();
103
+ if (env === undefined) {
104
+ if (ref !== undefined)
105
+ this.bySession.delete(sessionId); // 顺手收掉已回收的条目
106
+ return { ok: false, reason: "unbound" };
107
+ }
108
+ return { ok: true, env };
109
+ }
110
+ /** 登记簿规模(上限行为的可观测面;测试与运维探针用)。含尚未清扫的死条目。 */
111
+ get boundSessions() {
112
+ return this.bySession.size;
113
+ }
114
+ /** 清掉 env 已被回收的条目。只在触到上限时跑一遍(O(n) 的代价换掉一次拒收)。 */
115
+ sweepCollected() {
116
+ for (const [sid, ref] of this.bySession)
117
+ if (ref.deref() === undefined)
118
+ this.bySession.delete(sid);
119
+ }
120
+ }
121
+ /** 进程内唯一的登记簿:工厂装饰器(boot/execution-env.ts)与写门 wiring(boot/resolve-spec.ts)分处
122
+ * 装配链两端,而中间的 core 只肯传 `ExecutionEnvFactoryContext`——两端共享同一个实例是它们唯一的会合点。
123
+ * 测试要隔离时自建 {@link SandboxPathEnvSlots} 实例注入即可(两个消费点都收可选参)。 */
124
+ export const sandboxPathEnvSlots = new SandboxPathEnvSlots();
125
+ /**
126
+ * 装饰工厂:每铸出一个 env 就按 `ctx.sessionId` 登记(同键顶替,见 {@link SandboxPathEnvSlots}),
127
+ * 终态时注销。
128
+ *
129
+ * 挂**最外层**(装配链尾):内层装饰器可能**换掉**env 实例(worktree 隔离的 `rootEnvAt`)或改写它的
130
+ * 方法(scratchpad 的 exec/canonicalPath 前置),写门要裁决的是 core 最终拿到手的那一个。
131
+ *
132
+ * 生命周期三面(顺序即语义):
133
+ * · `destroy` ⇒ 注销。
134
+ * · `suspendVM` 成功 ⇒ 注销。core 在挂起时**跳过** destroy(runtask 留着 env 做快照),所以这是可挂起
135
+ * 车道唯一的及时腾位点:挂起期该 session 没有活腿,登记留着只是白占登记簿容量。
136
+ * · `resumeVM` 成功 ⇒ **重新登记**。挂起并不必然终结这一轮:`commitSuspendSaga` 的 checkpoint 写失败
137
+ * 臂会 `resumeVM` + `postResumeInit` 把**同一个实例**复活,然后作废本次挂起让 run 继续跑
138
+ * (core dist 亲读)。只注销不复登记,一次瞬时 checkpoint 故障就会让这条 run 之后每一次写都失去真身
139
+ * 裁决(方向 fail-closed,但整轮写面被毒死)。复登记幂等:仍在场时是 no-op,所以正常 resume 腿
140
+ * (新 env 在工厂处已登记、随后被 core `resumeVM` 复原快照)照旧不动;后继腿已接管该 session 时也不抢回。
141
+ *
142
+ * 两个终态面都缺席的 env(五条沙箱 adapter 都不是这一形)照样登记——正确性优先于回收:不登记等于
143
+ * 让那条 lane 的写门恒 fail-closed。回收兜底=登记簿自己的上限。
144
+ */
145
+ export function withSandboxPathEnvSlot(factory, slots, logger) {
146
+ return async (ctx) => {
147
+ const env = await factory(ctx);
148
+ if (!ctx.sessionId)
149
+ return env;
150
+ const sessionId = ctx.sessionId;
151
+ let release = slots.bind(sessionId, env);
152
+ if (release === undefined) {
153
+ // 上限拒收:该 task 的写门会落 fail-closed 而不是拿别人的盘裁决,但运维得知道为什么。
154
+ logger?.warn?.("sandbox_path_env_slot_at_capacity", { sessionId, boundSessions: slots.boundSessions });
155
+ return env;
156
+ }
157
+ const drop = () => {
158
+ release?.();
159
+ release = undefined;
160
+ };
161
+ const revive = () => {
162
+ if (release !== undefined)
163
+ return; // 仍在场:复活是 no-op,绝不重复登记
164
+ if (slots.resolve(sessionId).ok)
165
+ return; // 后继腿已接管这个 session:一个迟到的复活绝不把它顶掉
166
+ release = slots.bind(sessionId, env);
167
+ if (release === undefined)
168
+ logger?.warn?.("sandbox_path_env_slot_at_capacity", { sessionId, boundSessions: slots.boundSessions });
169
+ };
170
+ const destroyable = env;
171
+ if (typeof destroyable.destroy === "function") {
172
+ const origDestroy = destroyable.destroy.bind(env);
173
+ destroyable.destroy = async () => {
174
+ drop();
175
+ return origDestroy();
176
+ };
177
+ }
178
+ const lifecycle = env;
179
+ if (typeof lifecycle.suspendVM === "function") {
180
+ const origSuspend = lifecycle.suspendVM.bind(env);
181
+ lifecycle.suspendVM = async (...args) => {
182
+ const r = await origSuspend(...args);
183
+ if (r.ok)
184
+ drop();
185
+ return r;
186
+ };
187
+ }
188
+ if (typeof lifecycle.resumeVM === "function") {
189
+ const origResume = lifecycle.resumeVM.bind(env);
190
+ lifecycle.resumeVM = async (...args) => {
191
+ const r = await origResume(...args);
192
+ if (r.ok)
193
+ revive();
194
+ return r;
195
+ };
196
+ }
197
+ return env;
198
+ };
199
+ }
200
+ /**
201
+ * 写门用的 ExecutionEnv 代理(见文件头)。fs **读**原语转发 slot 里的真 env,`absolutePath` 保留词法形,
202
+ * 其余一切(写面 / shell 面 / listDir …)继承 `StubExecutionEnv` 的 `not_supported`。
203
+ *
204
+ * **继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会随 `StubExecutionEnv`
205
+ * 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
206
+ */
207
+ export class DeferredSandboxPathEnv extends StubExecutionEnv {
208
+ sessionId;
209
+ slots;
210
+ logger;
211
+ /** 每实例一次的留痕闸:裁决面每个写目标要走 3-4 次转发,逐次 warn 会把日志刷成噪声。 */
212
+ warned = false;
213
+ constructor(opts) {
214
+ super();
215
+ this.sessionId = opts.sessionId;
216
+ this.slots = opts.slots;
217
+ this.logger = opts.logger;
218
+ }
219
+ /** 取真 env;取不到就地铸 fail-closed 错误(并留痕一次)。 */
220
+ bound(path, face) {
221
+ const found = this.slots.resolve(this.sessionId);
222
+ if (found.ok)
223
+ return found;
224
+ if (!this.warned) {
225
+ this.warned = true;
226
+ this.logger?.warn?.("sandbox_path_env_slot_unresolved", {
227
+ reason: found.reason,
228
+ face,
229
+ sessionId: this.sessionId ?? null,
230
+ note: "the sandbox fs-write gate has no task environment to adjudicate against — every write target fails closed (ask, and deny under the sensitive-path set) until one is bound",
231
+ });
232
+ }
233
+ return {
234
+ ok: false,
235
+ error: new FileError("not_supported", `no sandbox execution environment is bound for this task (${found.reason}) — the write gate cannot inspect the real filesystem`, path),
236
+ };
237
+ }
238
+ /** 词法一面,**不**转发 —— 理由见文件头裁定 1。 */
239
+ absolutePath(path) {
240
+ // 含 NUL 的字符串不是合法 POSIX 路径(任何 fs 面都写不进去),但词法归一会照样给它铸出 canonical
241
+ // key——`\0` 尾巴让守卫段 glob 失配,豁免会话下还能拿 allow。拒收让 canon 在豁免咨询**之前**就失败。
242
+ if (path.includes("\u0000")) {
243
+ return Promise.resolve(err(new FileError("not_supported", "this gate resolves paths lexically and rejects a path containing a NUL byte (not a representable POSIX path)", path)));
244
+ }
245
+ // POSIX 绝对形是沙箱 lane(全 Linux)上 hand 工具的书面契约形。相对路径 / `~` / `C:\…` 都要一个
246
+ // 沙箱工作目录才能解析,而那个基准与工具真写处的基准不是同一个,不猜。
247
+ // (反斜杠 UNC 形根本到不了这里:core 在 canonicalizeTarget 入口就短路了。)
248
+ if (!path.startsWith("/")) {
249
+ return Promise.resolve(err(new FileError("not_supported", "this gate cannot resolve a non-absolute path (the sandbox working directory is not the gate's to guess)", path)));
250
+ }
251
+ return Promise.resolve(ok(normalizeAbsolutePathLexically(path)));
252
+ }
253
+ async exists(path, abortSignal) {
254
+ const b = this.bound(path, "exists");
255
+ return b.ok ? b.env.exists(path, abortSignal) : b;
256
+ }
257
+ async canonicalPath(path, abortSignal) {
258
+ const b = this.bound(path, "canonicalPath");
259
+ return b.ok ? b.env.canonicalPath(path, abortSignal) : b;
260
+ }
261
+ async fileInfo(path, abortSignal) {
262
+ const b = this.bound(path, "fileInfo");
263
+ return b.ok ? b.env.fileInfo(path, abortSignal) : b;
264
+ }
265
+ /** `readLink` 在 `ExecutionEnv` 上是**可选**面。本代理恒定义它,于是 core 永远走不到自己的
266
+ * 「env 没有 readLink」腿——真 env 缺这一面时由此处答错误,落到 core 同一条 `unresolvedSymlink` 出口,
267
+ * 裁决结果与那条腿完全一致。 */
268
+ async readLink(path, abortSignal) {
269
+ const b = this.bound(path, "readLink");
270
+ if (!b.ok)
271
+ return b;
272
+ const readLink = b.env.readLink?.bind(b.env);
273
+ if (readLink === undefined) {
274
+ return err(new FileError("not_supported", "the bound sandbox environment cannot read symlinks, so this target's real destination is unknown", path));
275
+ }
276
+ return readLink(path, abortSignal);
277
+ }
278
+ }
279
+ //# sourceMappingURL=deferred-sandbox-path-env.js.map
@@ -10,7 +10,9 @@
10
10
  * → `withRemoteScratchpad`(远程车道首 exec 才 mkdir)
11
11
  * → `withWorktreeIsolation`(host 车道 per-agent worktree,定 cwd)
12
12
  * → `TaskEnvRegistry.wrapFactory`(SendUserFile 直传登记 taskId→env)
13
- * → 附件物化(**最外层**:内层先定 cwd,附件才落在最终 cwd —— 原文逐字注释即此)
13
+ * → 附件物化(内层先定 cwd,附件才落在最终 cwd —— 原文逐字注释即此)
14
+ * → `withSandboxPathEnvSlot`(#165,**最外层**:沙箱写门要裁决的是 core 最终拿到手的那个实例,
15
+ * 而内层会换实例/改写方法,所以登记必须排在全部换手之后)。
14
16
  * 另:`lspManager` 声明在 host 工厂**之后**,host 工厂闭包对它是同作用域前向引用(闭包每任务才跑,
15
17
  * 那时已初始化)——本段整体搬运保住了这个前向引用,拆开两个模块就会破。
16
18
  */
@@ -34,6 +36,7 @@ import { materializeAttachmentsInto } from "../plugins/task-attachment-store.js"
34
36
  import { reapOrphanWorktrees, withWorktreeIsolation } from "../plugins/worktree-isolation.js";
35
37
  import { customPkgSourceFromEnv, derivePkgSourceEnv } from "../sandbox-pkg-source.js";
36
38
  import { effectiveHostWorkspace } from "../task-cwd.js";
39
+ import { isSandboxPathAdjudicationLane, sandboxPathEnvSlots, withSandboxPathEnvSlot } from "./deferred-sandbox-path-env.js";
37
40
  /**
38
41
  * #97 R11: the perSessionCwd/perSessionShellEnv setters below share one idiom (delete-then-set so a
39
42
  * re-set moves the tail to MRU, then oldest-first eviction on overflow). Local to this file — NOT the
@@ -337,6 +340,13 @@ export function createExecutionEnv(ctx) {
337
340
  return env;
338
341
  };
339
342
  }
343
+ // #165 沙箱写门的真 env slot(**最外层**,见文件头的装饰顺序):写门在 spec 期只能拿到一个代理,
344
+ // 裁决时刻由它去 slot 里取本 task 的真 env 转发 fs 读原语。所以登记的必须是 core 最终拿到手的那一个
345
+ // ——内层装饰器会换实例(worktree 的 rootEnvAt)或改写方法(scratchpad 的 exec/canonicalPath 前置)。
346
+ // 沙箱 lane 限定:host-semantics lane 的写门直接拿本机 NodeExecutionEnv,不经代理,包了也无人读。
347
+ if (executionEnvFactory && isSandboxPathAdjudicationLane(config.remoteExec?.provider)) {
348
+ executionEnvFactory = withSandboxPathEnvSlot(executionEnvFactory, sandboxPathEnvSlots, logger);
349
+ }
340
350
  if (executionEnvFactory) {
341
351
  // k8s (Kata) is isolated; it becomes WORKSPACE-suspendable when an S3 snapshot store is configured
342
352
  // (tar→S3→fresh pod restore — files durable, in-VM memory not); e2b is fully suspendable.
@@ -0,0 +1,10 @@
1
+ import { FileError, StubExecutionEnv, type Result } from "@sema-agent/core";
2
+ /**
3
+ * 无文件系统的路径裁决 env(见文件头)。只覆写 `absolutePath` 一面,其余全部继承 `StubExecutionEnv`
4
+ * 的 `not_supported` ——**继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会
5
+ * 随 `StubExecutionEnv` 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
6
+ */
7
+ export declare class LexicalPathExecutionEnv extends StubExecutionEnv {
8
+ absolutePath(path: string): Promise<Result<string, FileError>>;
9
+ }
10
+ //# sourceMappingURL=lexical-path-env.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * #156 —— 非 host lane(e2b/k8s/ssh/adb/local-docker)写门的**纯路径规则**过渡形所用的裁决 env。
3
+ *
4
+ * ## 它解决的缺口
5
+ *
6
+ * core 的 `createFsWriteGatePolicy` / `createSensitivePathPolicy` 都把写目标交给
7
+ * `canonicalizeTarget(env, …)` 去「问 fs 要真身」。host lane 上那个 env 就是 hand 工具真正写的那块盘,
8
+ * 答案可信。沙箱 lane 不是:per-task 的沙箱 env 由 core 的 `executionEnvFactory` 在 spec **之后**才铸,
9
+ * spec 期手边只有 worker 本机的 fs——拿它去裁沙箱里的路径会答错(exists/symlink 全是别人机器上的事实),
10
+ * 而**错误的 allow 比没有门更糟**。所以 [816]/[820] 当年在沙箱 lane 诚实不挂门(wiring=undefined ⇒
11
+ * 三个模式臂全回落 base 规则,写门整条缺席)。#153 的 ③ 号注把这笔记成了余款,#156 是它的过渡还款。
12
+ *
13
+ * ## 这个 env 是什么
14
+ *
15
+ * 一个**没有文件系统**的 env:除 `absolutePath` 外的每一个 fs 面(exists / canonicalPath / fileInfo /
16
+ * read* / write* / listDir / createDir / …)与 shell 面都继承 core 的 `StubExecutionEnv`,
17
+ * 恒返回 `not_supported` 错误(`readLink` 是可选面,Stub 干脆没有——core 把缺席当「symlink 不可解」,
18
+ * 且该分支在本 env 恒不可达:exists 先错就进了 canonicalizeNewPath)——这不是伪装,这就是本 env 的诚实回答:**spec 期我们确实不知道沙箱里
19
+ * 那块盘上有什么**。`absolutePath` 是唯一被赋予真实语义的一面,而它本来就是纯路径运算(不碰盘):
20
+ * POSIX 绝对路径做词法归一后原样奉还,其余一律「不知道」。
21
+ *
22
+ * ## 由此得到的裁决语义(core dist 亲读推出,`tools/fs/safety.js` canonicalizeTarget)
23
+ *
24
+ * · 绝对路径(`/…`):`absolutePath` ok ⇒ core 进 exists 探测 ⇒ 我们返回错误 ⇒ core 按「该路径尚不存在」
25
+ * 走 `canonicalizeNewPath`,那里逐级 exists 也全错 ⇒ 原样返回我们给的词法绝对路径。于是 canonical key
26
+ * = **词法归一后的路径**,`canonicalPath` / `fileInfo` / `readLink` 三面在这条路上根本不可达。
27
+ * · 相对路径 / `~/…` / Windows 盘符形:`absolutePath` 返回错误 ⇒ `canon.ok=false` ⇒ 写门直接
28
+ * `ask`(fail-closed)。沙箱的 cwd 在 spec 期不可知,这正是我们要的偏置——**不猜沙箱工作目录**。
29
+ * · 反斜杠 UNC 形(`\\host\share\…`)是唯一**不经过**本 env 的形:core 的 `canonicalizeTarget` 在入口
30
+ * 就把它短路成 `ok:true, key=原样`(复审 2026-08-05 dist 亲读+实测)。结果不变——写门无放行域可越,
31
+ * 落 defaultWrite 的 `ask`;sensitive 段匹配按 `[\\/]` 双分隔符切段,`\\host\share\.env` 照样 deny。
32
+ * · sensitivePatterns 的 deny 腿照常施加:它按 canonical key 的**路径段**做 glob 匹配,词法 key 足够,
33
+ * 且在 `combinePolicies` 折叠里 deny 恒胜(session 豁免 / accept 域越不过)。
34
+ * · `isExempt`(会话「本会话不再询问」探针)是 name-keyed 的,不做 fs 裁决,任何 lane 都安全,照接。
35
+ *
36
+ * ## 已知残余面(过渡形的边界,不是疏漏)
37
+ *
38
+ * · **symlink 形**:词法裁决看不见符号链接。沙箱里一个名字普通的软链可以指向守卫段(`.ssh` 等),
39
+ * 我们只会给 `ask` 而不是 `deny`;host lane 上 core 会 canonicalize 出真身并 deny。相应地,core 的
40
+ * `unresolvedSymlink ⇒ deny` 那条腿在本 env 下永不触发。注意豁免会话下的口径(codex 复审 E):
41
+ * name-keyed 豁免不看路径,这类词法无害的软链目标在豁免会话里是 **allow** 而非 ask——这是本残余面
42
+ * 在「操作员已授 don't-ask-again」情形下的完整代价,host lane 同情形仍会 deny。
43
+ * · **exempt/accept 域**:沙箱 lane 既无可信 cwd 也无 scratchpad 对应物,故本过渡形**一个自动放行域都
44
+ * 不铸**(见 resolve-spec 的 wiring)。代价是 acceptEdits 在沙箱 lane 退化成与 default 同形(全 ask);
45
+ * 这是 fail-safe 方向,与 host lane「accept 域解析不出 ⇒ 该域不生效」的哨兵先例同口径。
46
+ * · **`cwd` 字段**:继承 `StubExecutionEnv` 的 `"/"` 占位。本 env 只活在 policy 折叠里,core 的两个
47
+ * policy 都不读 `env.cwd`(相对路径基准走 `rootPath` 形参,而我们不给)——它不代表沙箱的工作目录。
48
+ *
49
+ * ## 终局 seam
50
+ *
51
+ * 正解是让写门在**工具执行时刻**拿到真沙箱 env:core [2751] 已排期给 `HookToolContext` 加一个只读的
52
+ * env 能力窄面(174/#22 窗)。那一面到货后,本过渡形整体退役换正解——届时沙箱 lane 与 host lane 走同
53
+ * 一条真 fs 裁决,上面三条残余面一并消失。
54
+ */
55
+ import { posix } from "node:path";
56
+ import { FileError, StubExecutionEnv, err, ok } from "@sema-agent/core";
57
+ /** 词法归一:纯字符串运算,折 `.` / `..` / 重复分隔符,不碰 fs。
58
+ * `posix.normalize` 把 `..` 在根部截断(`/../x` → `/x`),与「沙箱根之上没有东西」的语义一致。
59
+ * 尾部分隔符统一剥掉(根 `/` 除外),让同一目标只有一个 key ——前缀判域靠的就是 key 的唯一性。
60
+ * (core 内部有同形的 `normalizeAbsPathLexically`,但未从包根导出;此处是 Node 标准库的等价运算,
61
+ * 不是它的抄本——若日后 core 导出,这里应改为直接复用。) */
62
+ function normalizeAbsolutePathLexically(path) {
63
+ const collapsed = posix.normalize(path.replace(/^\/+/, "/"));
64
+ return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
65
+ }
66
+ /**
67
+ * 无文件系统的路径裁决 env(见文件头)。只覆写 `absolutePath` 一面,其余全部继承 `StubExecutionEnv`
68
+ * 的 `not_supported` ——**继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会
69
+ * 随 `StubExecutionEnv` 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
70
+ */
71
+ export class LexicalPathExecutionEnv extends StubExecutionEnv {
72
+ absolutePath(path) {
73
+ // POSIX 绝对形是沙箱 lane(全 Linux)上 hand 工具的书面契约形。其余一切——相对路径、`~`、
74
+ // `C:\…`——在 spec 期都无法诚实解析成一个沙箱内的绝对路径,返回错误让 core 落 ask。
75
+ // (反斜杠 UNC 形根本到不了这里:core 在 canonicalizeTarget 入口短路,见文件头「由此得到的裁决语义」。)
76
+ // codex 复审 A(2026-08-05):含 NUL 的字符串不是合法 POSIX 路径(任何 fs 面都写不进去),但词法归一
77
+ // 会照样给它铸出 canonical key——`\0` 尾巴让守卫段 glob 失配,豁免会话下还能拿 allow。拒收让 canon
78
+ // 在豁免咨询**之前**就失败 ⇒ 恒 ask。
79
+ if (path.includes("\u0000")) {
80
+ return Promise.resolve(err(new FileError("not_supported", "this environment adjudicates paths lexically and rejects a path containing a NUL byte (not a representable POSIX path)", path)));
81
+ }
82
+ if (!path.startsWith("/")) {
83
+ return Promise.resolve(err(new FileError("not_supported", "this environment adjudicates paths lexically and cannot resolve a non-absolute path (the sandbox working directory is unknown at spec time)", path)));
84
+ }
85
+ return Promise.resolve(ok(normalizeAbsolutePathLexically(path)));
86
+ }
87
+ }
88
+ //# sourceMappingURL=lexical-path-env.js.map
@@ -21,6 +21,36 @@ import type { Metrics } from "../observability/metrics.js";
21
21
  import type { WorkflowNotifyGate, WorkflowNotifyJournalStore } from "../orchestration/workflow-notify-journal.js";
22
22
  import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
23
23
  import type { CheckpointStoreFull, CostQuotaStore, ImageBake, RateLimiterStore, ServiceWorkflowJournalStore, StoreBackend, ToolResultStoreFull } from "../plugins/store-backend.js";
24
+ import type { ToolApprovalCoordinator } from "../tool-approval.js";
25
+ /** #157 / [2762]§五(sema-comms `audits/failopen-governance-176.md` §7 "Z 类 1 条"):default consecutive-failure
26
+ * count before a reaper leg's swallowed error gets a `warn` line. The ~15 `.catch(() => undefined)` sites
27
+ * below are correct in DIRECTION (a hiccuping periodic sweep must never crash the whole tick loop — one bad
28
+ * leg cannot be allowed to starve its ~15 siblings), but were zero-observability: an operator watching only
29
+ * `warn`/`error` would never learn a leg had been silently broken for days. `5` (not `1`) is deliberate —
30
+ * a transient blip (one DB reconnect, one timeout) is exactly what fail-open exists to absorb without noise;
31
+ * only a STREAK crosses into "diagnosable, not transient". Exported so a future per-leg override is a
32
+ * constructor-arg change, not a magic-number hunt. */
33
+ export declare const REAPER_FAILURE_WARN_THRESHOLD = 5;
34
+ /** The pair of hooks {@link createThrottledReaperCatch} returns — `onSuccess` resets the streak (call on the
35
+ * chain's fulfilled path), `onError` is the `.catch()`-equivalent rejection handler (throttled, never rethrows). */
36
+ export interface ThrottledReaperCatch {
37
+ onSuccess: () => void;
38
+ onError: (err: unknown) => void;
39
+ }
40
+ /**
41
+ * Builds a NAMED, stateful throttle for one reaper leg: attach as `.then(() => guard.onSuccess(), guard.onError)`
42
+ * in place of a bare `.catch(() => undefined)` (the two-arg `.then` form still catches a rejection from any
43
+ * earlier link in the chain — same coverage as the `.catch()` it replaces — while also observing the FULFILLED
44
+ * path, which a plain `.catch()` cannot). Behavior this closure owns, unchanged reaper scheduling/cleanup:
45
+ * - a successful run resets the consecutive-failure streak to 0 (one hiccup mid-outage doesn't inflate the count);
46
+ * - a failure increments the streak; only once the streak reaches `threshold` (default
47
+ * {@link REAPER_FAILURE_WARN_THRESHOLD}) does ONE `warn` line fire (reaper name + streak length + the most
48
+ * recent error's message) — then the streak resets to 0 so a STILL-broken leg reports again after another
49
+ * full `threshold` run, rather than going silent forever after its first report.
50
+ * One instance must be created ONCE per named leg (outside the tick's `setInterval` callback) so the streak
51
+ * survives across ticks — a fresh instance per tick would never accumulate past 1.
52
+ */
53
+ export declare function createThrottledReaperCatch(name: string, logger: Logger, threshold?: number): ThrottledReaperCatch;
24
54
  export interface ReapersCtx {
25
55
  config: ServiceConfig;
26
56
  logger: Logger;
@@ -44,6 +74,10 @@ export interface ReapersCtx {
44
74
  rosterStore: RunnerDeps["rosterStore"];
45
75
  backgroundAgentStore: RunnerDeps["backgroundAgentStore"];
46
76
  mailboxStore: RunnerDeps["mailboxStore"];
77
+ /** #151 车6:流内审批撤卡帧的 live 投递口(收敛器/孤儿代打腿产出的帧经它反查 broker 的活连接)。
78
+ * `TOOL_APPROVAL_ENABLED=false` 的部署恒 undefined ⇒ 收敛器照常收敛,只是不发通知帧(壳侧靠
79
+ * 重连 preamble 对账,见 approval-card.ts 的 `ApprovalRevokeFrame` 顶注)。 */
80
+ toolApproval: ToolApprovalCoordinator | undefined;
47
81
  /** 晚绑(server 造出来才有)——见文件头「位置即契约」①。 */
48
82
  getRunDenySweep: () => ((now: number) => Promise<void>) | undefined;
49
83
  }