@sema-agent/server 7.3.0 → 7.5.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 (78) hide show
  1. package/USAGE.md +63 -0
  2. package/dist/approval-card.d.ts +15 -3
  3. package/dist/approval-card.js +41 -7
  4. package/dist/approval-reconciler.d.ts +120 -16
  5. package/dist/approval-reconciler.js +146 -19
  6. package/dist/boot/coordinators.js +13 -3
  7. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  8. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  9. package/dist/boot/execution-env.js +11 -1
  10. package/dist/boot/org-memory.d.ts +6 -0
  11. package/dist/boot/org-memory.js +1 -1
  12. package/dist/boot/reapers.d.ts +2 -0
  13. package/dist/boot/reapers.js +11 -4
  14. package/dist/boot/resolve-spec.d.ts +3 -2
  15. package/dist/boot/resolve-spec.js +175 -63
  16. package/dist/boot/runner-deps.d.ts +23 -1
  17. package/dist/boot/runner-deps.js +8 -11
  18. package/dist/boot/workflow-orchestration.d.ts +8 -3
  19. package/dist/boot/workflow-orchestration.js +23 -1
  20. package/dist/capabilities/center-prompts.js +4 -1
  21. package/dist/config-center/apply-effective.js +33 -10
  22. package/dist/config-types.d.ts +32 -9
  23. package/dist/config.d.ts +6 -1
  24. package/dist/config.js +65 -12
  25. package/dist/elicitation.js +5 -1
  26. package/dist/env-facts.d.ts +3 -1
  27. package/dist/env-facts.js +3 -1
  28. package/dist/fleet/fleet-bus.d.ts +6 -1
  29. package/dist/fleet/fleet-bus.js +25 -3
  30. package/dist/governance-ask-marks.d.ts +31 -0
  31. package/dist/governance-ask-marks.js +122 -0
  32. package/dist/hooks/hook-runner.d.ts +28 -0
  33. package/dist/hooks/hook-runner.js +180 -24
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +145 -0
  36. package/dist/http/routes/memory-policy.d.ts +2 -1
  37. package/dist/http/routes/memory-policy.js +77 -13
  38. package/dist/http/routes/runs.js +1 -1
  39. package/dist/http/routes/tasks.js +87 -29
  40. package/dist/http/server.d.ts +10 -0
  41. package/dist/http/server.js +29 -12
  42. package/dist/http/wire-types.d.ts +7 -2
  43. package/dist/main.js +51 -8
  44. package/dist/observability/fail-open.d.ts +109 -0
  45. package/dist/observability/fail-open.js +227 -0
  46. package/dist/observability/prompt-manifest.d.ts +17 -0
  47. package/dist/observability/prompt-manifest.js +8 -0
  48. package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
  49. package/dist/orchestration/workflow-notify-journal.js +137 -32
  50. package/dist/parked-decide.js +9 -4
  51. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  52. package/dist/plugins/approval-ask-store-memory.js +3 -2
  53. package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
  54. package/dist/plugins/approval-ask-store-sql.js +9 -2
  55. package/dist/plugins/background-shell-support.d.ts +1 -1
  56. package/dist/plugins/background-shell-support.js +2 -2
  57. package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
  58. package/dist/plugins/checkpoint-store-sql.js +71 -11
  59. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  60. package/dist/plugins/local-checkpoint-store.js +19 -0
  61. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  62. package/dist/plugins/mailbox-store-sql.js +57 -4
  63. package/dist/question.d.ts +18 -14
  64. package/dist/question.js +83 -34
  65. package/dist/runs.d.ts +8 -0
  66. package/dist/runs.js +15 -2
  67. package/dist/runtime-governance.d.ts +18 -0
  68. package/dist/runtime-governance.js +90 -3
  69. package/dist/task-settings.d.ts +16 -21
  70. package/dist/task-settings.js +22 -19
  71. package/dist/tool-approval.d.ts +33 -6
  72. package/dist/tool-approval.js +95 -30
  73. package/dist/trace/core-keyset-guard.d.ts +17 -3
  74. package/dist/trace/project.d.ts +36 -1
  75. package/dist/trace/project.js +55 -2
  76. package/package.json +3 -3
  77. package/dist/boot/lexical-path-env.d.ts +0 -14
  78. package/dist/boot/lexical-path-env.js +0 -116
@@ -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.
@@ -26,9 +26,15 @@ import type { RunnerDeps } from "@sema-agent/core";
26
26
  import type { ServiceConfig } from "../config.js";
27
27
  import type { Logger } from "../observability/logger.js";
28
28
  import type { Metrics } from "../observability/metrics.js";
29
+ import { type OrgMemoryDirectory } from "../org-memory-admission.js";
29
30
  export interface OrgMemoryAdmissionWiring {
30
31
  memoryScopeAdmission: RunnerDeps["memoryScopeAdmission"];
31
32
  deploymentMemoryScopes: RunnerDeps["deploymentMemoryScopes"];
33
+ /** §7 第二消费者收编:同一个目录实例也喂 memory-policy 面的 `org:` 属主门(`ServiceDeps.orgMemoryDirectory`)。
34
+ * **同实例**是这条收编的全部意义——「谁属于 org:acme」在一个进程里必须只有一个答案,两面共享同一份
35
+ * TTL 缓存/退避窗/gen 高水位;各建各的目录会让准入门与策略面在吊销窗内公开分歧。缺席=无目录源
36
+ * (单用户/无 center 且无 env 表)⇒ 策略面逐字保持收编前的 operator-only 行为。 */
37
+ directory: OrgMemoryDirectory | undefined;
32
38
  }
33
39
  /** 依赖收窄到真实消费面(warn/inc 各一手)——接口隔离让测试用真形字面量,零铸形([2704] §1)。 */
34
40
  export declare function createOrgMemoryAdmissionWiring(opts: {
@@ -82,6 +82,6 @@ export function createOrgMemoryAdmissionWiring(opts) {
82
82
  },
83
83
  })
84
84
  : undefined;
85
- return { memoryScopeAdmission, deploymentMemoryScopes };
85
+ return { memoryScopeAdmission, deploymentMemoryScopes, directory };
86
86
  }
87
87
  //# sourceMappingURL=org-memory.js.map
@@ -68,6 +68,8 @@ export interface ReapersCtx {
68
68
  imageBakes: ImageBake | undefined;
69
69
  worktreeReap: (() => Promise<void>) | undefined;
70
70
  workflowNotifyGate: WorkflowNotifyGate | undefined;
71
+ /** [2995]/[2999] recover 的两半 opts(boot 与周期扫描共用,单点合成在 workflow-orchestration)。 */
72
+ workflowRecoverOpts: Parameters<WorkflowNotifyGate["recover"]>[0];
71
73
  workflowJournalStore: ServiceWorkflowJournalStore | undefined;
72
74
  sqlWorkflowRunStore: WorkflowRunStore | undefined;
73
75
  workflowNotifyJournal: WorkflowNotifyJournalStore | undefined;
@@ -74,7 +74,7 @@ export function createThrottledReaperCatch(name, logger, threshold = REAPER_FAIL
74
74
  }
75
75
  /** 起后台维护 tick,返回定时器 handle(收尾段 clearInterval 用)。 */
76
76
  export function startReapers(ctx) {
77
- const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, getRunDenySweep, } = ctx;
77
+ const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, getRunDenySweep, } = ctx;
78
78
  // S7 (SILENT-FALLBACK P0-d): the sweeps' return counts were discarded — an instance death that batch-fails
79
79
  // N orphans was indistinguishable from organic failures. Count + log ONLY when a sweep flipped rows (>0),
80
80
  // so healthy ticks stay silent. Tolerant of void-returning stores (typeof guard).
@@ -112,8 +112,15 @@ export function startReapers(ctx) {
112
112
  const scratchpadSweepStaleGuard = createThrottledReaperCatch("scratchpad_sweep_stale", logger);
113
113
  const approvalReconcileGuard = createThrottledReaperCatch("approval_reconcile", logger);
114
114
  let approvalReconcileInFlight = false; // 车5 §8 C-8:重入守卫(邻居四先例同形——本腿时长随 PARKING 行数增长)
115
- // #151 车5(design/172 §3.0 对账收敛器 + 崩溃恢复扫描):**总开关默认 OFF 本腿根本不注册**
116
- // (`STREAM_APPROVAL_ENABLED=false` 的部署一行库都不扫,零新增查询)。开且有 durable backend 时才建。
115
+ // #151 车5(design/172 §3.0 对账收敛器 + 崩溃恢复扫描):建腿条件 = 开关开 ∧ **有 `StoreBackend`**
116
+ // (见下方判据行 —— 是**任意** backend,不限 SQL twins:`local` 车道也算,它的 `approvalAsk()` 是进程内
117
+ // `InMemoryApprovalAskStore`,扫的是内存不是库)。无 backend 或显式关 ⇒ 本腿根本不注册,零扫描。
118
+ // ⚠️ 运维读数已反转:总开关默认**在树上已是 ON**(发布线上落在 7.4.0 之后的下一发布;版本坐标与
119
+ // 「别按 /health 版本号推默认」的告诫见属主 config.ts 的 `streamApprovalConfig()` 头注),
120
+ // 所以「零新增扫描」从**默认态**变成了 **opt-out 态** —— 任何配了 backend 的部署(含单用户 local)
121
+ // 默认都会注册本腿并每 tick 扫两段(PARKING + 孤儿 STREAM_PENDING,每段
122
+ // `STREAM_APPROVAL_RECONCILE_BATCH` 行,默认 200);SQL twins 上这就是**真新增的周期性查询**,local 上
123
+ // 只是内存遍历。存量部署升级后要回到零扫描,需显式把开关关掉。
117
124
  // 判据 1/2 的两个读口按能力面接:checkpoint 店的窄读口只有 SQL twins 有(local 车道的店没有这个面),
118
125
  // 缺席 ⇒ 判据 1 恒不命中(诚实降级,行落 ②③④⑤),不是「假装没 checkpoint 就该 DENY」。
119
126
  const approvalReconciler = (() => {
@@ -354,7 +361,7 @@ export function startReapers(ctx) {
354
361
  // run whose in-process notify was lost, AND finalizes-as-abandoned a `running` run orphaned past the grace
355
362
  // window (core never resumes/reaps a prior `running` row, so nothing else would). Idempotent; best-effort.
356
363
  void workflowNotifyGate
357
- ?.recover({ orphanGraceMs: config.workflowOrphanGraceMs })
364
+ ?.recover(workflowRecoverOpts)
358
365
  .then(() => workflowNotifyRecoverGuard.onSuccess(), workflowNotifyRecoverGuard.onError);
359
366
  // SVC-2 (adversarial-review HIGH): time-based GC of the workflow_journal table (the heaviest, TaskResult-bearing
360
367
  // one) — the per-run deleteByRun has no run-store reap hook, so this bounded sweep is what stops unbounded
@@ -73,8 +73,9 @@ export interface ResolveSpecCtx {
73
73
  * `createDurableQuestionPolicy()`(恒 ask ⇒ 恒 durable park)。在场 ⇒ **判决时**按活流上下文分腿:
74
74
  * 活流腿(bg/SSE,coordinator.runWithContext 包裹且投递面此刻可达,ALS 判)allow——工具执行落到
75
75
  * RunnerDeps.onQuestion 的 coordinator,问正在 tail 流的活人;无活流腿(sync /v1/tasks、verify/cascade、
76
- * durable resume 驱动、断连后的 detach 腿)ask——durable park 原语义逐字保留(此时执行只会拿到 headless
77
- * 空答,park 才是把问题送到人面前的那条腿)
76
+ * durable resume 驱动、断连后的 detach 腿)ask——durable park 原语义逐字保留(#166 后无活流腿放行执行
77
+ * 也不会产出空答:coordinator 无 ALS ctx ⇒ 冻结 `{kind:"unavailable"}`(src/question.ts),永不悬挂;
78
+ * park 仍是把问题送到人面前的唯一那条腿,正当性不变、只是反事实前提换了)。
78
79
  * 工具名字面量与 server.ts 的 pre-CAS 守卫同源("AskUserQuestion",core 未根导出常量)。
79
80
  * ⚠️ **单一属主**(复审 A2):AskUserQuestion 的 durable 判决只有这一处。任何需要「同参重建」这条判决的
80
81
  * 地方(main.ts 的 parkedReviveInheritedGate 父约束链)必须调本工厂,不得自折 core 原形——两份拷贝里