@sema-agent/server 7.102.0 → 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.
- package/MIGRATION.md +61 -0
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/USAGE.md +99 -6
- package/dist/background-session-reap.d.ts +4 -1
- package/dist/bake-runner/main.d.ts +21 -3
- package/dist/bake-runner/main.js +18 -11
- package/dist/boot/config-center.d.ts +7 -2
- package/dist/boot/config-center.js +18 -4
- package/dist/boot/stage-01-config.js +2 -0
- package/dist/boot/stage-10-http-server.js +6 -1
- package/dist/brain.d.ts +28 -8
- package/dist/brain.js +20 -4
- package/dist/capabilities/center-plugins.d.ts +9 -3
- package/dist/capabilities/center-plugins.js +5 -10
- package/dist/config-catalog.js +9 -5
- package/dist/config-center/apply-effective.d.ts +16 -0
- package/dist/config-center/apply-effective.js +23 -11
- package/dist/config-center/mcp-revocation.d.ts +2 -2
- package/dist/config-center/mcp-revocation.js +1 -1
- package/dist/config-center/restart-signal.d.ts +0 -10
- package/dist/config-center/restart-signal.js +2 -12
- package/dist/config-types.d.ts +35 -17
- package/dist/config-types.js +3 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +43 -17
- package/dist/env-name-allowlist-knobs.d.ts +21 -7
- package/dist/env-name-allowlist-knobs.js +7 -2
- package/dist/fleet-client.d.ts +9 -3
- package/dist/fleet-client.js +2 -0
- package/dist/host-lsp-manager.d.ts +4 -3
- package/dist/host-lsp-manager.js +2 -6
- package/dist/http/dispatch.js +4 -0
- package/dist/http/server.d.ts +4 -0
- package/dist/leader/wire.d.ts +8 -1
- package/dist/leader/wire.js +4 -3
- package/dist/model-entry-refusal.d.ts +31 -9
- package/dist/model-entry-refusal.js +22 -10
- package/dist/model-route-endpoint.d.ts +10 -0
- package/dist/model-route-endpoint.js +6 -2
- package/dist/observability/secret-env-scrub.d.ts +12 -0
- package/dist/observability/secret-env-scrub.js +12 -0
- package/dist/outbound-dispatcher.d.ts +125 -0
- package/dist/outbound-dispatcher.js +211 -0
- package/dist/plugins/host-platform.d.ts +6 -1
- package/dist/plugins/remote-env-host.js +3 -11
- package/dist/project-memory.js +3 -6
- package/dist/run-local.js +2 -0
- package/dist/server-secret-env.d.ts +97 -24
- package/dist/server-secret-env.js +37 -1
- package/dist/trace/core-keyset-guard.d.ts +6 -1
- package/dist/trace/project.js +13 -0
- package/dist/trace/redact.d.ts +11 -0
- package/dist/trace/redact.js +1 -0
- 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
|
|
@@ -59,13 +59,18 @@ 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;
|
|
64
66
|
/**
|
|
65
67
|
* S-745(core 7.32.0 #1107)—— **同步组硬收的一枪**:后台 driver 的 `killBackgroundNow` 座位用它(梯子的「组 SIGKILL」那一级,
|
|
66
68
|
* 不经 TERM、不挂定时器)。读数是 core 座位闭集里的三词,判据只看 `kill` 系统调用自己的结局:
|
|
67
69
|
* · POSIX:`kill(-pid, SIGKILL)` 落地 ⇒ `killed`;`ESRCH`(这个组已经没有进程)⇒ `already_gone`;其余拒因(`EPERM` 一族)⇒
|
|
68
|
-
* `no_pgid`(没有落地,调用方可重试;core 随即派普通 kill)
|
|
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`;
|
|
69
74
|
* · win32 ⇒ 恒 `no_pgid`(core 契约:win32 无同步硬收 —— 没有进程组,树杀要一个延后的发现步,同步 `taskkill` 会杀掉启动器而
|
|
70
75
|
* 留下 Git Bash 的作业组、却自称 `killed`)。
|
|
71
76
|
* 「这个 pid 还是不是我的」由**调用方**先判(观察到退出 ⇒ 不许再发,pid 可能已经是别人的):本函数只管打出去的那一枪。永不抛。
|
|
@@ -10,8 +10,7 @@ import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
|
|
|
10
10
|
import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, hardKillGroupNow, collapseWin32EnvKeys, adoptHostForegroundChild } from "./host-platform.js";
|
|
11
11
|
import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
|
|
12
12
|
import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
|
|
13
|
-
import {
|
|
14
|
-
import { allowlistedEnv, scrubServerSecrets } from "../server-secret-env.js";
|
|
13
|
+
import { allowlistedEnv, hostSubprocessEnv } from "../server-secret-env.js";
|
|
15
14
|
import { trackEphemeralHostWorkspace, untrackEphemeralHostWorkspace } from "./host-workspace-registry.js";
|
|
16
15
|
const PROVIDER = "host";
|
|
17
16
|
const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
@@ -335,14 +334,9 @@ export class RemoteHostExecutionEnv {
|
|
|
335
334
|
await fs.rm(state.dir, { recursive: true, force: true }).catch(() => { });
|
|
336
335
|
},
|
|
337
336
|
killNow(state) {
|
|
338
|
-
if (state.hardKilled === true)
|
|
339
|
-
return "already_gone";
|
|
340
337
|
if (state.child.exitCode !== null || state.child.signalCode !== null)
|
|
341
338
|
return "already_gone";
|
|
342
|
-
|
|
343
|
-
if (outcome === "killed")
|
|
344
|
-
state.hardKilled = true;
|
|
345
|
-
return outcome;
|
|
339
|
+
return hardKillGroupNow(state.pid);
|
|
346
340
|
},
|
|
347
341
|
};
|
|
348
342
|
}
|
|
@@ -1322,9 +1316,7 @@ export class RemoteHostExecutionEnv {
|
|
|
1322
1316
|
base = allowlistedEnv(mode, process.env);
|
|
1323
1317
|
}
|
|
1324
1318
|
else {
|
|
1325
|
-
|
|
1326
|
-
base = scrubServerSecrets(process.env, findings);
|
|
1327
|
-
recordSecretEnvScrub("server.remote-env-host.inherit-scrub", findings, []);
|
|
1319
|
+
base = hostSubprocessEnv({ site: "server.remote-env-host.inherit-scrub", allow: [] });
|
|
1328
1320
|
}
|
|
1329
1321
|
if (this.cfg.env)
|
|
1330
1322
|
Object.assign(base, this.cfg.env);
|
package/dist/project-memory.js
CHANGED
|
@@ -3,8 +3,7 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { createHmac, randomBytes } from "node:crypto";
|
|
4
4
|
import hostPath, { isAbsolute, join } from "node:path";
|
|
5
5
|
import { announceInstructionSource } from "./instruction-source-notice.js";
|
|
6
|
-
import {
|
|
7
|
-
import { scrubServerSecrets } from "./server-secret-env.js";
|
|
6
|
+
import { gitSubprocessEnv } from "./server-secret-env.js";
|
|
8
7
|
import { redactSecrets } from "./trace/redact.js";
|
|
9
8
|
const INSTRUCTION_FILE_CHAIN = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", ".claude/AGENTS.md"];
|
|
10
9
|
const INSTRUCTION_READ_CAP = 24 * 1024;
|
|
@@ -72,11 +71,9 @@ async function readChainMember(root, rel, maxBytes, truncatedFlag) {
|
|
|
72
71
|
}
|
|
73
72
|
function git(root, args, maxBytes) {
|
|
74
73
|
const safeArgs = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args];
|
|
75
|
-
const
|
|
76
|
-
const scrubbedEnv = scrubServerSecrets(process.env, scrubFindings);
|
|
77
|
-
recordSecretEnvScrub("server.project-memory.git-subprocess", scrubFindings, []);
|
|
74
|
+
const env = gitSubprocessEnv({ site: "server.project-memory.git-subprocess", isolateConfig: true });
|
|
78
75
|
return new Promise((res) => {
|
|
79
|
-
execFile("git", safeArgs, { cwd: root, timeout: 5000, maxBuffer: maxBytes, env
|
|
76
|
+
execFile("git", safeArgs, { cwd: root, timeout: 5000, maxBuffer: maxBytes, env }, (err, stdout, stderr) => {
|
|
80
77
|
if (!err)
|
|
81
78
|
return res({ out: stdout.trim() });
|
|
82
79
|
const e = err;
|
package/dist/run-local.js
CHANGED
|
@@ -52,6 +52,7 @@ import { createMetrics } from "./observability/metrics.js";
|
|
|
52
52
|
import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
|
|
53
53
|
import { createHostLspManager } from "./host-lsp-manager.js";
|
|
54
54
|
import { buildDisplaySafeUrl } from "./trace/redact.js";
|
|
55
|
+
import { installOutboundDispatcher } from "./outbound-dispatcher.js";
|
|
55
56
|
export function parseArgs(argv) {
|
|
56
57
|
let objective;
|
|
57
58
|
let root;
|
|
@@ -267,6 +268,7 @@ export async function runLocal(argv, deps = {}) {
|
|
|
267
268
|
const memoryEngineIncident = createMemoryEngineIncidentSink({ logger, metrics });
|
|
268
269
|
installSecretEnvScrubRecorder({ logger, metrics });
|
|
269
270
|
logConfigDiagnostics(logger);
|
|
271
|
+
installOutboundDispatcher(logger);
|
|
270
272
|
const configProvider = createConfigProvider({
|
|
271
273
|
provider: config.configProvider ?? "local",
|
|
272
274
|
...(config.configLocalDir ? { localDir: config.configLocalDir } : { localDir: root }),
|
|
@@ -1,40 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 7.99.0 合并复审 F7(web-admin [ref] 实测;安全轴;DEBTS S-684)—— 子进程 env
|
|
2
|
+
* 7.99.0 合并复审 F7(web-admin [ref] 实测;安全轴;DEBTS S-684)—— 子进程 env 交出去之前的**本仓**密钥剥除;7.103.0(车OT,
|
|
3
|
+
* S-684 残余按类收口)起也是本包**宿主子进程 env 的唯一组合点**。
|
|
3
4
|
*
|
|
4
5
|
* ## 病
|
|
5
|
-
*
|
|
6
|
+
* 本仓的子进程腿 —— 模型可驱动的 host shell(`inheritEnv:"scrub"` 缺省腿,`plugins/remote-env-host.ts`)与
|
|
6
7
|
* project-memory 的 git 子进程(`project-memory.ts`)—— 此前只调 core 的 `scrubSecretEnv`。它按键名**形状**判
|
|
7
8
|
* (`*_KEY` / `*_TOKEN` / `*_SECRET` …),而本仓自己的凭据键里有形状规则认不出的:`SERVICE_AUTH_TOKENS`(复数,
|
|
8
9
|
* per-system 凭据表)、`APPROVAL_HMAC_KEYS`(直连门 HMAC 密钥集)、`OTEL_EXPORTER_OTLP_HEADERS`(常载鉴权头)⇒
|
|
9
|
-
* 原样进子进程,模型选的一句 `printenv` 就读得到。
|
|
10
|
+
* 原样进子进程,模型选的一句 `printenv` 就读得到。S-684 残余(7.103.0):另外三条宿主腿连 core 的形状剥都没过 —— leader 推送的
|
|
11
|
+
* git(`leader/wire.ts`)、center-plugins 克隆的 git(`capabilities/center-plugins.ts`)、bake-runner 交给 build.sh 与主机命令的
|
|
12
|
+
* env(`bake-runner/main.ts`,连 runner 自己的 `BAKE_RUNNER_TOKEN`)。
|
|
10
13
|
*
|
|
11
14
|
* ## 规则(一句)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* 本包任何进程在宿主上 spawn 的子进程:env = {@link hostSubprocessEnv} = {@link scrubServerSecrets}(process.env) ∪ 部署方点名放回的键
|
|
16
|
+
* (只在运维真可能要传东西的腿上有旋钮),剥掉的键经一个登记过的 `SecretEnvScrubSite` 留痕;git 腿再叠 git 自己的两件
|
|
17
|
+
* ({@link gitSubprocessEnv})。各腿只调这两只 —— 不许再有内联拼装(类级门在 `test/secret-env-scrub-host-legs.test.ts`)。
|
|
18
|
+
*
|
|
19
|
+
* {@link scrubServerSecrets} = core `scrubSecretEnv`(按形)∘ 本仓按名两臂:
|
|
20
|
+
* · 目录臂 —— 配置目录(`config-catalog.ts`)里 `type:"secret"` 的每一行,从目录机器派生,不手抄第二份名单;
|
|
21
|
+
* · 名单型旋钮臂 —— `ENV_NAME_ALLOWLIST_KNOBS` 的每一只(`env-name-allowlist-knobs.ts`,表键派生):名单**原文**可能夹着一枚被错展开进来
|
|
22
|
+
* 的令牌,按凭据类对待。服务侧三只(`LSP_ENV_ALLOW` S-699 / `E2B_SANDBOX_ENV` / `DOCKER_SANDBOX_ENV` S-713)同时是目录 secret 行,
|
|
23
|
+
* 目录臂先认;本臂接住的是**不在服务目录里**的那一只 —— bake-runner 的 `BAKE_ENV_ALLOW`(它不是服务进程的配置)。
|
|
15
24
|
* core 形状规则的缺口由 core 源头修 —— 7.30.1 #1072 已补 `KEYS|TOKENS|PASSWORDS`(S-616 提货),目录臂独担的只剩
|
|
16
|
-
* `OTEL_EXPORTER_OTLP_HEADERS`(`*_HEADERS` 不在形状规则射程,是边界不是缺陷票)
|
|
17
|
-
*
|
|
18
|
-
* 认它们)。本层不改 core 的判据,也不自造
|
|
19
|
-
* 第二份形状规则。目录里加一个 secret 型键,三条腿当场就剥它。
|
|
25
|
+
* `OTEL_EXPORTER_OTLP_HEADERS`(`*_HEADERS` 不在形状规则射程,是边界不是缺陷票)与名单型旋钮(形状规则按名判,本来就不该认它们)。
|
|
26
|
+
* 本层不改 core 的判据,也不自造第二份形状规则。目录里加一个 secret 型键 / 叶表加一只名单型旋钮,每条腿当场就剥它。
|
|
20
27
|
*
|
|
21
28
|
* ## 不变的面
|
|
22
29
|
* `inheritEnv:"all"`(显式全继承)与显式 allowlist 两形不经本函数,行为逐字不变;调用方显式传入的 env
|
|
23
30
|
* (`cfg.env` / 单次命令的 `env`)照旧原样覆盖在剥除结果之上。
|
|
24
31
|
*
|
|
25
|
-
* ## 点名放行(S-699 / S-713
|
|
26
|
-
* {@link allowlistedEnv} 是本仓「一张名单点到哪些 env 键,就把它们从源 env
|
|
27
|
-
* host shell 的 `inheritEnv:[names]`(`plugins/remote-env-host.ts
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
32
|
+
* ## 点名放行(S-699 / S-713,一只谓词各处用)
|
|
33
|
+
* {@link allowlistedEnv} 是本仓「一张名单点到哪些 env 键,就把它们从源 env **原样**交给子进程」的唯一实现。调用:
|
|
34
|
+
* host shell 的 `inheritEnv:[names]`(`plugins/remote-env-host.ts`,hermetic 形)、{@link hostSubprocessEnv}(剥后放回形:本地语言服务器的
|
|
35
|
+
* `LSP_ENV_ALLOW`、bake-runner 的 `BAKE_ENV_ALLOW`)、沙箱带外 env 的 `E2B_SANDBOX_ENV` / `DOCKER_SANDBOX_ENV`(`config.ts`,S-713 起)。
|
|
36
|
+
* 名单型旋钮的**名单**另由 `config.ts` 的同一只解析器 `parseEnvNameAllowlist` 判形(形错拒启 / 源里没有的名按序号 warn),本函数只管取值。
|
|
37
|
+
* 「点名」在各处同义:点到的键原样带值(空串值照带),密钥形也照过(点名 = 部署方显式承担);源里没有的名不产生条目。
|
|
31
38
|
* 沙箱两腿不继承本进程环境:进沙箱的本进程值只有点到的键(与 host shell 的 hermetic 形同一侧)。
|
|
32
|
-
* host shell
|
|
33
|
-
* `NodeExecutionEnv` 同名选项同义,没点到的一律不继承)
|
|
34
|
-
* (没点到的照缺省剥,`PATH` / `HOME` 这类非密钥键照常在 —— hermetic
|
|
35
|
-
*
|
|
39
|
+
* host shell 与「剥后放回」形**只在没点到的键上**不同,这是有意保留的差别:host shell 的 `string[]` 形是 hermetic 白名单(与 core
|
|
40
|
+
* `NodeExecutionEnv` 同名选项同义,没点到的一律不继承);语言服务器 / bake-runner 在 {@link scrubServerSecrets} 的结果之上放回点名键
|
|
41
|
+
* (没点到的照缺省剥,`PATH` / `HOME` 这类非密钥键照常在 —— hermetic 形会让子进程当场找不到自己的工具链)。
|
|
42
|
+
* 两形都不放宽对方:把 host shell 改成「剥后放回」= 在模型可驱动的 shell 上放宽,把后者改成 hermetic = 功能当场失灵。
|
|
36
43
|
*/
|
|
37
44
|
import { type SecretEnvFinding } from "@sema-agent/core";
|
|
45
|
+
import { type SecretEnvScrubSite } from "./observability/secret-env-scrub.js";
|
|
38
46
|
/** 目录派生臂的一条剥除记录(形同 core 的 `SecretEnvFinding`:键名 / 规则 / 置信度 / 来源;值永不携带)。
|
|
39
47
|
* `source` 用本仓自己的词 `server-config-catalog`,与 core 的 `secret-env` 分开 —— 读观测面的人要分得清
|
|
40
48
|
* 「形状规则剥的」与「本仓目录点名剥的」(后者多出来的那部分正是 core 形状规则的缺口)。 */
|
|
@@ -44,16 +52,81 @@ export interface CatalogSecretEnvFinding {
|
|
|
44
52
|
readonly confidence: "high";
|
|
45
53
|
readonly source: "server-config-catalog";
|
|
46
54
|
}
|
|
47
|
-
/**
|
|
48
|
-
export
|
|
55
|
+
/** 名单型旋钮臂的一条剥除记录(形同上;`source` 用自己的词 —— 读观测面的人要分得清「目录点名剥的」与「名单型旋钮原文按凭据类剥的」)。 */
|
|
56
|
+
export interface NameListKnobEnvFinding {
|
|
57
|
+
readonly key: string;
|
|
58
|
+
readonly kind: "name-list-knob";
|
|
59
|
+
readonly confidence: "high";
|
|
60
|
+
readonly source: "server-name-list-knob";
|
|
61
|
+
}
|
|
62
|
+
/** 随残组扣下的 git 配置组成员(codex r1 C1;见 {@link hostSubprocessEnv} 的组不变量)。它本身不是凭据 —— 是因为同组的某个成员被剥掉、
|
|
63
|
+
* 整组已不成形而一起扣下的;`kind` 单列一桶,读观测面的人分得清「剥的」与「随组扣下的」。 */
|
|
64
|
+
export interface GitConfigGroupEnvFinding {
|
|
65
|
+
readonly key: string;
|
|
66
|
+
readonly kind: "git-config-group";
|
|
67
|
+
readonly confidence: "high";
|
|
68
|
+
readonly source: "server-git-config-group";
|
|
69
|
+
}
|
|
70
|
+
/** 本仓子进程腿的扣下记录:core 的形状臂 ∪ 本仓的目录臂 ∪ 名单型旋钮臂 ∪ 随残组扣下的 git 配置组成员。 */
|
|
71
|
+
export type ServerSecretEnvFinding = SecretEnvFinding | CatalogSecretEnvFinding | NameListKnobEnvFinding | GitConfigGroupEnvFinding;
|
|
49
72
|
/** 配置目录里 `type:"secret"` 的 env 名(大写 —— Windows 的 env 名不分大小写,按大写比才不会漏)。 */
|
|
50
73
|
export declare const CATALOG_SECRET_ENV_NAMES: ReadonlySet<string>;
|
|
51
74
|
/**
|
|
52
|
-
* 先交 core
|
|
75
|
+
* 先交 core 按形剥,再按目录名、名单型旋钮名剥它漏下的。`findings` 是调用方持有的收集器(交给 `recordSecretEnvScrub` 留痕):
|
|
53
76
|
* 形状臂剥掉的记 core 的 finding;目录臂只记**形状臂漏下、被目录点名剥掉**的键 —— 于是既有的形状臂计数不变,
|
|
54
|
-
* 目录臂的计数恰好就是 core
|
|
77
|
+
* 目录臂的计数恰好就是 core 形状规则的缺口读数;名单型旋钮臂只记前两臂都漏下的(今天 = 不在服务目录里的 `BAKE_ENV_ALLOW`)。
|
|
78
|
+
* 各腿不直接调它 —— 经 {@link hostSubprocessEnv}(剥 + 放回 + 留痕一只口)。
|
|
55
79
|
*/
|
|
56
80
|
export declare function scrubServerSecrets(env: NodeJS.ProcessEnv, findings: ServerSecretEnvFinding[]): NodeJS.ProcessEnv;
|
|
81
|
+
/**
|
|
82
|
+
* 🔴 **宿主子进程 env 的唯一组合**(S-684 残余按类收口,7.103.0):{@link scrubServerSecrets}(process.env) ∪ `allow` 点到的键
|
|
83
|
+
* (经 {@link allowlistedEnv} 从原 env 原样放回),**最终没进子进程**的剥除键经 `site` 留痕(点名放回的键不是剥除,不计数、不披露)。
|
|
84
|
+
*
|
|
85
|
+
* 六条宿主腿都只经它(直接或经 {@link gitSubprocessEnv}):host shell 缺省 scrub 腿、project-memory / center-plugins / leader 三条 git 腿、
|
|
86
|
+
* 本地语言服务器、bake-runner 的每一只子进程组。`allow` 只在**有部署级出路**的现场非空(今天 = `LSP_ENV_ALLOW` / `BAKE_ENV_ALLOW`,
|
|
87
|
+
* 它们的现场在 `SECRET_ENV_SCRUB_SITES` 里登记了 `remedy`,披露行因此是 warn + 放行名单 + 出路句);其余现场传 `[]`。
|
|
88
|
+
* 返回 `NodeJS.ProcessEnv` 形(值可能是 `undefined` 的键本来就不会进子进程);调用方显式要交的键(`LC_ALL`、`cfg.env` …)叠在它之上。
|
|
89
|
+
* 调用时机 = 每次要交 env 时现算一次(`process.env` 在本仓是 boot 期输入,各次同值;计数因此是「每组合一次」)。
|
|
90
|
+
*
|
|
91
|
+
* 🔴 **组不变量(codex r1 C1 [high])**:逐键剥除不许把一组**按组生效**的 env 剥成残组。今天的实例是 git 的 env 配置组
|
|
92
|
+
* (`GIT_CONFIG_COUNT` + `GIT_CONFIG_KEY_<i>` / `GIT_CONFIG_VALUE_<i>`):core 剥掉某个 `GIT_CONFIG_KEY_<i>`、COUNT 与 VALUE 照留 ⇒ 子进程里的
|
|
93
|
+
* git 起手就 `missing config key`、退 128(任何读配置的子命令,不只要用那条配置的)。core ≤7.33.0 形状规则的 `KEY(_<数字>)?` 后缀剥**每一个**
|
|
94
|
+
* `GIT_CONFIG_KEY_<i>`;core 7.33.1 #1129 起该族具名豁免,残组今天从**两侧**来:KEY 侧的值筛(值里是带 userinfo 的 URL —— 令牌
|
|
95
|
+
* `url.<base>.insteadOf`)剥 KEY;core 7.33.2 起 VALUE 侧的值族表(`GIT_CONFIG_VALUE_<i>` 是 `Authorization:` / `Proxy-Authorization:` /
|
|
96
|
+
* `Cookie:` 头、带 userinfo 的 URL,或 proxy 地址键下的 `user[:secret]@host` ⇒ `family-rule`)剥 VALUE。两侧的 URL userinfo 判据自 core 7.33.2
|
|
97
|
+
* 起就是**同一只**(7.33.2 = 正则 `URL_USERINFO_RE`:`//` 到 `@` 之间无 `/` `?` `#`);core 7.33.3 #1140 只改这一只判据本身,剥得更多(`://` 后到
|
|
98
|
+
* 第一个 `@` 的段:无 `/` `?` `#`,或第一个 `/` `?` `#` 之前有 `:`(段首 `[…]` 里的 `:` 不计)—— 密码里未转义的 `/` 也算;带端口的路径 `@` 过剥,
|
|
99
|
+
* core KL-1129b);core 7.33.4 #1146 再改它、不再解析 authority:值里有 `:/`、其后任意位置有 `@` 或 `%40` 即剥,proxy 地址键下值里有 `@` / `%40` 即剥
|
|
100
|
+
* —— 路径里的 `@`(`@scope` 包路径)与 scp 绝对路径带 `@`(`host:/repo@v2.git`)一并多剥(fail-closed,KL-1129b)。本不变量对哪一侧剥残的组都照样
|
|
101
|
+
* 成立(车OR / 车PA / 车PB / 车PC 提货翻钉,零改码)。
|
|
102
|
+
* 规则:交出去的组要么**源里有的成员一个不少**、要么**整组都不给**(值同扣 —— VALUE 里可能正是鉴权头);随组扣下的成员记
|
|
103
|
+
* `kind: "git-config-group"`。本层只**多扣**、从不放回 core 判掉的键(core 的判据不在这里改;`GIT_CONFIG_KEY_<i>` 是不是凭据是 core
|
|
104
|
+
* 形状规则的事,见 [ref] 登记);部署方点名放回全部 `GIT_CONFIG_KEY_<i>` 与 `GIT_CONFIG_VALUE_<i>`(有放行面的现场;core 7.33.2 起两边都可能被剥,
|
|
105
|
+
* 只放回 KEY 仍是残组)⇒ 组完整,原样交出。源里本就残缺的组不是本层
|
|
106
|
+
* 剥残的,不碰(git 对它照样报错 —— 那是部署方自己的配置)。
|
|
107
|
+
*/
|
|
108
|
+
export declare function hostSubprocessEnv(o: {
|
|
109
|
+
site: SecretEnvScrubSite;
|
|
110
|
+
allow: readonly string[];
|
|
111
|
+
}): NodeJS.ProcessEnv;
|
|
112
|
+
/** git 子进程腿的现场(闭集里名字带 `.git-` 的那几只 —— 编译期筛;它们都没有点名放行面)。 */
|
|
113
|
+
export type GitSubprocessSite = Extract<SecretEnvScrubSite, `${string}.git-${string}`>;
|
|
114
|
+
/**
|
|
115
|
+
* 宿主 **git 子进程**的 env:{@link hostSubprocessEnv}(site, 不放回) + `GIT_TERMINAL_PROMPT=0`(恒:凭据缺席 ⇒ 响亮失败,绝不挂在交互
|
|
116
|
+
* prompt 上 —— 服务进程没有终端)+(`isolateConfig` ⇒ `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` = `/dev/null`:宿主全局 / 系统 git 配置
|
|
117
|
+
* (insteadOf / credential helper / hooksPath / fsmonitor …)一概不继承)。
|
|
118
|
+
*
|
|
119
|
+
* `isolateConfig` 按腿定(三条腿各自的理由在调用点):project-memory 与 center-plugins 隔离(读仓 / 克隆不需要宿主身份);leader 推送
|
|
120
|
+
* **不**隔离 —— durable remote 的写凭据属于控制面宿主(「Coordinator's creds」),可能由宿主 git 配置里的 credential helper / insteadOf
|
|
121
|
+
* 提供,隔离会把它们一起切掉。不隔离 ≠ 不剥:helper 同样只看得到剥后的 env(读 `*_TOKEN` 形 env 的 helper 因此读不到,出路见 CHANGELOG)。
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ 已知边界(不在本函数修):Apple Git 另有一层 Xcode 自带配置(`…/Xcode.app/…/git-core/gitconfig`,内含 `credential.helper=osxkeychain`),
|
|
124
|
+
* `GIT_CONFIG_SYSTEM` 盖不住、`GIT_CONFIG_NOSYSTEM=1` 才盖得住 —— 只在 macOS 开发机上,文件属 root(不可被模型种),见 [ref] 登记。
|
|
125
|
+
*/
|
|
126
|
+
export declare function gitSubprocessEnv(o: {
|
|
127
|
+
site: GitSubprocessSite;
|
|
128
|
+
isolateConfig: boolean;
|
|
129
|
+
}): NodeJS.ProcessEnv;
|
|
57
130
|
/**
|
|
58
131
|
* S-699 —— **点名放行**的唯一谓词:从 `env` 里把 `names` 点到的键**原样**取出(值逐字,空串照带;源里没有的名不产生
|
|
59
132
|
* 条目)。密钥形的键点到了也照取 —— 点名即部署方显式承担;要不要在这之上再叠别的基底,是调用方的事(见文件头「点名放行」节)。
|