@sema-agent/server 7.107.0 → 7.109.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 (101) hide show
  1. package/MIGRATION.md +72 -1
  2. package/USAGE.md +51 -25
  3. package/dist/approval-ask-audit-store.d.ts +62 -4
  4. package/dist/approval-ask-audit-store.js +67 -11
  5. package/dist/approval-ask-machine.d.ts +9 -25
  6. package/dist/approval-ask-machine.js +5 -2
  7. package/dist/approval-card.d.ts +32 -3
  8. package/dist/approval-card.js +29 -2
  9. package/dist/approval.js +16 -13
  10. package/dist/bench/s1/live-deps.d.ts +4 -5
  11. package/dist/bench/s1/live-deps.js +9 -10
  12. package/dist/boot/coordinators.d.ts +4 -0
  13. package/dist/boot/coordinators.js +3 -2
  14. package/dist/boot/leader.js +0 -2
  15. package/dist/boot/resolve-spec.d.ts +1 -1
  16. package/dist/boot/resolve-spec.js +3 -8
  17. package/dist/boot/shutdown.d.ts +8 -0
  18. package/dist/boot/shutdown.js +31 -8
  19. package/dist/boot/stage-06-runners.d.ts +1 -0
  20. package/dist/boot/stage-06-runners.js +6 -3
  21. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  22. package/dist/boot/stage-08-reapers.d.ts +1 -0
  23. package/dist/boot/stage-09-leader.d.ts +1 -0
  24. package/dist/boot/stage-10-http-server.d.ts +1 -0
  25. package/dist/boot/stage-10-http-server.js +3 -1
  26. package/dist/brain.js +2 -1
  27. package/dist/capabilities/scenarios.d.ts +3 -2
  28. package/dist/config-catalog.js +1 -3
  29. package/dist/config-center/apply-effective.js +2 -0
  30. package/dist/config-center/types.d.ts +7 -0
  31. package/dist/config-provider.js +1 -0
  32. package/dist/config-types.d.ts +2 -9
  33. package/dist/config.js +2 -5
  34. package/dist/deployment-governance.js +2 -1
  35. package/dist/fleet-client.d.ts +10 -2
  36. package/dist/fleet-client.js +14 -8
  37. package/dist/http/admission.js +8 -2
  38. package/dist/http/memory-compliance-failure-reply.d.ts +41 -0
  39. package/dist/http/memory-compliance-failure-reply.js +73 -0
  40. package/dist/http/memory-store-refusal-reply.d.ts +2 -2
  41. package/dist/http/resume-legs.js +3 -2
  42. package/dist/http/route-ctx.d.ts +3 -3
  43. package/dist/http/routes/capabilities.js +1 -0
  44. package/dist/http/routes/diagnostics.js +2 -0
  45. package/dist/http/routes/memory-compliance.js +2 -34
  46. package/dist/http/routes/memory-origin.d.ts +1 -1
  47. package/dist/http/routes/sessions.js +3 -11
  48. package/dist/http/routes/side-query.js +7 -2
  49. package/dist/http/routes/tasks.js +2 -1
  50. package/dist/http/server.d.ts +2 -2
  51. package/dist/http/wire-types.d.ts +8 -0
  52. package/dist/leader/diffup.d.ts +53 -0
  53. package/dist/leader/diffup.js +95 -1
  54. package/dist/leader/leader.d.ts +0 -6
  55. package/dist/leader/leader.js +1 -1
  56. package/dist/leader/repair-wire.js +1 -1
  57. package/dist/leader/wire.d.ts +3 -10
  58. package/dist/leader/wire.js +39 -13
  59. package/dist/memory-store-refusal.d.ts +2 -1
  60. package/dist/model-entry-headers.d.ts +41 -0
  61. package/dist/model-entry-headers.js +61 -0
  62. package/dist/model-entry-refusal.d.ts +6 -0
  63. package/dist/model-entry-refusal.js +4 -0
  64. package/dist/model-reach-scope.d.ts +55 -0
  65. package/dist/model-reach-scope.js +16 -0
  66. package/dist/model-unreachable-notice.d.ts +38 -0
  67. package/dist/model-unreachable-notice.js +72 -0
  68. package/dist/observability/fail-open.d.ts +5 -5
  69. package/dist/observability/fail-open.js +5 -5
  70. package/dist/observability/run-terminal.d.ts +2 -1
  71. package/dist/outbound-dispatcher.d.ts +69 -0
  72. package/dist/outbound-dispatcher.js +123 -3
  73. package/dist/plugins/approval-ask-store-file.d.ts +2 -1
  74. package/dist/plugins/approval-ask-store-file.js +3 -0
  75. package/dist/plugins/approval-ask-store-memory.d.ts +2 -1
  76. package/dist/plugins/approval-ask-store-memory.js +7 -1
  77. package/dist/plugins/approval-ask-store-sql.d.ts +18 -0
  78. package/dist/plugins/approval-ask-store-sql.js +7 -0
  79. package/dist/plugins/park-commit-watch.d.ts +48 -0
  80. package/dist/plugins/park-commit-watch.js +87 -0
  81. package/dist/request-key-closure.js +1 -1
  82. package/dist/router/route-orchestration.js +7 -2
  83. package/dist/run-local.js +2 -3
  84. package/dist/runs.js +3 -2
  85. package/dist/runtime-governance.d.ts +3 -3
  86. package/dist/runtime-governance.js +3 -2
  87. package/dist/task-settings.js +4 -2
  88. package/dist/tool-approval.d.ts +99 -15
  89. package/dist/tool-approval.js +75 -10
  90. package/dist/tool-rule-names.d.ts +25 -0
  91. package/dist/tool-rule-names.js +5 -0
  92. package/dist/trace/core-keyset-guard.d.ts +14 -2
  93. package/dist/trace/engine-notice-wire.d.ts +3 -1
  94. package/dist/trace/engine-notice-wire.js +2 -0
  95. package/dist/trace/project.d.ts +3 -1
  96. package/dist/trace/project.js +2 -3
  97. package/dist/trace/self-minted-wire.d.ts +71 -0
  98. package/dist/trace/self-minted-wire.js +28 -0
  99. package/dist/unattended-permission-mode.d.ts +54 -93
  100. package/dist/unattended-permission-mode.js +8 -61
  101. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
1
  import { CC_MODEL_TIER_ALIASES, DEFAULT_TIER_ORDER, expandTiers } from "@sema-agent/core";
2
2
  import { ModelEntry, isReportableRefusalName } from "@sema-agent/settings-schema";
3
3
  import { catalogBrainLane } from "./model-provider.js";
4
+ import { readCatalogEntryHeaders } from "./model-entry-headers.js";
4
5
  import { describeSchemaIssueFields, describeSchemaIssues } from "./config-center/read-warnings.js";
5
6
  import { DEFAULT_MODEL_REF } from "./model-select.js";
6
7
  export const ENTRY_REFUSAL_REASON_OF = {
@@ -26,6 +27,9 @@ export function judgeCatalogEntry(m, ctx) {
26
27
  const fields = describeSchemaIssueFields(shape.error);
27
28
  return entryInvalidRefusal(typeof m.id === "string" ? m.id : "", typeof m.provider === "string" ? m.provider : "", fields, describeSchemaIssues(shape.error) ?? fields.join(", "));
28
29
  }
30
+ const headers = readCatalogEntryHeaders(m.headers, m.api);
31
+ if (!headers.ok)
32
+ return entryInvalidRefusal(m.id, m.provider, headers.fields, headers.issue);
29
33
  const lane = catalogBrainLane(m.provider);
30
34
  const mount = laneMount(lane, ctx);
31
35
  if (!mount.mounted) {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * S-925(7.108.0)—— 「这次模型调用的每一次出网尝试落在哪」的**异步作用域**(叶:只引 node 与类型)。
3
+ *
4
+ * 两层,一只 `AsyncLocalStorage`:
5
+ * · **腿层**(`withModelReachScope`,三条带用户面投递口的 run 腿各包一次:流式 `POST /v1/tasks/stream`、后台 `POST /v1/runs`、续跑):
6
+ * 带着这条腿的 `sessionId`(通告归属键)与本腿已经说过的目标集合(去重单位 = 每腿每目标一次);
7
+ * · **调用层**(`createModelReachBrain` 每次模型调用包一次):一只可变的 {@link ModelCallReach},出网观察者
8
+ * (`outbound-dispatcher.ts` 的 undici 诊断通道订阅)把这次调用发出的每一条请求的**结局**记在上面 —— 最后一条是
9
+ * 「连上了、有了回答」还是「建连就失败了」。
10
+ *
11
+ * 为什么是 ALS 而不是参数:模型调用的出网发生在 core 的 brain 里(`fetch` → 全局 dispatcher → undici),本仓在那条链上没有任何
12
+ * 参数座;undici 的 `undici:request:create` 通道在创建请求的那一拍同步发布、就在发起 `fetch` 的异步上下文里 —— 读 ALS 就知道
13
+ * 「这条请求属于哪一次模型调用」,不靠目标主机 + 时间去猜(那是把相关写成因果)。
14
+ * 作用域外(side-query、记忆整理、工具自己的出网 …)⇒ 没有腿层 ⇒ 只有运维日志,没有用户通告。
15
+ * 腿里的**辅助**模型调用(WebFetch 摘要器、压实、提示建议、分类器 …)与主循环**同一条规则**:通告说的是「这个模型端点够不着」,
16
+ * 不是「这条 run 失败了」—— 不设豁免名单(codex r1 / r2:豁免名单结构上开放,core 自己发起的辅助调用本仓包不到,列不全)。
17
+ */
18
+ import { AsyncLocalStorage } from "node:async_hooks";
19
+ import type { OutboundConnectFailure } from "./outbound-dispatcher.js";
20
+ /** 一条请求的结局(观察者按到达顺序覆盖写;模型调用终态时读**最后一条**)。 */
21
+ export type ModelCallOutcome = {
22
+ readonly kind: "reached";
23
+ } | {
24
+ readonly kind: "connect_failed";
25
+ readonly failure: OutboundConnectFailure;
26
+ } | {
27
+ readonly kind: "other";
28
+ };
29
+ /** 一次模型调用的出网观察(可变:观察者写、装饰器读)。 */
30
+ export interface ModelCallReach {
31
+ last?: ModelCallOutcome;
32
+ }
33
+ /** 一条 run 腿的作用域。 */
34
+ export interface ModelReachLegScope {
35
+ readonly sessionId: string;
36
+ /** 本腿已经通告过的目标(`targetHost`)—— 去重单位 = 每腿每目标一次。 */
37
+ readonly announced: Set<string>;
38
+ }
39
+ export interface ModelReachStore {
40
+ readonly leg?: ModelReachLegScope;
41
+ readonly call?: ModelCallReach;
42
+ }
43
+ export declare const modelReachContext: AsyncLocalStorage<ModelReachStore>;
44
+ /**
45
+ * 腿层入口:每条腿都开**自己的**作用域 —— `sessionId` 缺席(无会话腿)⇒ 空作用域(没有归属键就没有用户面可投;也不继承外层腿的
46
+ * 归属键 —— codex r1 旁注:修前缺席即原样执行、会沿用外层腿)。嵌套时内层覆盖外层 —— 归属跟着「这一轮是谁的腿」走。
47
+ */
48
+ export declare function withModelReachScope<T>(sessionId: string | undefined, fn: () => T): T;
49
+ /** 调用层入口(`createModelReachBrain` 用):在当前腿作用域(可能缺席)之上开一只新的调用观察。 */
50
+ export declare function withModelCallReach<T>(call: ModelCallReach, fn: () => T): T;
51
+ /** 观察者读口:当前异步上下文里的模型调用观察(作用域外 ⇒ `undefined`)。 */
52
+ export declare function currentModelCallReach(): ModelCallReach | undefined;
53
+ /** 装饰器读口:当前腿(作用域外 ⇒ `undefined`)。 */
54
+ export declare function currentModelReachLeg(): ModelReachLegScope | undefined;
55
+ //# sourceMappingURL=model-reach-scope.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export const modelReachContext = new AsyncLocalStorage();
3
+ export function withModelReachScope(sessionId, fn) {
4
+ return modelReachContext.run(sessionId === undefined || sessionId.length === 0 ? {} : { leg: { sessionId, announced: new Set() } }, fn);
5
+ }
6
+ export function withModelCallReach(call, fn) {
7
+ const outer = modelReachContext.getStore();
8
+ return modelReachContext.run({ ...(outer?.leg !== undefined ? { leg: outer.leg } : {}), call }, fn);
9
+ }
10
+ export function currentModelCallReach() {
11
+ return modelReachContext.getStore()?.call;
12
+ }
13
+ export function currentModelReachLeg() {
14
+ return modelReachContext.getStore()?.leg;
15
+ }
16
+ //# sourceMappingURL=model-reach-scope.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * S-925(7.108.0;test [ref] ② 用户报案分诊,验收 `srv7108-s925-connect-timeout-diagnostic`)—— **模型调用因建连失败终态**时
3
+ * 给这条腿的用户铸的通告 `server.model_unreachable` 的**唯一铸点**,与把它挂到每一次模型调用上的 brain 装饰器。
4
+ *
5
+ * ## 病
6
+ * 运营用户看不到日志 / 诊断面:VPN 中途切换之后模型网关建连超时,用户只拿到一句 undici 原句(`fetch failed (connect …)`),
7
+ * 分不清是网络断了还是起服时读进的代理已经失效,也没有「查 VPN / 代理、代理变过就重启」这条出路。只落运维日志不算闭环
8
+ * (test 立案原话)。core 没有现成的「模型不可达」用户通告码(`ENGINE_NOTICE_CODES` 亲查零命中)⇒ 本仓自铸,走既有的
9
+ * `SERVER_NOTICE_AUDIENCE` 族(S-690 形:白名单 / 归属 / 消毒 / 隔离四件判据一个不绕),不开第二条通道。
10
+ *
11
+ * ## 判据(一句):**这一次模型调用的终局是 `stopReason: "error"`,且它发出的最后一条请求在建连时就失败了**
12
+ * 「最后一条请求落在哪」由出网观察者记在调用作用域上(`model-reach-scope.ts`,undici 诊断通道,见 `outbound-dispatcher.ts`)——
13
+ * 不按目标主机 + 时间去猜。于是:
14
+ * · 服务商回了 5xx / 4xx(收到了响应头)⇒ 最后一条是 `reached` ⇒ 不铸(那是服务商的回答);
15
+ * · 建连失败、重试之后连上了并成功 ⇒ 终局不是 error ⇒ 不铸;
16
+ * · 中途断流 / TLS / 主动取消 / 首字节超时 ⇒ 最后一条是 `other` ⇒ 不铸(判不出「够不着」)。
17
+ *
18
+ * ## 投递 / 去重
19
+ * 归属键 = 这条 run 腿的 `sessionId`(腿层作用域,三条带用户面投递口的腿各包一次)。作用域外的模型调用(side-query、记忆整理 …)
20
+ * 没有归属键 ⇒ 不铸(运维日志 `outbound_connect_failed` 照有)。腿里的**辅助**调用(WebFetch 摘要器、压实、提示建议、分类器 …)与
21
+ * 主循环**同一条规则**:本帧说的是「这个模型端点够不着」,不是「这条 run 失败了」(措辞如此,不暗示终态);不设豁免名单 —— codex r1 / r2
22
+ * 两轮证明豁免名单结构上开放(core 自己发起的辅助调用本仓包不到)。按 **run 终态**归属是另一种设计,候裁(收车档 ⑤)。去重单位 = **每腿每目标一次**(同一条腿里
23
+ * 重试 / 子代对同一个网关再失败,不再说一遍)。受众 user;detail = `{ code, targetHost, proxyInUse?, remedy, sessionId }` ——
24
+ * `proxyInUse` 只在这一跳走了代理时在场,且只是**变量名**(代理地址是基础设施细节,不上租户 wire,与 `OutboundProxyError` 的
25
+ * F3 裁定同一条)。`targetHost`(模型端点的 `host:port`)对 user 受众是**新披露的部署事实**(F7 裁定:它是部署自己声明的模型端点,
26
+ * 不是基础设施秘密,可以上;代理地址仍不上)—— 不要读成「终局句里本就有」:代理形下 core 终局句展开到代理根因、不含模型主机,
27
+ * 辅助调用形下 run 可 completed、租户面本无主机句。
28
+ */
29
+ import type { Brain } from "@sema-agent/core";
30
+ import { type EngineNoticeRouter } from "./trace/engine-notice-wire.js";
31
+ /**
32
+ * `create*`:包在 `createBrain` 装好的那只 brain **最外层**(路由 / 切备 / 断路器 / 降级全在里面 ⇒「这次调用」= 调用方看到的那一次,
33
+ * 重试与切备都在同一个调用作用域里)。每次调用开一只新的调用观察;腿层作用域在**发起调用那一拍**读(之后的回调在别的上下文里)。
34
+ * 不改流、不改消息、不改错误:只在终局 promise 上挂一个观察。
35
+ * 形 = 同型 `Proxy`:表里 `observed` 的成员(内层有它才有)换成观察器;其余成员逐字转给内层、方法绑内层(`this` 不漂)。
36
+ */
37
+ export declare function createModelReachBrain(inner: Brain, router?: EngineNoticeRouter): Brain;
38
+ //# sourceMappingURL=model-unreachable-notice.d.ts.map
@@ -0,0 +1,72 @@
1
+ import { currentModelReachLeg, withModelCallReach } from "./model-reach-scope.js";
2
+ import { defaultEngineNoticeRouter } from "./trace/engine-notice-wire.js";
3
+ const SERVER_MODEL_UNREACHABLE = "server.model_unreachable";
4
+ const MODEL_UNREACHABLE_REMEDY = "check VPN / proxy; restart if the proxy changed since boot";
5
+ function announceModelUnreachable(leg, failure, router = defaultEngineNoticeRouter) {
6
+ if (leg.announced.has(failure.targetHost))
7
+ return;
8
+ leg.announced.add(failure.targetHost);
9
+ const via = failure.proxyInUse !== null ? ` through the proxy from ${failure.proxyInUse}` : "";
10
+ router.route({
11
+ code: SERVER_MODEL_UNREACHABLE,
12
+ message: `The model endpoint could not be reached: the connection to ${failure.targetHost}${via} failed (${failure.code}). ` +
13
+ "Check the VPN / proxy; if the proxy changed since the server started, restart it (proxy settings are read once at startup).",
14
+ detail: {
15
+ code: failure.code,
16
+ targetHost: failure.targetHost,
17
+ ...(failure.proxyInUse !== null ? { proxyInUse: failure.proxyInUse } : {}),
18
+ remedy: MODEL_UNREACHABLE_REMEDY,
19
+ sessionId: leg.sessionId,
20
+ },
21
+ sessionId: leg.sessionId,
22
+ });
23
+ }
24
+ const isThenable = (v) => typeof v.then === "function";
25
+ function settle(leg, call, message, router) {
26
+ if (leg === undefined || message.stopReason !== "error")
27
+ return;
28
+ const last = call.last;
29
+ if (last?.kind === "connect_failed")
30
+ announceModelUnreachable(leg, last.failure, router);
31
+ }
32
+ const BRAIN_MEMBER_TREATMENT = {
33
+ stream: "observed",
34
+ complete: "observed",
35
+ adjudicateRoute: "forwarded",
36
+ };
37
+ export function createModelReachBrain(inner, router = defaultEngineNoticeRouter) {
38
+ const observe = (leg, call, final) => {
39
+ void final.then((message) => settle(leg, call, message, router), () => undefined);
40
+ };
41
+ const MEMBER_OBSERVERS = {
42
+ stream: (innerStream) => (model, context, options) => {
43
+ const leg = currentModelReachLeg();
44
+ const call = {};
45
+ const s = withModelCallReach(call, () => innerStream.call(inner, model, context, options));
46
+ if (isThenable(s))
47
+ observe(leg, call, Promise.resolve(s).then((st) => st.result()));
48
+ else
49
+ observe(leg, call, s.result());
50
+ return s;
51
+ },
52
+ complete: (innerComplete) => (model, context, options) => {
53
+ const leg = currentModelReachLeg();
54
+ const call = {};
55
+ const p = withModelCallReach(call, () => innerComplete.call(inner, model, context, options));
56
+ observe(leg, call, p);
57
+ return p;
58
+ },
59
+ };
60
+ const observedStream = MEMBER_OBSERVERS.stream(inner.stream);
61
+ const observedComplete = inner.complete !== undefined ? MEMBER_OBSERVERS.complete(inner.complete) : undefined;
62
+ const observed = { stream: observedStream, complete: observedComplete };
63
+ return new Proxy(inner, {
64
+ get(target, prop) {
65
+ if (typeof prop === "string" && Object.hasOwn(observed, prop))
66
+ return Reflect.get(observed, prop);
67
+ const v = Reflect.get(target, prop, target);
68
+ return typeof v === "function" ? v.bind(target) : v;
69
+ },
70
+ });
71
+ }
72
+ //# sourceMappingURL=model-unreachable-notice.js.map
@@ -25,10 +25,6 @@ export declare const FAIL_OPEN_TAGS: {
25
25
  readonly cls: "F";
26
26
  readonly note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。";
27
27
  };
28
- readonly "server.unattended-leg.mode-releases-protective-asks": {
29
- readonly cls: "P-DEBT";
30
- readonly note: "S-877 四续 ⑧(候裁 18 D 的已披露残余,`unattended-permission-mode.ts` 的 `createUnattendedLeg`):无人值守腿(leader worker / 修复腿 / run-local / 续跑重挂)的旋钮 `UNATTENDED_PERMISSION_MODE` 缺省 `bypassPermissions` 释放六类保护问(`probe` / `tool_marks_mcp` / `tool_marks_a2a` / `transcript_write` / `rule_unreadable` / `artifact_shared_read`;写保护一类已改拒)—— 7.105.0 这些腿上它们照问、无人答即拒。放行的最坏后果=无人值守的写 / 读 / MCP·A2A 标记工具调用不再被这几道保护问拦下,`rule_unreadable` 是「规则店读不出照放」(安全轴 fail-open 方向)。core 对这几类没有拒座;S-906(core 7.37.0 按类作答)到货后本臂与旋钮一并硬删。计数 = 铸出的这类腿条数,detail = `mode=<词> released=<类…>`。";
31
- };
32
28
  readonly "server.workflow-park-index.sync-failed": {
33
29
  readonly cls: "P-DEBT";
34
30
  readonly note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到,`/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`)——**方向是 fail-closed**(不误配、不伪造、不放行任何东西),丢的是「人能不能把这条批准送出去」这个能力。归 P 类的理由是它决定**车道选择**(缺行 ⇒ 选中 legacy 腿),归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车)。计数非零 = 有 park 因此决不掉,运维要按 detail 里的 runId 去查。";
@@ -295,7 +291,7 @@ export declare const FAIL_OPEN_TAGS: {
295
291
  };
296
292
  readonly "server.approval-card.closed-word-out-of-set": {
297
293
  readonly cls: "F";
298
- readonly note: "S-249(`approval-card.ts` 的边界窄读族;今天两个成员:`readRuleOffersAbsence`,与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。";
294
+ readonly note: "S-249(`approval-card.ts` 的边界窄读族;今天三个成员:`readRuleOffersAbsence`、S-934 ② 起的 `readAskClassPair`(core 7.37.0 #1214 的类对 `askClass` / `classes`,活卡帧 / 呈卡帧的卡 / 运维队列行同一只,成员筛 = core `isAskClass`,两键同在同缺,半对 / 首员不符同样算读不出;detail 前缀 `askClass=` / `classes=`),与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。";
299
295
  };
300
296
  readonly "server.pg-pool.idle-client-error": {
301
297
  readonly cls: "F";
@@ -321,6 +317,10 @@ export declare const FAIL_OPEN_TAGS: {
321
317
  readonly cls: "F";
322
318
  readonly note: "S-582(`plugins/send-file-ledger.ts`,local 车道的发文件账本):一条账行读不出形(`42` / 缺 id·scope·objectKey 三元组 / 中间半截 JSON)⇒ 跳过它继续。最坏后果 = 那条已签发的链接在它租户的列表里**看不见**(对象本身没动;账本本来就不存 URL)。治理面少一行是可见性损失,不是放行 ⇒ F;但它恰是治理面要看的那一类事实,所以必须留痕 —— 修前零披露。";
323
319
  };
320
+ readonly "server.outbound.connect-observer-threw": {
321
+ readonly cls: "F";
322
+ readonly note: "S-925(`outbound-dispatcher.ts` 的 undici 诊断通道订阅者:`undici:request:create` / `:headers` / `:error`):一只订阅回调抛了。通道在发布方的调用栈里同步回调订阅者,抛出去在 Node 上是未捕获异常(整进程倒),所以每只回调整体 try、落本 tag。最坏后果 = 这一条请求的建连失败没被记下:少一行 `outbound_connect_failed`、诊断段 `outbound.lastConnectFailure` 不更新、那次模型调用不铸 `server.model_unreachable` 用户通告 —— 请求本身、它的错误、run 的终态一个字节不受影响(观察只读)。计数恒零是证据;非零 = undici 通道载荷的形变了,去看 detail 的 site。";
323
+ };
324
324
  };
325
325
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
326
326
  export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
@@ -16,10 +16,6 @@ export const FAIL_OPEN_TAGS = {
16
16
  cls: "F",
17
17
  note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。",
18
18
  },
19
- "server.unattended-leg.mode-releases-protective-asks": {
20
- cls: "P-DEBT",
21
- note: "S-877 四续 ⑧(候裁 18 D 的已披露残余,`unattended-permission-mode.ts` 的 `createUnattendedLeg`):无人值守腿(leader worker / 修复腿 / run-local / 续跑重挂)的旋钮 `UNATTENDED_PERMISSION_MODE` 缺省 `bypassPermissions` 释放六类保护问(`probe` / `tool_marks_mcp` / `tool_marks_a2a` / `transcript_write` / `rule_unreadable` / `artifact_shared_read`;写保护一类已改拒)—— 7.105.0 这些腿上它们照问、无人答即拒。放行的最坏后果=无人值守的写 / 读 / MCP·A2A 标记工具调用不再被这几道保护问拦下,`rule_unreadable` 是「规则店读不出照放」(安全轴 fail-open 方向)。core 对这几类没有拒座;S-906(core 7.37.0 按类作答)到货后本臂与旋钮一并硬删。计数 = 铸出的这类腿条数,detail = `mode=<词> released=<类…>`。",
22
- },
23
19
  "server.workflow-park-index.sync-failed": {
24
20
  cls: "P-DEBT",
25
21
  note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到,`/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`)——**方向是 fail-closed**(不误配、不伪造、不放行任何东西),丢的是「人能不能把这条批准送出去」这个能力。归 P 类的理由是它决定**车道选择**(缺行 ⇒ 选中 legacy 腿),归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车)。计数非零 = 有 park 因此决不掉,运维要按 detail 里的 runId 去查。",
@@ -268,7 +264,7 @@ export const FAIL_OPEN_TAGS = {
268
264
  "server.redact.ppk-eat-to-end": { cls: "F", note: "S-107 第三十二轮重扫:PuTTY .ppk 容器扫描器(redact.ts redactPpkContainers)在「无合格终止 / 收口后同容器范围再见私钥字段 / 收口后到下一版本头之间夹非空白」三形下从头**吃到输入末尾**(fail-closed 取证丢弃)——终态 errorMessage 座上=整条错误文案变成 «redacted:private-key»,与 redactions_applied_total{private-key} 的诚实替换同形不可分,故要有自己的计数/探针;影子扫不计;计数=多少次整段吃尾。" },
269
265
  "server.approval-card.closed-word-out-of-set": {
270
266
  cls: "F",
271
- note: "S-249(`approval-card.ts` 的边界窄读族;今天两个成员:`readRuleOffersAbsence`,与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。",
267
+ note: "S-249(`approval-card.ts` 的边界窄读族;今天三个成员:`readRuleOffersAbsence`、S-934 ② 起的 `readAskClassPair`(core 7.37.0 #1214 的类对 `askClass` / `classes`,活卡帧 / 呈卡帧的卡 / 运维队列行同一只,成员筛 = core `isAskClass`,两键同在同缺,半对 / 首员不符同样算读不出;detail 前缀 `askClass=` / `classes=`),与 S-721 起的 `readClosedWordSeat` —— 一座一只 core 成员筛,座是参数:`mandate`(core 7.32.0 #1092,活卡帧 / 活挂起行 / 运维队列行同一只,闭集筛 = core `isPersistedRuleMandate`)与 S-755 起的 `origin`(运维队列行读 park 行孪生座,闭集筛 = core `isAskOrigin`,与 inbox 行 core 投影处同一只筛);detail 前缀 `ruleOffersAbsence=` / `mandate=` / `origin=` 判别是哪一座):活卡帧 / `card_json` / (S-700 起)运维队列行(`parkRowFacts` 读寄存行上的 park 孪生座;队列是热读面 ⇒ 同一行每读一次计一次)的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40);数 / 布尔等原始值原样,对象 / 数组 / 函数 / null 只报形名(`<object>` / `<array>` / `<function>` / `<null>`,S-700 codex r1:格式化从不调用值自己的转换方法 —— 持久层里一格 `{\"toString\":null}` 曾让诊断本身抛出、打掉整只队列)。",
272
268
  },
273
269
  "server.pg-pool.idle-client-error": {
274
270
  cls: "F",
@@ -294,6 +290,10 @@ export const FAIL_OPEN_TAGS = {
294
290
  cls: "F",
295
291
  note: "S-582(`plugins/send-file-ledger.ts`,local 车道的发文件账本):一条账行读不出形(`42` / 缺 id·scope·objectKey 三元组 / 中间半截 JSON)⇒ 跳过它继续。最坏后果 = 那条已签发的链接在它租户的列表里**看不见**(对象本身没动;账本本来就不存 URL)。治理面少一行是可见性损失,不是放行 ⇒ F;但它恰是治理面要看的那一类事实,所以必须留痕 —— 修前零披露。",
296
292
  },
293
+ "server.outbound.connect-observer-threw": {
294
+ cls: "F",
295
+ note: "S-925(`outbound-dispatcher.ts` 的 undici 诊断通道订阅者:`undici:request:create` / `:headers` / `:error`):一只订阅回调抛了。通道在发布方的调用栈里同步回调订阅者,抛出去在 Node 上是未捕获异常(整进程倒),所以每只回调整体 try、落本 tag。最坏后果 = 这一条请求的建连失败没被记下:少一行 `outbound_connect_failed`、诊断段 `outbound.lastConnectFailure` 不更新、那次模型调用不铸 `server.model_unreachable` 用户通告 —— 请求本身、它的错误、run 的终态一个字节不受影响(观察只读)。计数恒零是证据;非零 = undici 通道载荷的形变了,去看 detail 的 site。",
296
+ },
297
297
  };
298
298
  export function failOpenTagForDroppedFrame(frameType) {
299
299
  switch (frameType) {
@@ -134,7 +134,8 @@ export declare function redactModelText(raw: string): string;
134
134
  * 工具调用 id(`strandedHumanAnswers[].toolCallId` / `stats.humanReview.gates[].toolCallId`)**出身**同样是服务商,但自 core 7.36.0(#1207)起
135
135
  * 它进引擎之前已**准入一次**:三只 brain 在建调用的那一行(`brain/tool-call-id.js` 的 `admitToolCallId`)—— 缺席 ⇒ 车道兜底铸;
136
136
  * 在形(可见 ASCII 1–128 且不含 `~`)⇒ 原样;越界 ⇒ 单射折叠成引擎 id(`<前 32 码元,越界字符记 `-`>~<sha256 前 32 hex>`),原字节只挂在
137
- * 转录的 `ToolCall.wireId` 上、只回给 provider。⇒ 到达本仓任何一张脸的 `toolCallId` 恒在形。
137
+ * 转录的 `ToolCall.wireId` 上、只回给 provider。⇒ 一方 brain 在 7.107.0 起新铸的调用,到达本仓任何一张脸的 `toolCallId` 恒在形。
138
+ * 残余两条(S-921,如实):① ≤7.106.0 写下的存量行(待批 park 行 `boundCallId` / 检查点 `pendingAction.toolCallId` / 转录)是服务商原串逐字 —— 新版不对存量行二次准入(e2e G4 钉);② 宿主自己的 `Brain`(不经三只一方 adapter)铸的 id 不准入(core KL-1207-a;本仓生产装配的 brain 恒是 `createBrain` 的一方 adapter 外套委托装饰,这一形只经嵌入 / 测试注入的 brain 到达)。两条残余里的 id 仍是跨面 join 键、每张脸同值,只是形状不保证(本闸照旧不动它们:动一张脸 = join 断)。
138
139
  *
139
140
  * 🔴 **本闸只施加在「这个值出现的每一张脸都被覆盖到」的回声上**(codex r1 F1,实测成立的回归后改判):工具调用 id 是
140
141
  * **跨面 join 键** —— 同一枚 id 同时逐字出现在 `tool_start` / `tool_end` / 审批卡等逐帧面上。只在结果面动它 ⇒ 结果面与逐帧面
@@ -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;
@@ -1,6 +1,6 @@
1
1
  import type { Logger } from "../observability/logger.js";
2
2
  import type { AskState } from "../approval-ask-machine.js";
3
- import type { AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, EnsureAskResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
3
+ import type { AskCallCoordinate, AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, EnsureAskResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
4
4
  /** 数据根下的目录名。**按店命名**,与兄弟 File 店同规(`approval-nonces` / `approval-ask-audit` /
5
5
  * `approval-exemptions` / `resume-anchors` / `checkpoint-ctx` / …)—— 首版写的是族名 `approvals`,而那
6
6
  * 正是读者会去找**持久 park 行**的地方(它们其实在 `checkpoint-ctx`),且持久数据面的名字按硬 breaking
@@ -175,6 +175,7 @@ export declare class FileApprovalAskStore implements ApprovalAskStore {
175
175
  listByState(state: AskState, limit: number): Promise<AskRow[]>;
176
176
  listPendingByTask(taskId: string, signal?: AbortSignal): Promise<AskRow[]>;
177
177
  listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
178
+ listByCall(call: AskCallCoordinate): Promise<AskRow[]>;
178
179
  getAsk(askId: string): Promise<AskRow | null>;
179
180
  getByIdempotencyKey(taskId: string, key: string): Promise<AskRow | null>;
180
181
  getBatch(batchId: string): Promise<BatchRow | null>;
@@ -253,6 +253,9 @@ export class FileApprovalAskStore {
253
253
  listPendingBySession(sessionId, owner, signal) {
254
254
  return this.core.listPendingBySession(sessionId, owner, signal);
255
255
  }
256
+ listByCall(call) {
257
+ return this.core.listByCall(call);
258
+ }
256
259
  getAsk(askId) {
257
260
  return this.core.getAsk(askId);
258
261
  }
@@ -25,7 +25,7 @@
25
25
  * - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
26
26
  */
27
27
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
28
- import type { AskDecision, AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
28
+ import type { AskCallCoordinate, AskDecision, AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
29
29
  /**
30
30
  * [ref] 的 `updatedInput` **入店编码**——两 twin 共用的那一只(本函数是唯一真源)。
31
31
  *
@@ -98,6 +98,7 @@ export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
98
98
  * 已 abort 就一条都不读、如实抛,与 SQL twin 的第①条语义逐字一致(第②条在这里天然平凡成立)。 */
99
99
  listPendingByTask(taskId: string, signal?: AbortSignal): Promise<AskRow[]>;
100
100
  listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
101
+ listByCall(call: AskCallCoordinate): Promise<AskRow[]>;
101
102
  getAsk(askId: string): Promise<AskRow | null>;
102
103
  getByIdempotencyKey(taskId: string, key: string): Promise<AskRow | null>;
103
104
  resolveProvisional(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
@@ -1,5 +1,5 @@
1
1
  import { canAskTransition } from "../approval-ask-machine.js";
2
- import { assertDecideSettledBy, assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, claimTargetState, POLICY_REFUSAL_DECISION, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
2
+ import { askRowOnCall, assertDecideSettledBy, assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, claimTargetState, POLICY_REFUSAL_DECISION, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
3
3
  function applyPatch(row, patch) {
4
4
  if (patch.decision !== undefined)
5
5
  row.decision = patch.decision;
@@ -304,6 +304,12 @@ export class InMemoryApprovalAskStore {
304
304
  .sort((a, b) => a.createdAtMs - b.createdAtMs)
305
305
  .map((r) => ({ ...r }));
306
306
  }
307
+ async listByCall(call) {
308
+ return [...this.asks.values()]
309
+ .filter((r) => askRowOnCall(r, call))
310
+ .sort((a, b) => a.createdAtMs - b.createdAtMs || (a.askId < b.askId ? -1 : a.askId > b.askId ? 1 : 0))
311
+ .map((r) => ({ ...r }));
312
+ }
307
313
  async getAsk(askId) {
308
314
  const r = this.asks.get(askId);
309
315
  return r ? { ...r } : null;