@sema-agent/server 7.101.1 → 7.103.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 (113) hide show
  1. package/MIGRATION.md +84 -0
  2. package/README.md +4 -4
  3. package/README.zh-CN.md +4 -4
  4. package/USAGE.md +156 -9
  5. package/dist/approval-card.d.ts +35 -2
  6. package/dist/approval-card.js +19 -1
  7. package/dist/background-session-reap.d.ts +58 -0
  8. package/dist/background-session-reap.js +90 -0
  9. package/dist/bake-runner/main.d.ts +21 -3
  10. package/dist/bake-runner/main.js +18 -11
  11. package/dist/boot/config-center.d.ts +21 -4
  12. package/dist/boot/config-center.js +62 -19
  13. package/dist/boot/exit-boundary.js +17 -0
  14. package/dist/boot/runner-deps.js +1 -0
  15. package/dist/boot/runtime-caps.js +3 -2
  16. package/dist/boot/session-faces.js +3 -4
  17. package/dist/boot/shutdown.d.ts +2 -1
  18. package/dist/boot/shutdown.js +33 -12
  19. package/dist/boot/stage-01-config.js +2 -0
  20. package/dist/boot/stage-07-capability-layer.d.ts +1 -1
  21. package/dist/boot/stage-07-capability-layer.js +6 -4
  22. package/dist/boot/stage-08-reapers.d.ts +1 -1
  23. package/dist/boot/stage-09-leader.d.ts +1 -1
  24. package/dist/boot/stage-10-http-server.d.ts +1 -1
  25. package/dist/boot/stage-10-http-server.js +13 -4
  26. package/dist/brain.d.ts +28 -8
  27. package/dist/brain.js +20 -4
  28. package/dist/capabilities/builtin-tools.d.ts +14 -1
  29. package/dist/capabilities/builtin-tools.js +3 -0
  30. package/dist/capabilities/center-plugins.d.ts +9 -3
  31. package/dist/capabilities/center-plugins.js +5 -10
  32. package/dist/capabilities/scenarios.d.ts +1 -1
  33. package/dist/capabilities/scenarios.js +3 -3
  34. package/dist/center-credential-renewer.d.ts +76 -0
  35. package/dist/center-credential-renewer.js +359 -0
  36. package/dist/center-credential.d.ts +76 -22
  37. package/dist/center-credential.js +119 -43
  38. package/dist/config-catalog.js +17 -8
  39. package/dist/config-center/apply-effective.d.ts +16 -0
  40. package/dist/config-center/apply-effective.js +26 -21
  41. package/dist/config-center/center-request.d.ts +13 -0
  42. package/dist/config-center/center-request.js +23 -3
  43. package/dist/config-center/facade.d.ts +1 -1
  44. package/dist/config-center/http-client.js +2 -5
  45. package/dist/config-center/mcp-revocation.d.ts +2 -2
  46. package/dist/config-center/mcp-revocation.js +1 -1
  47. package/dist/config-center/read-warnings.d.ts +19 -48
  48. package/dist/config-center/read-warnings.js +7 -49
  49. package/dist/config-center/restart-signal.d.ts +8 -21
  50. package/dist/config-center/restart-signal.js +2 -12
  51. package/dist/config-center/types.d.ts +4 -2
  52. package/dist/config-provider.js +1 -48
  53. package/dist/config-types.d.ts +107 -17
  54. package/dist/config-types.js +7 -2
  55. package/dist/config.d.ts +7 -0
  56. package/dist/config.js +91 -29
  57. package/dist/device-store.d.ts +1 -1
  58. package/dist/env-name-allowlist-knobs.d.ts +21 -7
  59. package/dist/env-name-allowlist-knobs.js +7 -2
  60. package/dist/fleet-client.d.ts +27 -7
  61. package/dist/fleet-client.js +43 -12
  62. package/dist/fleet-lease.d.ts +1 -1
  63. package/dist/host-lsp-manager.d.ts +4 -3
  64. package/dist/host-lsp-manager.js +2 -6
  65. package/dist/http/approval-sweeps.js +5 -20
  66. package/dist/http/dispatch.js +24 -14
  67. package/dist/http/readiness.d.ts +87 -0
  68. package/dist/http/readiness.js +54 -0
  69. package/dist/http/resume-legs.js +5 -4
  70. package/dist/http/route-table.d.ts +7 -0
  71. package/dist/http/route-table.js +6 -0
  72. package/dist/http/routes/a2a-serve.js +6 -14
  73. package/dist/http/routes/approvals-assistant.js +1 -0
  74. package/dist/http/routes/background.d.ts +80 -0
  75. package/dist/http/routes/background.js +94 -0
  76. package/dist/http/routes/capabilities.js +2 -0
  77. package/dist/http/server.d.ts +25 -8
  78. package/dist/http/server.js +2 -2
  79. package/dist/leader/wire.d.ts +8 -1
  80. package/dist/leader/wire.js +4 -3
  81. package/dist/model-entry-refusal.d.ts +42 -19
  82. package/dist/model-entry-refusal.js +34 -19
  83. package/dist/model-route-endpoint.d.ts +10 -0
  84. package/dist/model-route-endpoint.js +6 -2
  85. package/dist/observability/fail-open.d.ts +2 -2
  86. package/dist/observability/fail-open.js +2 -2
  87. package/dist/observability/secret-env-scrub.d.ts +12 -0
  88. package/dist/observability/secret-env-scrub.js +12 -0
  89. package/dist/outbound-dispatcher.d.ts +125 -0
  90. package/dist/outbound-dispatcher.js +211 -0
  91. package/dist/parked-decide.d.ts +11 -5
  92. package/dist/parked-decide.js +4 -1
  93. package/dist/parked-revive-surface.d.ts +47 -0
  94. package/dist/parked-revive-surface.js +20 -0
  95. package/dist/plugins/background-shell-support.d.ts +20 -1
  96. package/dist/plugins/background-shell-support.js +26 -0
  97. package/dist/plugins/host-platform.d.ts +16 -1
  98. package/dist/plugins/host-platform.js +11 -0
  99. package/dist/plugins/remote-env-host.d.ts +14 -1
  100. package/dist/plugins/remote-env-host.js +15 -10
  101. package/dist/project-memory.js +3 -6
  102. package/dist/run-local.js +2 -0
  103. package/dist/server-secret-env.d.ts +97 -24
  104. package/dist/server-secret-env.js +37 -1
  105. package/dist/tool-approval.d.ts +30 -1
  106. package/dist/tool-approval.js +31 -8
  107. package/dist/trace/core-keyset-guard.d.ts +9 -4
  108. package/dist/trace/engine-notice-wire.d.ts +1 -1
  109. package/dist/trace/engine-notice-wire.js +2 -0
  110. package/dist/trace/project.js +13 -0
  111. package/dist/trace/redact.d.ts +11 -0
  112. package/dist/trace/redact.js +1 -0
  113. package/package.json +4 -3
@@ -0,0 +1,125 @@
1
+ /**
2
+ * S-776 —— server 进程出网的**唯一**一只 dispatcher:回环目标恒直连,其余按 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`
3
+ * 交给 undici `EnvHttpProxyAgent`。**一只 dispatcher、一处安装、零旋钮** —— 代理变量本身就是配置通道。
4
+ *
5
+ * ── 病(test 黑盒追出,core 直讯 09-26)────────────────────────────────────────────────────────────
6
+ * Node 的内建 `fetch` 不读这三只变量(Node 24 起要另开 `NODE_USE_ENV_PROXY`)。于是只能经代理出网的部署上,引擎 → 网关这一跳、
7
+ * 以及 server 自己所有出网跳(中心拉取 / 公告 / 凭证续签 / WebSearch / 对象存储 …)一律直连 ⇒ 网关主机名只有代理解析得了时
8
+ * `ENOTFOUND`、代理零命中。
9
+ *
10
+ * ── 为什么一处安装就覆盖全部(读实现得出,不是从名字推断)─────────────────────────────────────────
11
+ * · 本仓的出网 fetch 座默认都是**全局** `fetch`(`fetchImpl ?? fetch` / `fetchImpl: typeof fetch = fetch`),全局 fetch 每次请求
12
+ * 读 undici 的全局 dispatcher 槽;
13
+ * · core 的引擎传输(`@sema-agent/core` 7.33.1 `dist/brain/transport.js` 的 `hostDispatcher` / `resolveFetch`)给每次请求带 `ENGINE_DISPATCHER`,它是宿主
14
+ * 全局 dispatcher 的透明替身 —— **每次请求**按 handler API 版本读 `globalThis[Symbol.for("undici.globalDispatcher.2")]` / `…1`,
15
+ * 除逐请求的头 / 体钟外一切转发 ⇒ 本仓装一只全局 dispatcher,引擎跳同样生效,**core 零改**;
16
+ * · 槽位(车OY 实测,不臆断):npm undici 7.30.0 的 `setGlobalDispatcher` **两只槽同写**(`lib/global.js` 的 `setGlobalDispatcher` 两次 `defineProperty`),所以
17
+ * **一次调用**就同时覆盖 Node 20 / 22(内嵌 undici 6.24.1,读槽 1)与 Node 24(内嵌 7.10.0 / 7.29.1,读槽 1)的内建 `fetch`,
18
+ * 以及 core 按 handler API 选槽的那一读 —— 不需要第二只槽的安装代码。实测表(内建 `fetch()` 是否命中):Node 20.20.2 / 22.22.3 /
19
+ * 24.2.0 / 24.21.0 全命中,内建 fetch 交来的 handler 在四个版本上都是 v1 形(`onConnect`/`onHeaders`…),undici 7 的
20
+ * `DispatcherBase.dispatch` 自己 `UnwrapHandler.unwrap` 两形都收。
21
+ * · 🔴 **Bun 例外**(镜像 `CMD ["bun", "run", "src/main.ts"]` 与 `bun build --compile` 两形):`import "undici"` 在 Bun 下解析到
22
+ * Bun 自带的 undici 垫片 —— `setGlobalDispatcher` 只赋一个模块变量、两只槽都不写,Bun 的原生 `fetch` 也不读 dispatcher。
23
+ * Bun 的原生 fetch **自己**读 `HTTP(S)_PROXY` / `NO_PROXY`(Bun 1.3.14 实测:代理、`NO_PROXY` 精确 / 后缀 / `*` 三形都认),
24
+ * 但**回环不豁免**(`127.0.0.1` / `localhost` 照送代理)。本模块在那里是惰性的:安装后探槽,槽里不是我们 ⇒ 如实报
25
+ * `loopbackDirect:false`,配了代理时升 warn 并指路(见 {@link installOutboundDispatcher})。
26
+ *
27
+ * ── 回环恒直连(无条件规则;对 undici 缺省的**收窄**)─────────────────────────────────────────────
28
+ * `EnvHttpProxyAgent` 只按 `NO_PROXY` 判豁免,**没有回环隐式豁免**(undici 7.30.0 `lib/dispatcher/env-http-proxy-agent.js` 的 `#shouldProxy`:`NO_PROXY` 空 ⇒ 恒代理)
29
+ * ⇒ `HTTPS_PROXY` 设了而 `NO_PROXY` 没写 localhost 时,`127.0.0.1` 上的本机网关 / 本机中心会被送进代理 —— 代理够不到本进程的回环,
30
+ * 那是静默全断。所以回环目标在这里**先于** `NO_PROXY` 判、恒走直连 Agent;不看拓扑、不看客户端表态、没有旋钮(旋钮 = 让运维
31
+ * 能把本机流量送进公司代理,没有任何部署形需要它)。判据集与 sema-cli 1.0.136 `src/utils/proxy.ts:104` `isLoopbackHost`
32
+ * (壳 → 本机引擎那一跳的同形修 `LoopbackDirectDispatcher`,L-587)同一集,两段一致。
33
+ * **unix socket**:undici 的 `socketPath` 是连接器(`Agent` / `Client` 构造)选项,不是逐请求选项 ⇒ 打 unix socket 的调用必然自带
34
+ * dispatcher,**结构上**不经过全局槽 —— 本模块没有、也不需要一条 unix 臂。
35
+ *
36
+ * ── boot 冻结 ────────────────────────────────────────────────────────────────────────────────────
37
+ * 三只变量在构造时读**一次**(与本仓其余 env 同律)。`EnvHttpProxyAgent` 自己会在每次请求比对 `process.env.NO_PROXY` 并重读 ——
38
+ * 显式把 `noProxy`(以及两只代理)交给它就关掉那条运行期重读(`#noProxyChanged` 在 `opts.noProxy !== undefined` 时恒假)。
39
+ * 读法与 undici 读 `process.env` **逐字同序**:小写优先、`??`(空串算「设了」)、`HTTPS_PROXY` 缺席 / 空 ⇒ https 目标走 `HTTP_PROXY`
40
+ * (`EnvHttpProxyAgent` 构造器与 `#noProxyEnv`)—— 本模块读一次、再原样交给它,undici 不再读 env,读者只有这一处。
41
+ *
42
+ * ── 代理那一跳:失败可辨认 + 不许进重连风暴(test 复测义务 srv-s776-engine-proxy-recheck ④,板 [ref];opus 修复验证轮 F1 / F2)──
43
+ * 接缝 = `EnvHttpProxyAgent` 公开的 `clientFactory` 选项(类型面 `ProxyAgent.Options.clientFactory`,原样交给两只 `ProxyAgent`;交给
44
+ * 直连 `Agent` 的那份被它的 `deepClone`(JSON 往返)丢掉,不生效)—— 它造的是**连代理的那只客户端**,每一次代理拨号(TCP / TLS 到代理 +
45
+ * `CONNECT` 交换)都经它的 `connect`,不多不少。{@link ProxyDialPool} 在这里把两类结局收成一只错误 {@link OutboundProxyError}:
46
+ * · 拨号失败(拒连 / 解析不了 / 超时 / 代理 TLS / 收下就关 …)⇒ 包成 `OutboundProxyError`(`cause` = 原错误);
47
+ * · `CONNECT` 回非 200 ⇒ 关掉那条 socket(undici 自己也是这么做的),同样包成 `OutboundProxyError`(`cause` 说出状态码)。
48
+ * 🔴 **包装层不铸 `code`,这是承重的两件事**:① undici 客户端在连接阶段把 `UND_ERR_SOCKET` 当可恢复错误重连(`client.js` 的 `onError`),
49
+ * 而 `CONNECT` 恰在连接阶段 —— 代理收下 TCP 就关(Linux 上是 FIN ⇒ `UND_ERR_SOCKET`)时,裸 `ProxyAgent` / Node 24 `NODE_USE_ENV_PROXY`
50
+ * 都会重连死循环(修前实测 10 s 四五万条连接、请求挂到调用方超时、abort 之后仍在涨);无 code 的错误让排队的请求当场失败。
51
+ * ② core 的连接失败分类(`classifyConnectFailure`)只看链上的 `code`,无 code 的一节对它透明 —— 重试 / 切备 / 断路器的判据不变。
52
+ * 修前读数(装了 dispatcher、未加归因;引擎终局句 = core `describeNetworkError` 原样):代理拒连 `[network] fetch failed (connect ECONNREFUSED
53
+ * 127.0.0.1:1)`;代理主机解析不了 `… (getaddrinfo ENOTFOUND proxy.s776.invalid)`;CONNECT 回 403 `… (Request was cancelled. ← UND_ERR_ABORTED:
54
+ * Proxy response (403) !== 200 when HTTP Tunneling)` —— 都不说「经代理」。修后:`… (via outbound proxy (from HTTPS_PROXY) ←
55
+ * connect ECONNREFUSED 127.0.0.1:1)`(message 不带代理地址 —— 主会话 F3 裁定,见 {@link OutboundProxyError})。core 的句子一字不改(它逐层展开 cause 链,包装层就是链上多一节)。隧道建好之后的失败(目标的 TLS、
56
+ * 目标的响应)不经这个接缝,不冠代理 —— 那是目标的事。
57
+ *
58
+ * ── 安全轴 ───────────────────────────────────────────────────────────────────────────────────────
59
+ * 代理 URL 的 userinfo 是凭据(undici 把它译成 `Proxy-Authorization: Basic …`)。它只进 dispatcher,不进任何观测面:姿态
60
+ * ({@link buildOutboundProxyPosture})、拒启句与归因句同一把尺 {@link displayProxyUrl}(形干净只出 `scheme//host[:port]`、过全仓那一只
61
+ * display-safe 铸点;形不干净整只占位);姿态只上启动日志一行
62
+ * `outbound_proxy`,不进 `/health`、不进公告、不进租户 wire(代理那一跳失败的终局句只说「经代理 + 哪只变量」,F3 裁定)。代理变量不可用(undici 构造即拒:无 scheme、`ftp:`、带路径 / 查询)⇒
63
+ * **拒启**(与 Node 24 `NODE_USE_ENV_PROXY=1` 遇坏值的行为同向)—— 静默直连 = 运维以为出网走了代理而实际没有,那是出网策略面的
64
+ * fail-open;拒因只回显 display-safe 值。
65
+ */
66
+ import { Dispatcher } from "undici";
67
+ import type { Logger } from "./observability/logger.js";
68
+ /**
69
+ * 出网代理姿态(`build*`:纯数据)—— 启动日志 `outbound_proxy` 行与配置目录共读的**同一份**投影。
70
+ * · `httpProxy` / `httpsProxy`:http: / https: 目标**实际**走的代理(display-safe;`null` = 直连)。`httpsProxy` 在 `HTTPS_PROXY`
71
+ * 缺席 / 空时回落 `HTTP_PROXY` —— undici 的读法,不是本仓的发明;
72
+ * · `noProxy`:条目原样(按 undici 的切分:逗号或空白;`null` = 未设 / 空)。
73
+ */
74
+ export interface OutboundProxyPosture {
75
+ readonly httpProxy: string | null;
76
+ readonly httpsProxy: string | null;
77
+ readonly noProxy: readonly string[] | null;
78
+ }
79
+ export declare function buildOutboundProxyPosture(env?: NodeJS.ProcessEnv): OutboundProxyPosture;
80
+ /**
81
+ * 目标 origin 会不会落在**本机**(回环 / 未指定地址)。入参是 dispatch 的 `origin`(`string | URL`);解析不动 ⇒ `false`
82
+ * (按远端处置 = 交给代理那一半,与修前方向一致)。认哪几形(与 sema-cli 1.0.136 `isLoopbackHost` 同集):
83
+ * · `localhost`(含 FQDN 尾点 `localhost.`);刻意**不**认 `*.localhost` 子域(能否落到本机取决于系统解析器,不是地址本身的事实);
84
+ * · IPv4 `127.0.0.0/8` 整段(WHATWG URL 先把 `127.1` / `0x7f.1` 这类写法规范成点分四段);
85
+ * · IPv6 `::1`、IPv4 映射段 `::ffff:127.x.x.x`(URL 规范成 `::ffff:7fxx:xxxx` 形);
86
+ * · 未指定地址 `0.0.0.0` / `::`(及映射形):作为目的地址它在本机就是「本机」,送进代理只会让代理去连它自己。
87
+ */
88
+ export declare function isLoopbackOrigin(origin: string | URL | undefined): boolean;
89
+ /**
90
+ * 代理那一跳失败的**唯一**错误形(`cause` = 原错误;**不铸 `code`**,理由见顶注)。`proxy` = display-safe 代理地址,`from` = 哪只变量给的。
91
+ * 🔴 **message 不带地址**(主会话 F3 裁定,09-26):这句经 core 的终局句进租户可见的失败原因;地址是基础设施细节,只点明「经代理 + 哪只变量」,
92
+ * 一条规则、不按姿态分叉。地址留在 `proxy` 属性上供进程内日志腿取;单用户运维在启动日志 `outbound_proxy` 行里有同一串可对照。根因那一节是
93
+ * undici 自己的错误原文(拒连 / 解析不了两形会点名代理的 host:port,与直连失败点名目标同形),本仓不改写上游错误。
94
+ * 字符串化(`String(err)`)带出根因:只打第一层 cause 的日志腿(如 hook 的 `String(cause)`)也读得到 `ECONNREFUSED` 这类根因;
95
+ * core 的 `describeNetworkError` 读的是 `.message`,不受影响、不重复。
96
+ */
97
+ export declare class OutboundProxyError extends Error {
98
+ readonly proxy: string;
99
+ readonly from: string;
100
+ readonly name = "OutboundProxyError";
101
+ constructor(proxy: string, from: string, cause: unknown);
102
+ toString(): string;
103
+ }
104
+ /** 代理变量不可用的拒启码(拼法随 `config.*` 家族,出现在拒句方括号里)。 */
105
+ export declare const OUTBOUND_PROXY_INVALID_CODE = "config.outbound_proxy_invalid";
106
+ /**
107
+ * 构造(`create*`:活对象)。代理变量不可用(undici 构造即拒)⇒ **抛**,拒因点名变量、只回显 {@link displayProxyUrl} 的形
108
+ * (undici 自己的错误文案不转述:`ERR_INVALID_URL` 把原值挂在 `input` 上,转述即泄漏的一条路)。
109
+ */
110
+ export declare function createOutboundDispatcher(env?: NodeJS.ProcessEnv): {
111
+ dispatcher: Dispatcher;
112
+ posture: OutboundProxyPosture;
113
+ };
114
+ /** 安装结果 = 姿态 + 这条规则在本进程**是否生效**(运行时的 fetch 读不读 undici 全局槽 —— Node 读、Bun 不读)。 */
115
+ export interface OutboundDispatcherInstall extends OutboundProxyPosture {
116
+ readonly loopbackDirect: boolean;
117
+ }
118
+ /**
119
+ * 进程级安装(**幂等**:第二次调用原样返回第一次的结果,不再装)。唯二调用点:HTTP 服务的 `boot/stage-01-config.ts` 末尾
120
+ * (config 已载、任何出网之前)与一次性 CLI `run-local.ts` 的同一位置(它不经 stage-01)。
121
+ */
122
+ export declare function installOutboundDispatcher(logger: Logger, env?: NodeJS.ProcessEnv): OutboundDispatcherInstall;
123
+ /** 测试专用:撤回安装(槽还给装之前的那只,本只关掉),下一次 {@link installOutboundDispatcher} 重新读 env。 */
124
+ export declare function resetOutboundDispatcherForTest(): void;
125
+ //# sourceMappingURL=outbound-dispatcher.d.ts.map
@@ -0,0 +1,211 @@
1
+ import { Agent, Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, Pool, setGlobalDispatcher } from "undici";
2
+ import { isIP } from "node:net";
3
+ import { buildDisplaySafeUrl, DISPLAY_SAFE_URL_TOKEN } from "./trace/redact.js";
4
+ function readOutboundProxyEnv(env = process.env) {
5
+ return {
6
+ httpProxy: env.http_proxy ?? env.HTTP_PROXY ?? "",
7
+ httpsProxy: env.https_proxy ?? env.HTTPS_PROXY ?? "",
8
+ noProxy: env.no_proxy ?? env.NO_PROXY ?? "",
9
+ };
10
+ }
11
+ function isCleanProxyUrl(raw) {
12
+ return /^[a-z][a-z0-9+.-]*:\/\/[^/?#\\]+\/?$/i.test(raw) && URL.canParse(raw) && new URL(raw).host !== "";
13
+ }
14
+ function displayProxyUrl(raw) {
15
+ if (!isCleanProxyUrl(raw))
16
+ return DISPLAY_SAFE_URL_TOKEN;
17
+ const u = new URL(raw);
18
+ return buildDisplaySafeUrl(`${u.protocol}//${u.host}`);
19
+ }
20
+ function postureOf(v) {
21
+ const shown = (raw) => (raw === "" ? null : displayProxyUrl(raw));
22
+ const entries = v.noProxy.split(/[,\s]/).filter((e) => e !== "");
23
+ return { httpProxy: shown(v.httpProxy), httpsProxy: shown(v.httpsProxy || v.httpProxy), noProxy: entries.length > 0 ? entries : null };
24
+ }
25
+ export function buildOutboundProxyPosture(env = process.env) {
26
+ return postureOf(readOutboundProxyEnv(env));
27
+ }
28
+ export function isLoopbackOrigin(origin) {
29
+ if (origin === undefined)
30
+ return false;
31
+ const text = String(origin);
32
+ if (!URL.canParse(text))
33
+ return false;
34
+ let host = new URL(text).hostname.toLowerCase();
35
+ if (host.startsWith("[") && host.endsWith("]"))
36
+ host = host.slice(1, -1);
37
+ host = host.replace(/\.+$/, "");
38
+ if (host === "localhost")
39
+ return true;
40
+ const family = isIP(host);
41
+ if (family === 4)
42
+ return host.startsWith("127.") || host === "0.0.0.0";
43
+ if (family !== 6)
44
+ return false;
45
+ if (host === "::1" || host === "::")
46
+ return true;
47
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
48
+ if (mapped === null)
49
+ return false;
50
+ const hi = Number.parseInt(mapped[1], 16);
51
+ const lo = Number.parseInt(mapped[2], 16);
52
+ return hi >> 8 === 127 || (hi === 0 && lo === 0);
53
+ }
54
+ class OutboundDispatcher extends Dispatcher {
55
+ direct;
56
+ proxied;
57
+ constructor(direct, proxied) {
58
+ super();
59
+ this.direct = direct;
60
+ this.proxied = proxied;
61
+ }
62
+ dispatch(options, handler) {
63
+ return (isLoopbackOrigin(options.origin) ? this.direct : this.proxied).dispatch(options, handler);
64
+ }
65
+ close(callback) {
66
+ const done = Promise.all([this.direct.close(), this.proxied.close()]).then(() => undefined);
67
+ if (callback === undefined)
68
+ return done;
69
+ void done.then(callback, callback);
70
+ }
71
+ destroy(errOrCallback, callback) {
72
+ const cb = typeof errOrCallback === "function" ? errOrCallback : callback;
73
+ const err = typeof errOrCallback === "function" ? null : (errOrCallback ?? null);
74
+ const done = Promise.all([this.direct.destroy(err), this.proxied.destroy(err)]).then(() => undefined);
75
+ if (cb === undefined)
76
+ return done;
77
+ void done.then(cb, cb);
78
+ }
79
+ }
80
+ export class OutboundProxyError extends Error {
81
+ proxy;
82
+ from;
83
+ name = "OutboundProxyError";
84
+ constructor(proxy, from, cause) {
85
+ super(`via outbound proxy (from ${from})`, { cause });
86
+ this.proxy = proxy;
87
+ this.from = from;
88
+ }
89
+ toString() {
90
+ let root = this.cause;
91
+ for (let depth = 0; root instanceof Error && root.cause instanceof Error && depth < 4; depth++)
92
+ root = root.cause;
93
+ return `${this.name}: ${this.message} ← ${root instanceof Error ? root.message : String(root)}`;
94
+ }
95
+ }
96
+ const PROXY_CONNECT_DEADLINE_MS = 10_000;
97
+ const dialDeadlines = new WeakMap();
98
+ function clearDialDeadline(socket) {
99
+ clearTimeout(dialDeadlines.get(socket));
100
+ dialDeadlines.delete(socket);
101
+ }
102
+ class ProxyDialPool extends Pool {
103
+ shown;
104
+ from;
105
+ constructor(origin, options, shown, from) {
106
+ super(origin, options);
107
+ this.shown = shown;
108
+ this.from = from;
109
+ }
110
+ connect(options, callback) {
111
+ if (callback === undefined) {
112
+ return super.connect(options).then((data) => {
113
+ const refused = this.refusal(data);
114
+ if (refused !== undefined)
115
+ throw refused;
116
+ return data;
117
+ }, (err) => {
118
+ throw new OutboundProxyError(this.shown, this.from, err);
119
+ });
120
+ }
121
+ super.connect(options, (err, data) => callback(err !== null ? new OutboundProxyError(this.shown, this.from, err) : (this.refusal(data) ?? null), data));
122
+ }
123
+ refusal(data) {
124
+ clearDialDeadline(data.socket);
125
+ if (data.statusCode === 200)
126
+ return undefined;
127
+ data.socket.on("error", () => undefined).destroy();
128
+ return new OutboundProxyError(this.shown, this.from, new Error(`proxy answered CONNECT with HTTP ${String(data.statusCode)}`));
129
+ }
130
+ }
131
+ function withDialDeadline(options) {
132
+ const inner = Reflect.get(options, "connect");
133
+ if (typeof inner !== "function")
134
+ return { ...options };
135
+ const connect = (opts, callback) => {
136
+ const expiresAt = performance.now() + PROXY_CONNECT_DEADLINE_MS;
137
+ Reflect.apply(inner, undefined, [
138
+ opts,
139
+ (err, socket) => {
140
+ if (err === null && socket !== null) {
141
+ const expire = () => void socket.destroy(new Error(`proxy did not answer CONNECT within ${String(PROXY_CONNECT_DEADLINE_MS)} ms`));
142
+ dialDeadlines.set(socket, setTimeout(expire, Math.max(0, expiresAt - performance.now())));
143
+ socket.once("close", () => clearDialDeadline(socket));
144
+ callback(null, socket);
145
+ }
146
+ else
147
+ callback(err ?? new Error("proxy connector returned neither a socket nor an error"), null);
148
+ },
149
+ ]);
150
+ };
151
+ return { ...options, connect };
152
+ }
153
+ function createProxyClientFactory(v) {
154
+ const vars = [["HTTP_PROXY", v.httpProxy], ["HTTPS_PROXY", v.httpsProxy]].filter(([, raw]) => raw !== "" && URL.canParse(raw));
155
+ return (origin, options) => {
156
+ const same = vars.filter(([, raw]) => new URL(raw).href === origin.href);
157
+ const raw = same[0]?.[1] ?? origin.href;
158
+ return new ProxyDialPool(origin, withDialDeadline(options), displayProxyUrl(raw), same.length > 0 ? same.map(([name]) => name).join("/") : "proxy variable");
159
+ };
160
+ }
161
+ export const OUTBOUND_PROXY_INVALID_CODE = "config.outbound_proxy_invalid";
162
+ export function createOutboundDispatcher(env = process.env) {
163
+ const v = readOutboundProxyEnv(env);
164
+ const shown = (name, raw) => `${name}=${raw === "" ? "(unset)" : `"${displayProxyUrl(raw)}"`}`;
165
+ const refuse = (code) => new Error(`[${OUTBOUND_PROXY_INVALID_CODE}] the outbound proxy variables are not usable by the HTTP transport (${code}): ${shown("HTTP_PROXY/http_proxy", v.httpProxy)}, ${shown("HTTPS_PROXY/https_proxy", v.httpsProxy)} — ` +
166
+ `write the proxy as a full URL (http://host:port or https://host:port; userinfo allowed, no path or query), or unset it for this process`);
167
+ if ([v.httpProxy, v.httpsProxy].some((raw) => raw.includes("://") && !isCleanProxyUrl(raw)))
168
+ throw refuse("ambiguous-url");
169
+ let proxied;
170
+ try {
171
+ proxied = new EnvHttpProxyAgent({ httpProxy: v.httpProxy, httpsProxy: v.httpsProxy, noProxy: v.noProxy, clientFactory: createProxyClientFactory(v) });
172
+ }
173
+ catch (err) {
174
+ throw refuse(err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : "invalid");
175
+ }
176
+ return { dispatcher: new OutboundDispatcher(new Agent(), proxied), posture: postureOf(v) };
177
+ }
178
+ const GLOBAL_DISPATCHER_SLOTS = [Symbol.for("undici.globalDispatcher.1"), Symbol.for("undici.globalDispatcher.2")];
179
+ let installed;
180
+ export function installOutboundDispatcher(logger, env = process.env) {
181
+ if (installed !== undefined)
182
+ return installed.result;
183
+ const prior = getGlobalDispatcher();
184
+ const { dispatcher, posture } = createOutboundDispatcher(env);
185
+ setGlobalDispatcher(dispatcher);
186
+ const loopbackDirect = GLOBAL_DISPATCHER_SLOTS.every((slot) => Reflect.get(globalThis, slot) === dispatcher);
187
+ const result = { ...posture, loopbackDirect };
188
+ const runtime = process.versions.bun !== undefined ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
189
+ if (!loopbackDirect && (posture.httpProxy !== null || posture.httpsProxy !== null)) {
190
+ logger.warn("outbound_proxy", {
191
+ ...result,
192
+ runtime,
193
+ note: "this runtime's fetch does not read the undici global dispatcher: it applies HTTP(S)_PROXY / NO_PROXY itself and does NOT exempt loopback targets — list localhost,127.0.0.1,::1 in NO_PROXY so local gateways / centers are not sent to the proxy",
194
+ });
195
+ }
196
+ else {
197
+ logger.info("outbound_proxy", { ...result, runtime });
198
+ }
199
+ installed = { dispatcher, prior, result };
200
+ return result;
201
+ }
202
+ export function resetOutboundDispatcherForTest() {
203
+ if (installed === undefined)
204
+ return;
205
+ const { dispatcher, prior } = installed;
206
+ installed = undefined;
207
+ if (prior !== undefined)
208
+ setGlobalDispatcher(prior);
209
+ void dispatcher.close().catch(() => undefined);
210
+ }
211
+ //# sourceMappingURL=outbound-dispatcher.js.map
@@ -9,7 +9,9 @@
9
9
  * 行永 parked、生命周期分叉)。
10
10
  */
11
11
  import type { HostDecision } from "./host-decision.js";
12
- import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, ResumeOutcome, TaskRegistry, ToolSpec, WorkflowRun, WorkflowRunStore } from "@sema-agent/core";
12
+ import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, ResumeOutcome, TaskRegistry, WorkflowRun, WorkflowRunStore } from "@sema-agent/core";
13
+ import type { ParkedReviveSurfaceFactory } from "./parked-revive-surface.js";
14
+ import type { RebuiltInheritedGate } from "./boot/parked-revive-gate.js";
13
15
  import { type WorkflowRunParkKey, type WorkflowAgentSessionIndex } from "./orchestration/workflow-agent-session-index.js";
14
16
  export interface ParkedAgentMatch {
15
17
  handle: string;
@@ -112,9 +114,13 @@ export interface ParkedDecideDeps {
112
114
  checkpointStore: Pick<CheckpointStore, "get">;
113
115
  /** 与 reaper/scenarios 同一个 registry(claim/rollback 的 CAS 与 in-memory handle 收敛在同处)。 */
114
116
  registry: Pick<TaskRegistry, "claimParkedAgent" | "rollbackParkedClaim">;
115
- /** boot 期从生产装配(buildScenarios)取的裸 Agent ToolSpec——与 spawn 用同一装配路径,
116
- * subRunner/bg opts/checkpointStore 同实例天然成立([ref] 设计稿 §4)。 */
117
- reviveTool: ToolSpec;
117
+ /** #1110(core 7.32.0):按**行**解析赎回面 —— 驱动哪一只 Agent 工具(生产装配,与 spawn 同源:subRunner / bg opts /
118
+ * checkpointStore 同实例,[ref] 设计稿 §4),以及与它同出一张列表的继承座 `mountsCallerTool`(一层行才有;属主与全部判据
119
+ * 在 `parked-revive-surface.ts`)。修前是 boot 期的一只裸 Agent 工具、从不交座 ⇒ 7.32.0 起停在继承工具上的 allow 恒 422。 */
120
+ reviveSurface: ParkedReviveSurfaceFactory;
121
+ /** #1110 codex r1 [high]:会话属主面(`sessionStorage.ownerOf`)—— 赎回腿在 claim 前读**根会话现在**的属主交给赎回面(属主换人 / 会话
122
+ * 已不在 ⇒ 不交座)。缺席 = 本部署无属主面 ⇒ 读数缺席 ⇒ 不交座(fail-closed)。读抛 ⇒ 原样上抛(claim 之前,没有东西要回滚)。 */
123
+ sessionOwnerOf?: (sessionId: string) => Promise<string | null | undefined>;
118
124
  /** revive args 的 `subagent_type` 成员性判定集([ref] Q1 修正:非 roster 成员必须省略,否则
119
125
  * unknown subagent_type 拒;字面比对 general-purpose 在 roster 撞名时会错省)。缺省空集=恒省略。 */
120
126
  knownAgentTypes?: ReadonlySet<string>;
@@ -130,7 +136,7 @@ export interface ParkedDecideDeps {
130
136
  * 「席位必须同步」是那个文件此前自设的约束、不是结构事实。 */
131
137
  rebuildInheritedGate?: (row: BackgroundAgentRecord, identity: {
132
138
  principal: string | undefined;
133
- }) => Promise<unknown>;
139
+ }) => Promise<RebuiltInheritedGate | undefined>;
134
140
  warn?: (event: string, fields: Record<string, unknown>) => void;
135
141
  }
136
142
  export interface ParkedDecideRequest {
@@ -149,6 +149,8 @@ export async function decideParkedAgent(deps, req, match) {
149
149
  return { status: 409, body: { error: "parked row carries no agent name — the revive path requires a named agent (row damaged?)", errorCode: "decide.row_unrevivable", taskId: match.handle } };
150
150
  }
151
151
  const inheritedGate = deps.rebuildInheritedGate ? await deps.rebuildInheritedGate(row, { principal: decodeCheckpointScope(req.scope) }) : undefined;
152
+ const rootOwner = row.rootSessionId !== undefined && deps.sessionOwnerOf ? await deps.sessionOwnerOf(row.rootSessionId) : undefined;
153
+ const surface = deps.reviveSurface(row, { principal: decodeCheckpointScope(req.scope), ...(rootOwner !== undefined ? { rootOwner } : {}) });
152
154
  const stores = { agentStore: deps.agentStore, checkpointStore: deps.checkpointStore };
153
155
  const claim = await deps.registry.claimParkedAgent(stores, match.handle, { scope: row.scope, owner: row.owner });
154
156
  if (!claim.ok) {
@@ -180,6 +182,7 @@ export async function decideParkedAgent(deps, req, match) {
180
182
  const outcome = buildParkedResumeOutcome(req);
181
183
  const ctx = {
182
184
  toolCallId: `drv-${ticket.claimId}`,
185
+ ...(surface.mountsCallerTool !== undefined ? { mountsCallerTool: surface.mountsCallerTool } : {}),
183
186
  reviveClaim: {
184
187
  row: { ...ticket.row, parkClaimId: ticket.claimId },
185
188
  rev: ticket.reservedRev,
@@ -211,7 +214,7 @@ export async function decideParkedAgent(deps, req, match) {
211
214
  defaultSubagentTailBus.forgetHandle(match.handle);
212
215
  let receipt;
213
216
  try {
214
- receipt = await deps.reviveTool.execute(mintReviveArgs(row, deps.knownAgentTypes ?? new Set()), ctx);
217
+ receipt = await surface.reviveTool.execute(mintReviveArgs(row, deps.knownAgentTypes ?? new Set()), ctx);
215
218
  }
216
219
  catch (e) {
217
220
  const disp = await rollback("execute_threw");
@@ -0,0 +1,47 @@
1
+ /**
2
+ * #1110(core 7.32.0)—— `/decide` parked 赎回腿的**赎回面**:驱动哪一只 Agent 工具,以及交不交继承座
3
+ * `ToolExecuteContext.mountsCallerTool`。
4
+ *
5
+ * core 7.32.0 起 Agent 子代按**身份**继承场景层工具(bundle 交给 Agent 的 `inheritedTools` ∩ 父 run 的 `spec.tools`),由宿主
6
+ * **自己**驱动 `execute` 的一方(本腿)必须交座,否则 fail-closed:复活的子代什么都不继承,而停在一只**继承工具**上的 `allow` 在消费
7
+ * 审批之前被拒(`parked_resume.startup_failed` ⇒ `parked-decide.ts` 的 422 臂,审批留 pending;`deny` 照常结)。
8
+ *
9
+ * 规则(core [ref] / unreleased-1110 原话,一条):**座建在那条行的根会话解析出来的同一张列表上,赎回工具取自同一张列表**。
10
+ * · 列表 = 内建 default 场景工厂按行的根会话重解一次(`defaultScenario({}, principal, { sessionId: root })`)—— 与生产 resolve-spec
11
+ * 给那个会话的 run 组 `spec.tools` 的同一只工厂、同一个会话分区(任务清单店 `taskListStoreFor(root)` 与领队那一张是同一张;
12
+ * 多租户的 WebFetch 过滤同样生效 ⇒ 子代同样拿不到)。座 = `(t) => tools.includes(t)`(身份判:同名副本不算)。
13
+ * · **禁**用 boot 期单例填座:那只 bundle 没有会话,它的任务清单是一张私有表 —— 填了座,每个会话 / 租户的复活队友都会共用它。
14
+ * · **根会话现在还属于这张卡的租户**才填座(codex r1 [high]):任务清单店只按会话 id 分区,而会话 id 由调用方自选、删后可被别人重领 ——
15
+ * 根会话删了(或换了主人)之后赎回一张旧卡,按 id 重解出来的是**新主人**的清单。属主读数由赎回腿在 claim 前读(`identity.rootOwner`,
16
+ * 读自会话属主面;缺席 = 会话已不在 / 读不出),与卡的权威 principal 逐字相等才填;否则不交座(fail-closed:子代不继承,继承工具上的
17
+ * allow 回 422、审批待决,deny 照结)。根删除时把子孙的 parked 卡一并围栏 / 过期 = 另车(会话化身轴,[ref] 同族)。
18
+ * · 只对**一层**行填座(`rootSessionId` 在场且 = `parentSessionId`:派它的 run 就是根会话的 run,列表正是那个 run 的挂载)。更深的行
19
+ * (孙代:派它的是一个子代,它的挂载是继承裁剪后的子集,行上读不出来)不填 —— 用 boot 期那只裸 Agent、无座 = core 的 fail-closed 臂;
20
+ * 行上带继承组成是 core #1112(7.32.x)的半场,到货再改读行。
21
+ * 近似如实记:重解的列表是**部署缺省**那一份,读不回那一 run 的逐请求差异(`settings.webSearch` 后端、center overlay 场景)——
22
+ * 座只判 bundle 实例的成员性,继承面是 `[...web, ...scaffolds]`,那两项差异不改变谁在面内。
23
+ */
24
+ import { type BackgroundAgentRecord, type ToolSpec } from "@sema-agent/core";
25
+ import type { Scenario } from "./capabilities/scenarios.js";
26
+ /** 一次赎回用的面:驱动的 Agent 工具 + (一层行时)与它同出一张列表的继承座。 */
27
+ export interface ParkedReviveSurface {
28
+ readonly reviveTool: ToolSpec;
29
+ /** 缺席 = 不交座(core fail-closed:复活的子代不继承任何场景层工具)。 */
30
+ readonly mountsCallerTool?: (tool: ToolSpec) => boolean;
31
+ }
32
+ /**
33
+ * 按行解析赎回面的入参身份:`principal` = 权威 principal(取 checkpoint 的 scope 列,不取行上有损的 `row.scope`);`rootOwner` = 行的根会话
34
+ * **现在**的属主(赎回腿在 claim 前读会话属主面;`null` = 匿名会话,缺席 = 会话已不在 / 读不出 / 本部署无属主面)。
35
+ */
36
+ export interface ParkedReviveSurfaceIdentity {
37
+ readonly principal: string | undefined;
38
+ readonly rootOwner?: string | null;
39
+ }
40
+ /** 按行解析赎回面。 */
41
+ export type ParkedReviveSurfaceFactory = (row: BackgroundAgentRecord, identity: ParkedReviveSurfaceIdentity) => ParkedReviveSurface;
42
+ /**
43
+ * 赎回面工厂。入参 = **内建** default 场景工厂(stage-07 在 center overlay 盖名之前取的那一只;赎回要的是与 spawn 同源的生产装配)。
44
+ * 内建 bundle 里没有 Agent 工具 ⇒ `undefined`(park 本就不可达,赎回腿不接线)。
45
+ */
46
+ export declare function createParkedReviveSurface(defaultScenario: Scenario): ParkedReviveSurfaceFactory | undefined;
47
+ //# sourceMappingURL=parked-revive-surface.d.ts.map
@@ -0,0 +1,20 @@
1
+ import { DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
2
+ const agentOf = (tools) => tools.find((t) => t.name === DEFAULT_SUBAGENT_TOOL_NAME);
3
+ export function createParkedReviveSurface(defaultScenario) {
4
+ const bootTool = agentOf(defaultScenario({}).tools);
5
+ if (bootTool === undefined)
6
+ return undefined;
7
+ return (row, identity) => {
8
+ const root = row.rootSessionId;
9
+ if (root === undefined || row.parentSessionId !== root)
10
+ return { reviveTool: bootTool };
11
+ if (identity.rootOwner === undefined || (identity.rootOwner ?? undefined) !== identity.principal)
12
+ return { reviveTool: bootTool };
13
+ const tools = defaultScenario({}, identity.principal, { sessionId: root }).tools;
14
+ const reviveTool = agentOf(tools);
15
+ if (reviveTool === undefined)
16
+ return { reviveTool: bootTool };
17
+ return { reviveTool, mountsCallerTool: (t) => tools.includes(t) };
18
+ };
19
+ }
20
+ //# sourceMappingURL=parked-revive-surface.js.map
@@ -21,7 +21,7 @@
21
21
  * re-read by byte cursor on each poll (path ①). Both are expressed through {@link BackgroundShellDriver}.
22
22
  */
23
23
  import { BackgroundShellError } from "@sema-agent/core";
24
- import type { BackgroundShellId, BackgroundPoll, BackgroundSpawnOptions, Result } from "@sema-agent/core";
24
+ import type { BackgroundHardKillOutcome, BackgroundShellId, BackgroundPoll, BackgroundSpawnOptions, Result } from "@sema-agent/core";
25
25
  import { StringDecoder } from "node:string_decoder";
26
26
  /** Per-端 background capability bounds, surfaced as `backgroundCapabilities` on the env ([ref] §3.1/§3.6).
27
27
  *
@@ -105,6 +105,13 @@ export interface BackgroundShellDriver<S> {
105
105
  kill(state: S): Promise<void>;
106
106
  /** Best-effort cleanup of ONE shell's transport resources (kill + remove temp dirs/handles). Should not throw. */
107
107
  disposeOne(state: S): Promise<void>;
108
+ /**
109
+ * S-745(core 7.32.0 #1107)—— **可选**的同步硬收一枪(`BackgroundShellCapability.killBackgroundNow` 的 driver 半场)。
110
+ * 只有**本机有进程组可发**的 driver 实现它(今天只有 host 车道);远端 / 沙箱车道留空 ⇒ 座位根本不挂出来,core 对那些行
111
+ * 答 `no_pgid` 并派普通 kill。契约(core `docs/background-process-lifetime.md`):同步(无 TERM、无定时器、无 await)、
112
+ * 幂等、永不抛、只对**证明得了还是自己的** pid 发信号(观察到退出 ⇒ `already_gone`,一枪都不发)。
113
+ */
114
+ killNow?(state: S): BackgroundHardKillOutcome;
108
115
  }
109
116
  export declare class BackgroundShellManager<S> {
110
117
  private readonly driver;
@@ -154,6 +161,18 @@ export declare class BackgroundShellManager<S> {
154
161
  private register;
155
162
  poll(shellId: BackgroundShellId): Promise<Result<BackgroundPoll, BackgroundShellError>>;
156
163
  kill(shellId: BackgroundShellId): Promise<Result<void, BackgroundShellError>>;
164
+ /**
165
+ * S-745 —— 座位 `killBackgroundNow(shellId)` 的 manager 半场:**同步**、永不抛。
166
+ * · 不是本 env 的 shell(未知 / 伪造 / 已被驱逐的终态行)⇒ `already_gone`(core 契约「or not its shell」);
167
+ * · driver 不实现 {@link BackgroundShellDriver.killNow} ⇒ `no_pgid`(没有同步硬收可落;实现座位的 env 才会走到这里);
168
+ * · driver 答出三词之外 ⇒ `no_pgid`(没有落地,可重试)。driver 的座位按契约**永不抛**(host 的那只由构造保证:只读子进程句柄
169
+ * + `hardKillGroupNow` 自己吞 `kill` 的拒因);万一抛了,本函数**不吞** —— 异常原样上抛给 core 的注册表,由它读成 `no_pgid` 并记
170
+ * `SEMA_DEBUG_SHUTDOWN` 取证行(core 契约:「A seat that throws … reads no_pgid」),不在这里再写一只静默兜底;
171
+ * · 落地 `killed` 且条目还在 `running` ⇒ 条目结算 `killed` + 清 BG 超时墙(与 {@link kill} 的记账同一形:这一枪是**我们**
172
+ * 打的,之后迟到的 `close` 不再把它改判成 `exited`)。条目状态**不参与**判「该不该发」:软梯子会先把条目翻成 `killed`
173
+ * 再等宽限(屏蔽 TERM 的组此刻还活着),所以「该不该发」只由 driver 读子进程句柄判。
174
+ */
175
+ killNow(shellId: BackgroundShellId): BackgroundHardKillOutcome;
157
176
  /** Kill + clean up EVERY shell. Best-effort, MUST NOT throw, idempotent ([ref] §3.7).
158
177
  *
159
178
  * core `except` 契约(background-shell.d.ts:core runtask 尾调 `{except: keepAlive}`):
@@ -216,6 +216,32 @@ export class BackgroundShellManager {
216
216
  }
217
217
  return ok(undefined);
218
218
  }
219
+ killNow(shellId) {
220
+ const entry = this.shells.get(shellId);
221
+ if (!entry)
222
+ return "already_gone";
223
+ const seat = this.driver.killNow;
224
+ if (seat === undefined)
225
+ return "no_pgid";
226
+ const outcome = seat.call(this.driver, entry.state);
227
+ switch (outcome) {
228
+ case "killed":
229
+ if (entry.status === "running") {
230
+ entry.status = "killed";
231
+ if (entry.timer)
232
+ clearTimeout(entry.timer);
233
+ }
234
+ return "killed";
235
+ case "already_gone":
236
+ case "no_pgid":
237
+ return outcome;
238
+ default: {
239
+ const unreachable = outcome;
240
+ void unreachable;
241
+ return "no_pgid";
242
+ }
243
+ }
244
+ }
219
245
  async dispose(opts) {
220
246
  const keep = opts?.except?.length ? new Set(opts.except) : undefined;
221
247
  const doomed = [];
@@ -23,7 +23,7 @@
23
23
  * structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
24
24
  */
25
25
  import type { ChildProcess } from "node:child_process";
26
- import { killProcessTree } from "@sema-agent/core";
26
+ import { killProcessTree, type BackgroundHardKillOutcome } from "@sema-agent/core";
27
27
  export declare const IS_WIN32: boolean;
28
28
  export interface HostShell {
29
29
  shell: string;
@@ -59,8 +59,23 @@ export declare function spawnGroupOptions(): {
59
59
  * INCLUDING the throw on a dead group (ESRCH): every call site already wraps in its own try/catch with its own
60
60
  * fallback (e.g. `child.kill("SIGKILL")`), and that structure must not change.
61
61
  * win32 = core `signalProcessTree(pid,"SIGKILL")` → `taskkill /F /T` (never throws — fire-and-forget spawn).
62
+ * 不返回「落地」证据(S-769 车PA):POSIX 组信号「任一成员收到即成功」,返回成功证明不了组里没有打不着的活成员 —— 这一枪之后
63
+ * 「这个组还在不在」只由观察回答(同步硬收座 {@link hardKillGroupNow} 的 ESRCH、node 收割组长),不由这里推断。
62
64
  */
63
65
  export declare function killTreeHard(pid: number): void;
66
+ /**
67
+ * S-745(core 7.32.0 #1107)—— **同步组硬收的一枪**:后台 driver 的 `killBackgroundNow` 座位用它(梯子的「组 SIGKILL」那一级,
68
+ * 不经 TERM、不挂定时器)。读数是 core 座位闭集里的三词,判据只看 `kill` 系统调用自己的结局:
69
+ * · POSIX:`kill(-pid, SIGKILL)` 落地 ⇒ `killed`;`ESRCH`(这个组已经没有进程)⇒ `already_gone`;其余拒因(`EPERM` 一族)⇒
70
+ * `no_pgid`(没有落地,调用方可重试;core 随即派普通 kill)。⚠️ S-769 读数:darwin 对「组里只剩还没被 node 收割的僵尸」也答 `EPERM`
71
+ * (linux 答成功)。本函数**不**替它改判:EPERM 同样是「组里还有无权发信号的活成员」的答复,只看组长分不开这两形(codex r1 C1 [high]
72
+ * 验真:组长已死未收割 + 活后代被拒 ⇒ 改判就把真被拒读成已不在)。落地也**不**等于「组已空」(组信号任一成员收到即成功,车PA)——
73
+ * 所以 `already_gone` 只有 ESRCH 这一个来源;梯子那一枪先落地、组长还没收割的那一拍,darwin 上如实答 `no_pgid`(未确认),linux 答 `killed`;
74
+ * · win32 ⇒ 恒 `no_pgid`(core 契约:win32 无同步硬收 —— 没有进程组,树杀要一个延后的发现步,同步 `taskkill` 会杀掉启动器而
75
+ * 留下 Git Bash 的作业组、却自称 `killed`)。
76
+ * 「这个 pid 还是不是我的」由**调用方**先判(观察到退出 ⇒ 不许再发,pid 可能已经是别人的):本函数只管打出去的那一枪。永不抛。
77
+ */
78
+ export declare function hardKillGroupNow(pid: number): BackgroundHardKillOutcome;
64
79
  /**
65
80
  * SOFT kill (design D2 — the bg KillShell/timeout-wall first rung; the caller owns the grace→hard escalation
66
81
  * timer, unchanged). POSIX = `process.kill(-pid, "SIGTERM")` byte-identical (throws on dead group — the caller
@@ -46,6 +46,17 @@ export function killTreeHard(pid) {
46
46
  }
47
47
  process.kill(-pid, "SIGKILL");
48
48
  }
49
+ export function hardKillGroupNow(pid) {
50
+ if (IS_WIN32)
51
+ return "no_pgid";
52
+ try {
53
+ process.kill(-pid, "SIGKILL");
54
+ return "killed";
55
+ }
56
+ catch (err) {
57
+ return err.code === "ESRCH" ? "already_gone" : "no_pgid";
58
+ }
59
+ }
49
60
  export function killTreeSoft(pid) {
50
61
  if (IS_WIN32) {
51
62
  signalProcessTree(pid, "SIGTERM");
@@ -1,6 +1,10 @@
1
- import { FileError, ExecutionError, RemoteExecutionError, SchedulerError, type SchedulerCapability, type ScheduledIntent, type SchedulerContext, type ScheduledTaskId, type ScheduledTaskSummary, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, BackgroundShellError, type BackgroundShellCapability, type BackgroundShellId, type BackgroundPoll, type BackgroundSpawnOptions } from "@sema-agent/core";
1
+ import { FileError, ExecutionError, RemoteExecutionError, SchedulerError, type SchedulerCapability, type ScheduledIntent, type SchedulerContext, type ScheduledTaskId, type ScheduledTaskSummary, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, BackgroundShellError, type BackgroundShellCapability, type BackgroundShellId, type BackgroundHardKillOutcome, type BackgroundPoll, type BackgroundSpawnOptions } from "@sema-agent/core";
2
2
  /** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */
3
3
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
4
+ /** SIGTERM → grace → SIGKILL (no host isolation). 🔴 宽限的**唯一属主**:本 driver 的 `kill` 梯子用它排升级定时器;会话级收敛
5
+ * (`background-session-reap.ts#reapSessionBackgroundHard`,DELETE 与停止口)在软面之后、硬面之前**等同一只**(车OL O1 —— 硬面不许把
6
+ * 这把梯子的用户面宽限取消),不另抄数。启动期读一次;`0` 被 `numEnvOr` 读作缺席(回落 1000),要几乎不等写 `1`。 */
7
+ export declare const HOST_BG_KILL_GRACE_MS: number;
4
8
  /** Construction config for {@link RemoteHostExecutionEnv}. */
5
9
  export interface HostEnvConfig {
6
10
  /**
@@ -242,6 +246,15 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
242
246
  private adoptRunningExec;
243
247
  pollBackground(shellId: BackgroundShellId): Promise<Result<BackgroundPoll, BackgroundShellError>>;
244
248
  killBackground(shellId: BackgroundShellId): Promise<Result<void, BackgroundShellError>>;
249
+ /**
250
+ * S-745(core 7.32.0 #1107)—— `BackgroundShellCapability.killBackgroundNow` 座位:退出边界 / 会话收敛的**同步硬收**面经它对
251
+ * 本 env 的一行发**一枪组 SIGKILL**(pid 不出 env,调用方只握不透明的 shellId)。同步、永不抛、幂等;答 `killed` /
252
+ * `already_gone` / `no_pgid`(win32 恒 `no_pgid`)。从没起过 shell 的 env(`_bgManager` 未建)⇒ 这个 id 不是本 env 的 ⇒
253
+ * `already_gone`;**不**顺手新建 manager(拆除路径上不造东西,与 `disposeBackgroundShells` 同一条)。
254
+ * 本仓只有 host 车道实现它:e2b / k8s·kata / local-docker / ssh / device / adb 没有本机进程组可发,留空 ⇒ core 每行 `no_pgid`
255
+ * 并派普通 kill(车道矩阵钉:`test/background-hard-kill-s745.test.ts`)。
256
+ */
257
+ killBackgroundNow(shellId: BackgroundShellId): BackgroundHardKillOutcome;
245
258
  disposeBackgroundShells(opts?: {
246
259
  except?: readonly BackgroundShellId[];
247
260
  }): Promise<void>;