@sema-agent/server 7.106.0 → 7.108.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 (70) hide show
  1. package/MIGRATION.md +52 -0
  2. package/README.md +2 -2
  3. package/USAGE.md +40 -4
  4. package/dist/approval-ask-audit-store.d.ts +62 -4
  5. package/dist/approval-ask-audit-store.js +67 -11
  6. package/dist/approval.js +16 -13
  7. package/dist/boot/coordinators.d.ts +4 -0
  8. package/dist/boot/coordinators.js +3 -2
  9. package/dist/boot/shutdown.d.ts +8 -0
  10. package/dist/boot/shutdown.js +31 -8
  11. package/dist/boot/stage-06-runners.d.ts +1 -0
  12. package/dist/boot/stage-06-runners.js +6 -3
  13. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  14. package/dist/boot/stage-08-reapers.d.ts +1 -0
  15. package/dist/boot/stage-09-leader.d.ts +1 -0
  16. package/dist/boot/stage-10-http-server.d.ts +1 -0
  17. package/dist/boot/stage-10-http-server.js +3 -1
  18. package/dist/brain.js +2 -1
  19. package/dist/capabilities/composite-members.d.ts +1 -1
  20. package/dist/capabilities/composite-members.js +1 -1
  21. package/dist/config-catalog.d.ts +6 -1
  22. package/dist/config-catalog.js +34 -17
  23. package/dist/config-center/apply-effective.js +2 -0
  24. package/dist/config-center/types.d.ts +7 -0
  25. package/dist/deployment-governance.js +2 -1
  26. package/dist/fleet-client.d.ts +10 -2
  27. package/dist/fleet-client.js +14 -8
  28. package/dist/http/memory-compliance-failure-reply.d.ts +41 -0
  29. package/dist/http/memory-compliance-failure-reply.js +73 -0
  30. package/dist/http/memory-store-refusal-reply.d.ts +2 -2
  31. package/dist/http/resume-legs.js +3 -2
  32. package/dist/http/routes/capabilities.js +0 -2
  33. package/dist/http/routes/diagnostics.js +2 -0
  34. package/dist/http/routes/memory-compliance.js +2 -34
  35. package/dist/http/routes/memory-origin.d.ts +1 -1
  36. package/dist/http/routes/sessions.js +3 -11
  37. package/dist/http/routes/side-query.js +7 -2
  38. package/dist/http/routes/tasks.js +2 -1
  39. package/dist/memory-store-refusal.d.ts +2 -1
  40. package/dist/model-entry-headers.d.ts +41 -0
  41. package/dist/model-entry-headers.js +61 -0
  42. package/dist/model-entry-refusal.d.ts +6 -0
  43. package/dist/model-entry-refusal.js +4 -0
  44. package/dist/model-reach-scope.d.ts +55 -0
  45. package/dist/model-reach-scope.js +16 -0
  46. package/dist/model-unreachable-notice.d.ts +38 -0
  47. package/dist/model-unreachable-notice.js +72 -0
  48. package/dist/observability/fail-open.d.ts +4 -0
  49. package/dist/observability/fail-open.js +4 -0
  50. package/dist/observability/run-terminal.d.ts +8 -5
  51. package/dist/outbound-dispatcher.d.ts +69 -0
  52. package/dist/outbound-dispatcher.js +123 -3
  53. package/dist/plugins/park-commit-watch.d.ts +48 -0
  54. package/dist/plugins/park-commit-watch.js +87 -0
  55. package/dist/router/route-orchestration.js +7 -2
  56. package/dist/runs.js +3 -2
  57. package/dist/runtime-governance.js +3 -2
  58. package/dist/task-settings.d.ts +30 -17
  59. package/dist/task-settings.js +15 -4
  60. package/dist/tool-approval.d.ts +62 -0
  61. package/dist/tool-approval.js +67 -3
  62. package/dist/tool-rule-names.d.ts +25 -0
  63. package/dist/tool-rule-names.js +5 -0
  64. package/dist/trace/core-keyset-guard.d.ts +13 -1
  65. package/dist/trace/engine-notice-wire.d.ts +3 -1
  66. package/dist/trace/engine-notice-wire.js +2 -0
  67. package/dist/trace/project.d.ts +6 -3
  68. package/dist/trace/self-minted-wire.d.ts +71 -0
  69. package/dist/trace/self-minted-wire.js +28 -0
  70. package/package.json +2 -2
@@ -62,9 +62,28 @@
62
62
  * `outbound_proxy`,不进 `/health`、不进公告、不进租户 wire(代理那一跳失败的终局句只说「经代理 + 哪只变量」,F3 裁定)。代理变量不可用(undici 构造即拒:无 scheme、`ftp:`、带路径 / 查询)⇒
63
63
  * **拒启**(与 Node 24 `NODE_USE_ENV_PROXY=1` 遇坏值的行为同向)—— 静默直连 = 运维以为出网走了代理而实际没有,那是出网策略面的
64
64
  * fail-open;拒因只回显 display-safe 值。
65
+ *
66
+ * ── 建连失败的观察(S-925,7.108.0;test [ref] ② Windows 用户 VPN 中途切换)──────────────────────────────
67
+ * 病:建连失败(`UND_ERR_CONNECT_TIMEOUT` 一族)只有 undici 原句,不点名起服时读进的代理、不给「环境变了要重启」的出路。
68
+ * 观察点 = undici 的**公开诊断通道**(`undici:request:create` / `:headers` / `:error`,进程级按名订阅,npm undici 与 Node 内建那一份
69
+ * 同名同发):不包 handler、不动 dispatch 的热路径。{@link installOutboundDispatcher} 装一次:
70
+ * ① 每条请求的建连期失败(码在闭集 {@link OUTBOUND_CONNECT_FAILURE_CODES} 里、且这条请求还没收到过响应头)⇒ 一条结构化 warn
71
+ * `outbound_connect_failed { targetHost, code, proxyInUse, proxyReadAtMs, sinceBootMs, hint }`(`proxyInUse` = 这一跳走的代理**变量名**,
72
+ * 不打值;直连 ⇒ `null`)—— 全进程的出网都算(模型、中心、工具 …),不只模型;
73
+ * ② 最近一次建连失败 + 生效代理的**名字**与读取时刻 ⇒ `GET /v1/diagnostics/wiring` 的 `outbound` 段({@link readOutboundWiring});
74
+ * ③ 请求若创建在一次**模型调用**的作用域里(`model-reach-scope.ts`),结局记到那次调用上 —— 模型调用终态时由
75
+ * `model-unreachable-notice.ts` 决定要不要给这条腿的用户铸 `server.model_unreachable`(只落日志不算闭环:运营用户看不到日志)。
76
+ * 代理 CONNECT 请求本身(`method === "CONNECT"`,打向代理的那条)不单独计 —— 它的失败会以 {@link OutboundProxyError} 的形态落在
77
+ * 目标请求上,那一条才知道「要去哪」。Bun 的原生 fetch 不经 undici ⇒ 通道不发、三件都没有(如实:与回环直连同一条 Bun 例外)。
65
78
  */
66
79
  import { Dispatcher } from "undici";
67
80
  import type { Logger } from "./observability/logger.js";
81
+ /** 三只变量的读值(boot 冻结;空串 = 未设)。 */
82
+ interface OutboundProxyEnv {
83
+ readonly httpProxy: string;
84
+ readonly httpsProxy: string;
85
+ readonly noProxy: string;
86
+ }
68
87
  /**
69
88
  * 出网代理姿态(`build*`:纯数据)—— 启动日志 `outbound_proxy` 行与配置目录共读的**同一份**投影。
70
89
  * · `httpProxy` / `httpsProxy`:http: / https: 目标**实际**走的代理(display-safe;`null` = 直连)。`httpsProxy` 在 `HTTPS_PROXY`
@@ -110,7 +129,56 @@ export declare const OUTBOUND_PROXY_INVALID_CODE = "config.outbound_proxy_invali
110
129
  export declare function createOutboundDispatcher(env?: NodeJS.ProcessEnv): {
111
130
  dispatcher: Dispatcher;
112
131
  posture: OutboundProxyPosture;
132
+ env: OutboundProxyEnv;
133
+ };
134
+ /**
135
+ * 建连期失败码 —— **闭集**(码 → 给运维的一句读法)。只收**只可能**在建立连接时出现的码:undici 的建连截止
136
+ * (`UND_ERR_CONNECT_TIMEOUT`,TCP + TLS 没在传输层的建连时限内完成)与 OS 的拒连 / 解析不了 / 无路由。可能出现在**已建立**
137
+ * 连接上的码(`ECONNRESET` / `ETIMEDOUT` / `UND_ERR_SOCKET`)刻意不收 —— 它们判不出「够不着」,收进来就会把一次中途断流
138
+ * 说成「模型不可达、去查 VPN」。加一个码 = 加一行并写明它为什么只可能在建连时出现。
139
+ */
140
+ export declare const OUTBOUND_CONNECT_FAILURE_CODES: {
141
+ readonly UND_ERR_CONNECT_TIMEOUT: "the TCP + TLS connect did not complete within the transport's connect deadline";
142
+ readonly ECONNREFUSED: "the peer actively refused the connection (nothing listening there, or a firewall reset it)";
143
+ readonly ENOTFOUND: "the host name did not resolve";
144
+ readonly EAI_AGAIN: "the DNS lookup failed temporarily";
145
+ readonly EHOSTUNREACH: "there is no route to the host";
146
+ readonly ENETUNREACH: "the network is unreachable";
113
147
  };
148
+ export type OutboundConnectFailureCode = keyof typeof OUTBOUND_CONNECT_FAILURE_CODES;
149
+ /** 一次建连失败的事实(纯数据:日志行、诊断段、模型调用结局同一份)。`proxyInUse` = 这一跳走的代理**变量名**(不打值),直连 ⇒ `null`。 */
150
+ export interface OutboundConnectFailure {
151
+ readonly targetHost: string;
152
+ readonly code: OutboundConnectFailureCode;
153
+ readonly proxyInUse: string | null;
154
+ }
155
+ /**
156
+ * 一只请求错误是不是**建连期失败**:在错误图里找第一枚闭集码;路上遇到 {@link OutboundProxyError} ⇒ 这一跳走了代理,
157
+ * 记下它的变量名。找不到闭集码 ⇒ `undefined`(不是建连失败:TLS、中途断、取消、超时、代理 CONNECT 非 200 …)。
158
+ * 纯函数、不抛(只读 `code` / `cause` / `errors` 三个自有数据位)。
159
+ */
160
+ export declare function classifyOutboundConnectFailure(error: unknown): {
161
+ readonly code: OutboundConnectFailureCode;
162
+ readonly proxyInUse: string | null;
163
+ } | undefined;
164
+ /**
165
+ * `GET /v1/diagnostics/wiring` 的 `outbound` 段(`build*`:纯数据)。**名不打值**:代理只报是哪只变量供的(`HTTP_PROXY` / `HTTPS_PROXY`;
166
+ * https 目标在 `HTTPS_PROXY` 缺席时回落 `HTTP_PROXY`,undici 的读法),`NO_PROXY` 只报在不在场;代理地址一个字节都不上本段
167
+ * (地址在启动日志 `outbound_proxy` 行,display-safe)。未安装(不是由 main.ts / run-local 装起来的进程)⇒ `null`。
168
+ */
169
+ export interface OutboundWiring {
170
+ readonly proxy: {
171
+ readonly http: "HTTP_PROXY" | null;
172
+ readonly https: "HTTPS_PROXY" | "HTTP_PROXY" | null;
173
+ };
174
+ readonly noProxy: boolean;
175
+ readonly loopbackDirect: boolean;
176
+ readonly proxyReadAtMs: number;
177
+ readonly lastConnectFailure: (OutboundConnectFailure & {
178
+ readonly atMs: number;
179
+ }) | null;
180
+ }
181
+ export declare function readOutboundWiring(): OutboundWiring | null;
114
182
  /** 安装结果 = 姿态 + 这条规则在本进程**是否生效**(运行时的 fetch 读不读 undici 全局槽 —— Node 读、Bun 不读)。 */
115
183
  export interface OutboundDispatcherInstall extends OutboundProxyPosture {
116
184
  readonly loopbackDirect: boolean;
@@ -122,4 +190,5 @@ export interface OutboundDispatcherInstall extends OutboundProxyPosture {
122
190
  export declare function installOutboundDispatcher(logger: Logger, env?: NodeJS.ProcessEnv): OutboundDispatcherInstall;
123
191
  /** 测试专用:撤回安装(槽还给装之前的那只,本只关掉),下一次 {@link installOutboundDispatcher} 重新读 env。 */
124
192
  export declare function resetOutboundDispatcherForTest(): void;
193
+ export {};
125
194
  //# sourceMappingURL=outbound-dispatcher.d.ts.map
@@ -1,6 +1,9 @@
1
1
  import { Agent, Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, Pool, setGlobalDispatcher } from "undici";
2
2
  import { isIP } from "node:net";
3
+ import diagnosticsChannel from "node:diagnostics_channel";
3
4
  import { buildDisplaySafeUrl, DISPLAY_SAFE_URL_TOKEN } from "./trace/redact.js";
5
+ import { recordFailOpen } from "./observability/fail-open.js";
6
+ import { currentModelCallReach } from "./model-reach-scope.js";
4
7
  function readOutboundProxyEnv(env = process.env) {
5
8
  return {
6
9
  httpProxy: env.http_proxy ?? env.HTTP_PROXY ?? "",
@@ -173,7 +176,120 @@ export function createOutboundDispatcher(env = process.env) {
173
176
  catch (err) {
174
177
  throw refuse(err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : "invalid");
175
178
  }
176
- return { dispatcher: new OutboundDispatcher(new Agent(), proxied), posture: postureOf(v) };
179
+ return { dispatcher: new OutboundDispatcher(new Agent(), proxied), posture: postureOf(v), env: v };
180
+ }
181
+ export const OUTBOUND_CONNECT_FAILURE_CODES = {
182
+ UND_ERR_CONNECT_TIMEOUT: "the TCP + TLS connect did not complete within the transport's connect deadline",
183
+ ECONNREFUSED: "the peer actively refused the connection (nothing listening there, or a firewall reset it)",
184
+ ENOTFOUND: "the host name did not resolve",
185
+ EAI_AGAIN: "the DNS lookup failed temporarily",
186
+ EHOSTUNREACH: "there is no route to the host",
187
+ ENETUNREACH: "the network is unreachable",
188
+ };
189
+ const isConnectFailureCode = (c) => typeof c === "string" && Object.hasOwn(OUTBOUND_CONNECT_FAILURE_CODES, c);
190
+ const CONNECT_ERROR_GRAPH_BUDGET = 16;
191
+ export function classifyOutboundConnectFailure(error) {
192
+ const queue = [error];
193
+ let proxyInUse = null;
194
+ for (let seen = 0; queue.length > 0 && seen < CONNECT_ERROR_GRAPH_BUDGET; seen++) {
195
+ const node = queue.shift();
196
+ if (typeof node !== "object" || node === null)
197
+ continue;
198
+ if (node instanceof OutboundProxyError)
199
+ proxyInUse = node.from;
200
+ const code = Object.getOwnPropertyDescriptor(node, "code")?.value;
201
+ if (isConnectFailureCode(code))
202
+ return { code, proxyInUse };
203
+ const cause = Object.getOwnPropertyDescriptor(node, "cause")?.value;
204
+ if (cause !== undefined)
205
+ queue.push(cause);
206
+ const errors = Object.getOwnPropertyDescriptor(node, "errors")?.value;
207
+ if (Array.isArray(errors))
208
+ queue.push(...errors.slice(0, CONNECT_ERROR_GRAPH_BUDGET));
209
+ }
210
+ return undefined;
211
+ }
212
+ function targetHostOf(origin) {
213
+ const text = typeof origin === "string" ? origin : origin instanceof URL ? origin.href : "";
214
+ return URL.canParse(text) ? new URL(text).host : "(unreadable origin)";
215
+ }
216
+ let observer;
217
+ const requestCalls = new WeakMap();
218
+ const answeredRequests = new WeakSet();
219
+ const requestOf = (message) => {
220
+ const r = typeof message === "object" && message !== null ? Reflect.get(message, "request") : undefined;
221
+ return typeof r === "object" && r !== null ? r : undefined;
222
+ };
223
+ function startConnectObserver(logger, nowMs) {
224
+ const subs = [];
225
+ const current = {
226
+ proxyReadAtMs: nowMs,
227
+ unsubscribe: () => {
228
+ for (const [name, fn] of subs)
229
+ diagnosticsChannel.unsubscribe(name, fn);
230
+ },
231
+ };
232
+ const guard = (site, fn) => (message) => {
233
+ try {
234
+ fn(message);
235
+ }
236
+ catch (e) {
237
+ recordFailOpen("server.outbound.connect-observer-threw", `site=${site} err=${e instanceof Error ? e.name : typeof e}`);
238
+ }
239
+ };
240
+ const onCreate = guard("create", (message) => {
241
+ const request = requestOf(message);
242
+ const call = currentModelCallReach();
243
+ if (request !== undefined && call !== undefined)
244
+ requestCalls.set(request, call);
245
+ });
246
+ const onHeaders = guard("headers", (message) => {
247
+ const request = requestOf(message);
248
+ if (request === undefined)
249
+ return;
250
+ answeredRequests.add(request);
251
+ const call = requestCalls.get(request);
252
+ if (call !== undefined)
253
+ call.last = { kind: "reached" };
254
+ });
255
+ const onError = guard("error", (message) => {
256
+ const request = requestOf(message);
257
+ if (request === undefined || Reflect.get(request, "method") === "CONNECT")
258
+ return;
259
+ const call = requestCalls.get(request);
260
+ const verdict = answeredRequests.has(request) ? undefined : classifyOutboundConnectFailure(Reflect.get(message, "error"));
261
+ if (verdict === undefined) {
262
+ if (call !== undefined)
263
+ call.last = { kind: "other" };
264
+ return;
265
+ }
266
+ const failure = { targetHost: targetHostOf(Reflect.get(request, "origin")), ...verdict };
267
+ current.last = { ...failure, atMs: Date.now() };
268
+ logger.warn("outbound_connect_failed", {
269
+ ...failure,
270
+ proxyReadAtMs: current.proxyReadAtMs,
271
+ sinceBootMs: Math.round(process.uptime() * 1000),
272
+ hint: `${OUTBOUND_CONNECT_FAILURE_CODES[failure.code]} — ${failure.proxyInUse !== null ? `this hop went through the proxy from ${failure.proxyInUse} (read once at boot)` : "this hop was a direct connection"}; if the VPN / proxy changed since boot, restart the server so it re-reads HTTP(S)_PROXY / NO_PROXY`,
273
+ });
274
+ if (call !== undefined)
275
+ call.last = { kind: "connect_failed", failure };
276
+ });
277
+ subs.push(["undici:request:create", onCreate], ["undici:request:headers", onHeaders], ["undici:request:error", onError]);
278
+ for (const [name, fn] of subs)
279
+ diagnosticsChannel.subscribe(name, fn);
280
+ return current;
281
+ }
282
+ export function readOutboundWiring() {
283
+ if (installed === undefined || observer === undefined)
284
+ return null;
285
+ const v = installed.env;
286
+ return {
287
+ proxy: { http: v.httpProxy !== "" ? "HTTP_PROXY" : null, https: v.httpsProxy !== "" ? "HTTPS_PROXY" : v.httpProxy !== "" ? "HTTP_PROXY" : null },
288
+ noProxy: postureOf(v).noProxy !== null,
289
+ loopbackDirect: installed.result.loopbackDirect,
290
+ proxyReadAtMs: observer.proxyReadAtMs,
291
+ lastConnectFailure: observer.last ?? null,
292
+ };
177
293
  }
178
294
  const GLOBAL_DISPATCHER_SLOTS = [Symbol.for("undici.globalDispatcher.1"), Symbol.for("undici.globalDispatcher.2")];
179
295
  let installed;
@@ -181,7 +297,7 @@ export function installOutboundDispatcher(logger, env = process.env) {
181
297
  if (installed !== undefined)
182
298
  return installed.result;
183
299
  const prior = getGlobalDispatcher();
184
- const { dispatcher, posture } = createOutboundDispatcher(env);
300
+ const { dispatcher, posture, env: proxyEnv } = createOutboundDispatcher(env);
185
301
  setGlobalDispatcher(dispatcher);
186
302
  const loopbackDirect = GLOBAL_DISPATCHER_SLOTS.every((slot) => Reflect.get(globalThis, slot) === dispatcher);
187
303
  const result = { ...posture, loopbackDirect };
@@ -196,10 +312,14 @@ export function installOutboundDispatcher(logger, env = process.env) {
196
312
  else {
197
313
  logger.info("outbound_proxy", { ...result, runtime });
198
314
  }
199
- installed = { dispatcher, prior, result };
315
+ installed = { dispatcher, prior, result, env: proxyEnv };
316
+ observer?.unsubscribe();
317
+ observer = startConnectObserver(logger, Date.now());
200
318
  return result;
201
319
  }
202
320
  export function resetOutboundDispatcherForTest() {
321
+ observer?.unsubscribe();
322
+ observer = undefined;
203
323
  if (installed === undefined)
204
324
  return;
205
325
  const { dispatcher, prior } = installed;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 🔴 **S-929 F1(7.108.0 [ref] 合并复审 codex 腿 A ①,[high] 回归修)—— park 提交的唯一观察点。**
3
+ *
4
+ * core 的 durable park 就是对本仓 checkpoint 店的一次 `put`(SQL 孪生 / 本地文件店都是本仓的 `CheckpointStore` 实现,
5
+ * 一只实例、四个消费点 —— 见 `boot/stage-06-runners.ts` 的构造注)。审批协调器交出 park 路由(ask 回调返回 `"unavailable"`)
6
+ * **之后** core 才铸行、才 `put`;两者之间隔着 core 自己的 park 前置(会话叶、环境快照…)。所以「ask 回调返回」**不是**
7
+ * 「park 落盘」,有序关停若把前者当后者,就会在 `put` 之下关掉连接池(修前病:park 行没提交,审计账本却已记「结清」)。
8
+ *
9
+ * 本模块给店加**一层**装饰(两店同用,不读 core 私有键 —— [ref]):
10
+ * ① **计在飞 `put`**:`quiesce(deadlineMs)` 等到零或到期(绝对时刻,`Date.now()` 域),如实返回残余数 —— 收尾链在
11
+ * `backend.close()` 之前等它(`boot/shutdown.ts`),审批协调器的关停收尾也等它(`ToolApprovalCoordinator.drainForShutdown`);
12
+ * ② **报 park 提交** —— 「这只店里有了这一行」的两种观察,同一件事实:一次 `put` 落定(resolve);或一次 `put` 抛错之后,同一 token 的
13
+ * `get` 读到了行(写已落、回执丢了 —— core 的挂起序正是用这一次回读确认提交的,本层观察的就是那一次读,不另读;车内 codex r1)。
14
+ * 那一行是审批停车行(`pendingAction.kind === "tool_approval"`,kind 先判 —— core d.ts「Every consumer that reads the tool fields MUST
15
+ * branch on `kind` first」)⇒ 把**停车行自己陈述的**调用身份交给订阅者({@link CommittedParkCalls})。抛错且回读不到 = 没提交,不报。
16
+ * 身份取自**读到的那一行**(不是本进程想写的那一只):哪怕 token 撞了别人的行,报出去的也只是那一行真握着的调用。
17
+ *
18
+ * 订阅者契约:**不得抛**。订阅者跑在 `put` 已提交之后、返回给 core 之前 —— 一只抛错的订阅者会把一次**已提交**的 park 报成失败。
19
+ * 唯一的订阅者是审批协调器(写审计 settle 行,那只 sink 自吞失败并按 [ref] 留痕),它在自己那一侧兜住(同 tag 扩射程,见协调器)。
20
+ */
21
+ import type { CheckpointStore } from "@sema-agent/core";
22
+ /**
23
+ * 一只**已提交**的审批停车行说它握着哪些调用(停车行自己的陈述,原样,不是推断):
24
+ * · `sessionId` —— 行停住的会话 `cp.sessionId`(执行这次调用的会话:委派 / 后台子代的行落在**子会话**上)。core 铸行用的是 prepare-task 的
25
+ * 同一个 `sessionId`,它也是同一次调用的 ask 上的 `sourceTaskId`(`askSourceIdentity`)⇒ 订阅者按**相等**配,不做别名(车 SO:
26
+ * 调用 id 只在 run 内唯一,宿主会话若当子代的等价别名,宿主停车行会冒充子代的提交);
27
+ * · `toolCallIds` —— 停在门前的那只调用(`pendingAction.toolCallId`)∪ 同一条助手消息里**尚未执行**的调用
28
+ * (`batchToolCallIds − completedCallIds`,core d.ts「#k+1..N are still pending」):那些调用随这一行一起被持久挂起,
29
+ * 续跑时重新过门 —— 它们的等待同样由这一行承载。
30
+ */
31
+ export interface CommittedParkCalls {
32
+ readonly sessionId: string;
33
+ readonly toolCallIds: readonly string[];
34
+ }
35
+ /** 装饰层的观察面(`create*` 产出的活对象的一半;另一半是装饰后的店本身)。 */
36
+ export interface ParkCommitWatch {
37
+ /** 订阅 park 提交(见模块头「订阅者契约」:不得抛)。 */
38
+ onParkCommitted(listener: (calls: CommittedParkCalls) => void): void;
39
+ /** 等在飞 `put` 归零或到 `deadlineMs`(绝对时刻,`Date.now()` 域);返回那一刻的残余在飞数(0 = 静默了)。从不抛。 */
40
+ quiesce(deadlineMs: number): Promise<{
41
+ inFlight: number;
42
+ }>;
43
+ }
44
+ export declare function createParkCommitWatch<S extends Pick<CheckpointStore, "put" | "get">>(inner: S): {
45
+ store: S;
46
+ watch: ParkCommitWatch;
47
+ };
48
+ //# sourceMappingURL=park-commit-watch.d.ts.map
@@ -0,0 +1,87 @@
1
+ function committedParkCallsOf(cp) {
2
+ const pa = cp.pendingAction;
3
+ if (pa.kind !== "tool_approval")
4
+ return undefined;
5
+ const completed = new Set(pa.completedCallIds);
6
+ const toolCallIds = [pa.toolCallId, ...pa.batchToolCallIds.filter((id) => id !== pa.toolCallId && !completed.has(id))];
7
+ return { sessionId: cp.sessionId, toolCallIds };
8
+ }
9
+ const UNCONFIRMED_PUT_TOKENS_MAX = 256;
10
+ export function createParkCommitWatch(inner) {
11
+ const listeners = [];
12
+ let inFlight = 0;
13
+ const idleWaiters = new Set();
14
+ const unconfirmed = new Set();
15
+ const notify = (row) => {
16
+ const calls = committedParkCallsOf(row);
17
+ if (calls !== undefined)
18
+ for (const l of listeners)
19
+ l(calls);
20
+ };
21
+ const put = async (token, cp) => {
22
+ inFlight += 1;
23
+ try {
24
+ try {
25
+ await inner.put(token, cp);
26
+ }
27
+ catch (err) {
28
+ unconfirmed.add(token);
29
+ if (unconfirmed.size > UNCONFIRMED_PUT_TOKENS_MAX) {
30
+ const oldest = unconfirmed.values().next();
31
+ if (oldest.done !== true)
32
+ unconfirmed.delete(oldest.value);
33
+ }
34
+ throw err;
35
+ }
36
+ notify(cp);
37
+ }
38
+ finally {
39
+ inFlight -= 1;
40
+ if (inFlight === 0)
41
+ for (const wake of [...idleWaiters])
42
+ wake();
43
+ }
44
+ };
45
+ const get = async (token) => {
46
+ if (!unconfirmed.has(token))
47
+ return inner.get(token);
48
+ unconfirmed.delete(token);
49
+ const row = await inner.get(token);
50
+ if (row !== null)
51
+ notify(row);
52
+ return row;
53
+ };
54
+ const store = new Proxy(inner, {
55
+ get(target, prop) {
56
+ if (prop === "put")
57
+ return put;
58
+ if (prop === "get")
59
+ return get;
60
+ const v = Reflect.get(target, prop, target);
61
+ return typeof v === "function" ? v.bind(target) : v;
62
+ },
63
+ });
64
+ const watch = {
65
+ onParkCommitted(listener) {
66
+ listeners.push(listener);
67
+ },
68
+ quiesce(deadlineMs) {
69
+ if (inFlight === 0 || Date.now() >= deadlineMs)
70
+ return Promise.resolve({ inFlight });
71
+ return new Promise((resolve) => {
72
+ const timer = setTimeout(() => {
73
+ idleWaiters.delete(wake);
74
+ resolve({ inFlight });
75
+ }, deadlineMs - Date.now());
76
+ const wake = () => {
77
+ clearTimeout(timer);
78
+ idleWaiters.delete(wake);
79
+ resolve({ inFlight: 0 });
80
+ };
81
+ idleWaiters.add(wake);
82
+ });
83
+ },
84
+ };
85
+ return { store, watch };
86
+ }
87
+ //# sourceMappingURL=park-commit-watch.js.map
@@ -1,5 +1,6 @@
1
1
  import { COARSE_SHELL_TOOLS } from "@sema-agent/core";
2
2
  import { operatorAlwaysHumanAsk } from "../operator-ask.js";
3
+ import { heldRuleName } from "../tool-rule-names.js";
3
4
  export function routeOrchestration(s) {
4
5
  if (s.explicitMode)
5
6
  return { mode: s.explicitMode, reason: `explicit:${s.explicitMode}` };
@@ -42,9 +43,13 @@ export function isIsolatedExecEnv(provider, runtimeClass) {
42
43
  const WRITE_HAND_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
43
44
  const SHELL_TOOLS = new Set(COARSE_SHELL_TOOLS);
44
45
  export function supPostureOverrides() {
45
- const ask = (toolName) => operatorAlwaysHumanAsk(`supervisor routing(capability-danger:${toolName})`, `${toolName} gated by supervisor routing (capability-danger task)`);
46
46
  const gate = {
47
- check: (req) => (WRITE_HAND_TOOLS.has(req.toolName) || SHELL_TOOLS.has(req.toolName) ? ask(req.toolName) : { action: "allow" }),
47
+ check: (req) => {
48
+ const hit = heldRuleName(req, (n) => WRITE_HAND_TOOLS.has(n) || SHELL_TOOLS.has(n));
49
+ return hit !== undefined
50
+ ? operatorAlwaysHumanAsk(`supervisor routing(capability-danger:${hit})`, `${req.toolName} gated by supervisor routing (capability-danger task)`)
51
+ : { action: "allow" };
52
+ },
48
53
  };
49
54
  return { shellGate: "always", toolPolicy: gate };
50
55
  }
package/dist/runs.js CHANGED
@@ -5,6 +5,7 @@ import { TOOL_DECLARATION_HTTP_STATUS } from "./tool-declaration-http-status.js"
5
5
  import { isSessionErrorCode, sessionErrorHttp } from "./session-error-wire.js";
6
6
  import { redactErrorMessage } from "./observability/run-terminal.js";
7
7
  import { withPrincipal } from "./observability/principal-context.js";
8
+ import { withModelReachScope } from "./model-reach-scope.js";
8
9
  import { redactSecrets } from "./trace/redact.js";
9
10
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
10
11
  import { createLedgerSink } from "./trace/ledger-sink.js";
@@ -419,7 +420,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
419
420
  await append("suggestions", { suggestions: suggestions.map((s) => redactSecrets(s)) });
420
421
  }
421
422
  };
422
- await withPrincipal(principal, () => {
423
+ await withModelReachScope(spec.sessionId, () => withPrincipal(principal, () => {
423
424
  const withElicit = elicitation
424
425
  ? () => elicitation.runWithContext({ taskId, owner: elicitOwner ?? owner, emit: elicitEmit, abortSignal: cancelCtrl.signal }, runBody)
425
426
  : runBody;
@@ -442,7 +443,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
442
443
  return sendUserFile
443
444
  ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withApproval)
444
445
  : withApproval();
445
- });
446
+ }));
446
447
  if (!reached && cancelCtrl.signal.aborted) {
447
448
  try {
448
449
  await flush();
@@ -2,6 +2,7 @@ import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createC
2
2
  import { GovernanceConfig } from "@sema-agent/settings-schema";
3
3
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
4
4
  import { operatorAsk, withDeploymentAuthorship } from "./operator-ask.js";
5
+ import { heldRuleName } from "./tool-rule-names.js";
5
6
  const COMMAND_RULE_SHAPE = GovernanceConfig.shape.commandPolicy.unwrap().element.shape;
6
7
  const COMMAND_DECISIONS = COMMAND_RULE_SHAPE.decision.options;
7
8
  export const AUTONOMY_MODES = GovernanceConfig.shape.autonomy.unwrap().options;
@@ -20,7 +21,7 @@ function createCommandAskListPolicy(askCommands, tools = COARSE_SHELL_TOOLS) {
20
21
  const ask = (message, rule) => operatorAsk(rule, message);
21
22
  return {
22
23
  check(req) {
23
- if (!shellTools.has(req.toolName))
24
+ if (heldRuleName(req, (n) => shellTools.has(n)) === undefined)
24
25
  return { action: "allow" };
25
26
  const command = req.args?.command;
26
27
  if (typeof command !== "string")
@@ -135,7 +136,7 @@ function createGovernanceShellGateMarkPolicy(marksOf, tools = COARSE_SHELL_TOOLS
135
136
  const shellTools = new Set(tools);
136
137
  return {
137
138
  check(req) {
138
- if (shellTools.has(req.toolName))
139
+ if (heldRuleName(req, (n) => shellTools.has(n)) !== undefined)
139
140
  marksOf()?.setMarked(req.toolCallId, true);
140
141
  return { action: "allow" };
141
142
  },
@@ -35,25 +35,38 @@
35
35
  * - (`hooks` NO LONGER deferred either — 轴B #2 注释保鲜:applied 形已接线(本文件 hooks 分片 +
36
36
  * main.ts 单用户闸),仅 malformed 才报 deferred;旧「需要 remote hook-runner」是接线前拍照。)
37
37
  */
38
- import { type ExecutionEnv, type TaskSpec, type ThinkingLevel, type ToolPolicy, type PromptProvider } from "@sema-agent/core";
38
+ import { type ExecutionEnv, type TaskSpec, type ThinkingLevel, type ToolPolicy, type PromptProvider, type PermissionMode } from "@sema-agent/core";
39
39
  import type { HooksConfig } from "@sema-agent/settings-schema/hooks";
40
40
  import { type KeyClosureIssue } from "./request-key-closure.js";
41
- /** The permission MODE a client may request (SDK `SettingsPermissions.defaultMode`), the [ref]/[ref] five-word
42
- * table. ⚠️ This is a DELIBERATE deployment set, NOT a copy of any CC set ([ref] P2-1 corpus check): CC's own
43
- * full enum is SIX words (= ours + `dontAsk`), CC's managed/remote form accepts FOUR (= ours − `bypassPermissions`,
44
- * which it warns-and-ignores). Ours = {CC-remote four} ∪ {`bypassPermissions`} — registered divergence, not parity.
45
- * `bypassPermissions` and `auto` joined the set with the [ref] fs-write gate: since the gate exists, every
46
- * mode is a choice of HOW MUCH mode-derived gating the interpretation layer ADDS on top of the deployment baseline —
47
- * `bypassPermissions` adds none (= the pre-[ref] behavior of every mode: "不加门" not "开门", [ref] table row 4; the
48
- * deployment approval/governance baseline is composed OUTSIDE this derive and is untouchable from a settings stamp),
49
- * and `auto` adds the same ask gate as `default` with core's auto-mode classifier screening the asks upstream
50
- * (entitlement-gated in core, [ref]②). */
51
- export declare const SETTINGS_PERMISSION_MODES: readonly ["default", "acceptEdits", "plan", "bypassPermissions", "auto"];
52
- /** 五模式闭集的型形 —— **从值表派生**(S-496 / [ref]):改前本仓有 **4 份**同一张五词表(本处的手写联合 /
53
- * 下面 `coercePermissionModeStrict` 的运行期字面量链 / `http/admission.ts` 顶层 `permissionMode` 的比较链 /
54
- * 同文件 `settings.permissions.defaultMode` 的比较链),外加两句错误文案里各抄一遍词。现在值表一份、
55
- * 型与文案都从它派生,比较链改调 {@link isSettingsPermissionMode}。 */
56
- export type SettingsPermissionMode = (typeof SETTINGS_PERMISSION_MODES)[number];
41
+ /**
42
+ * S-902(7.107.0,车QY 候裁 11 裁 A;[ref])—— core 的**每一个**权限模式词,本仓请求面收不收:`"request"` = 受理(顶层 `permissionMode` /
43
+ * `settings.permissions.defaultMode` / `?permissionMode=` 查询串三处同读),`"excluded"` = 显式排除(不上请求面)。
44
+ *
45
+ * 🔴 这张表是**表态**,不是词表的第二份:词与词序只从 core `PERMISSION_MODES` 来(下面 {@link SETTINGS_PERMISSION_MODES} 按它过滤);
46
+ * 表只对每个词说一句「收 / 不收」。`satisfies Record<PermissionMode, …>` 对 core 的词型**穷举**:core 加一个词 ⇒ 本表缺键 **编译红**
47
+ * (新词必须被表态一次,不会被静默收进请求面、也不会被静默漏掉);core 删 / 改名一个词 ⇒ 本表多键编译红(残条目不留)。
48
+ *
49
+ * 今天的表态(与 7.106.0 逐字同行为):五词受理;`dontAsk`(CC 的「自动拒」档,core 7.34.0 起第六词)**排除** —— 它上不上请求面是一件
50
+ * 新能力(未裁,本表不替它裁);不收它时提交面 400 `request.field_invalid`,续跑重放腿按 {@link coercePermissionMode} 的既有宽读折 `default`。
51
+ * 语义沿革(为什么是这五个、`bypassPermissions` / `auto` 何时进来):[ref] / [ref] / [ref] —— CC 自己的全集是六词,CC 托管 / 远端形收四词
52
+ * (= 本仓 − `bypassPermissions`),本仓 = {CC 远端四词} ∪ {`bypassPermissions`},登记过的偏离;生效模式原样过境 `TaskSpec.permissionMode`(S-877)。
53
+ */
54
+ export declare const PERMISSION_MODE_REQUEST_FACE: {
55
+ readonly default: "request";
56
+ readonly acceptEdits: "request";
57
+ readonly plan: "request";
58
+ readonly auto: "request";
59
+ readonly bypassPermissions: "request";
60
+ readonly dontAsk: "excluded";
61
+ };
62
+ /** 请求面受理的模式词的型形 —— 从表态表派生(`"request"` 那几行的键)。 */
63
+ export type SettingsPermissionMode = {
64
+ [M in PermissionMode]: (typeof PERMISSION_MODE_REQUEST_FACE)[M] extends "request" ? M : never;
65
+ }[PermissionMode];
66
+ /** 请求面受理的模式词(core 序)= core `PERMISSION_MODES` 过滤表态 `"request"`。wire 门、coerce 两族、两句 400 文案同读这一份。 */
67
+ export declare const SETTINGS_PERMISSION_MODES: readonly SettingsPermissionMode[];
68
+ /** 显式排除集(core 序)= core `PERMISSION_MODES` 过滤表态 `"excluded"`;今天恰是 `["dontAsk"]`。只作披露 / 钉读,不参与判定。 */
69
+ export declare const SETTINGS_PERMISSION_MODES_EXCLUDED: readonly Exclude<PermissionMode, SettingsPermissionMode>[];
57
70
  /** 五词表的 wire 文案形(`"a" | "b" | …`)—— 两条 400 的句子从这里取词,不再各抄一份。 */
58
71
  export declare const SETTINGS_PERMISSION_MODE_WORDS: string;
59
72
  /** 闭集成员判定(`unknown` 入口用)—— 词表的**唯一**运行期读者:wire 门与 coerce 两族同调。 */
@@ -1,9 +1,19 @@
1
- import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, createPermissionRulePolicy, parseRuleText, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, RUN_WORKFLOW_TOOL_NAME, ruleToolGrammarOf, } from "@sema-agent/core";
1
+ import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, createPermissionRulePolicy, parseRuleText, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, RUN_WORKFLOW_TOOL_NAME, PERMISSION_MODES, ruleToolGrammarOf, } from "@sema-agent/core";
2
2
  import { findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
3
3
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
4
4
  import { MAX_KEY_ECHO, keyClosureClause, scanKeyClosure } from "./request-key-closure.js";
5
5
  import { parseHooksConfig } from "./hooks/hook-runner.js";
6
- export const SETTINGS_PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions", "auto"];
6
+ import { heldRuleName } from "./tool-rule-names.js";
7
+ export const PERMISSION_MODE_REQUEST_FACE = {
8
+ default: "request",
9
+ acceptEdits: "request",
10
+ plan: "request",
11
+ auto: "request",
12
+ bypassPermissions: "request",
13
+ dontAsk: "excluded",
14
+ };
15
+ export const SETTINGS_PERMISSION_MODES = PERMISSION_MODES.filter((m) => PERMISSION_MODE_REQUEST_FACE[m] === "request");
16
+ export const SETTINGS_PERMISSION_MODES_EXCLUDED = PERMISSION_MODES.filter((m) => PERMISSION_MODE_REQUEST_FACE[m] === "excluded");
7
17
  export const SETTINGS_PERMISSION_MODE_WORDS = SETTINGS_PERMISSION_MODES.map((m) => `"${m}"`).join(" | ");
8
18
  export function isSettingsPermissionMode(raw) {
9
19
  return typeof raw === "string" && SETTINGS_PERMISSION_MODES.includes(raw);
@@ -269,10 +279,11 @@ function createRuleListPolicy(names, behavior, pathBases) {
269
279
  }
270
280
  }
271
281
  const WORKFLOW_ASK_NAMES = [RUN_WORKFLOW_TOOL_NAME];
282
+ const isWorkflowName = (name) => name === RUN_WORKFLOW_TOOL_NAME;
272
283
  function createWorkflowAskPolicy(isExempt) {
273
284
  return {
274
285
  async check(req) {
275
- if (req.toolName !== RUN_WORKFLOW_TOOL_NAME)
286
+ if (heldRuleName(req, isWorkflowName) === undefined)
276
287
  return { action: "allow" };
277
288
  if (isExempt) {
278
289
  try {
@@ -290,7 +301,7 @@ function createWorkflowAskPolicy(isExempt) {
290
301
  function createWorkflowPlanDenyPolicy() {
291
302
  return {
292
303
  check(req) {
293
- return req.toolName === RUN_WORKFLOW_TOOL_NAME
304
+ return heldRuleName(req, isWorkflowName) !== undefined
294
305
  ? { action: "deny", message: `Plan mode is active — "${req.toolName}" is a write/mutating tool and is read-only-blocked.`, decisionReason: "rule" }
295
306
  : { action: "allow" };
296
307
  },