@sema-agent/server 7.11.0 → 7.12.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/README.md +1 -1
- package/USAGE.md +56 -0
- package/dist/auth-keys.d.ts +28 -4
- package/dist/auth-keys.js +60 -15
- package/dist/boot/parked-revive-gate.d.ts +18 -2
- package/dist/boot/parked-revive-gate.js +136 -14
- package/dist/boot/permission-rules-audit.d.ts +49 -0
- package/dist/boot/permission-rules-audit.js +57 -0
- package/dist/boot/resolve-spec.js +43 -12
- package/dist/budget.js +22 -0
- package/dist/config-types.d.ts +17 -9
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -79
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/approvals-assistant.js +2 -1
- package/dist/http/routes/capabilities.js +44 -9
- package/dist/http/routes/rules.d.ts +19 -7
- package/dist/http/routes/rules.js +180 -4
- package/dist/http/server.d.ts +4 -2
- package/dist/http/server.js +81 -3
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +19 -1
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/observability/metrics.js +2 -1
- package/dist/observability/tool-trace.d.ts +5 -1
- package/dist/observability/tool-trace.js +33 -6
- package/dist/parked-decide.d.ts +13 -3
- package/dist/parked-decide.js +10 -1
- package/dist/plugins/permission-rule-store-file.d.ts +83 -0
- package/dist/plugins/permission-rule-store-file.js +371 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +7 -0
- package/dist/plugins/permission-rule-store-sql.js +11 -0
- package/dist/plugins/store-backend.d.ts +12 -6
- package/dist/plugins/store-backend.js +58 -9
- package/dist/rules-consent.d.ts +69 -1
- package/dist/rules-consent.js +43 -1
- package/dist/run-local.js +120 -13
- package/dist/runtime-governance.js +9 -3
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +6 -1
- package/dist/tool-approval.js +106 -27
- package/dist/trace/core-keyset-guard.d.ts +13 -2
- package/dist/trace/project.d.ts +19 -2
- package/dist/trace/project.js +24 -4
- package/package.json +2 -2
package/dist/config.js
CHANGED
|
@@ -172,17 +172,34 @@ function numEnvBounded(name, fallback, min, max) {
|
|
|
172
172
|
throw new Error(`env ${name}=${n} must be in [${min}, ${max}]`);
|
|
173
173
|
return n;
|
|
174
174
|
}
|
|
175
|
-
/** Optional FAIL-SAFE numeric env for an OPTIONAL feature knob: a non-numeric / non-positive value yields `undefined`
|
|
176
|
-
* (the caller falls back to its own default) rather than NaN OR a hard startup throw. Used for SESSION_SNAPSHOT_TTL_SEC
|
|
177
|
-
* (finding 20): a typo like "600s" must NOT sign every presigned URL with X-Amz-Expires=NaN (total snapshot outage),
|
|
178
|
-
* but ALSO must not crash boot of an unrelated subsystem — degrade to the MinioBlobBackend default. */
|
|
179
|
-
/** S20 (SILENT-FALLBACK P1): soft-knob parse rejects, collected here because config parsing runs BEFORE the
|
|
180
|
-
* logger exists. main.ts drains this once after createLogger and warns per entry ("configured X=raw is
|
|
181
|
-
* invalid, using the default"). Module-level on purpose; drain clears it (repeat loadConfig in tests is fine). */
|
|
182
175
|
const CONFIG_WARNINGS = [];
|
|
183
176
|
export function drainConfigWarnings() {
|
|
184
177
|
return CONFIG_WARNINGS.splice(0, CONFIG_WARNINGS.length);
|
|
185
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* #210 —— 轮换钥两口(APPROVAL_HMAC_KEYS / PRINCIPAL_JWT_PUBKEYS)的诊断落点。
|
|
181
|
+
*
|
|
182
|
+
* 这两口的 fail-closed 方向本来就是对的(坏形 ⇒ 空钥集 ⇒ 门关着),本批**一个字都不动它**;补的是
|
|
183
|
+
* 「丢弃零留痕」:轮换新钥时写错一个字段,那一条被静默滤掉,旧钥还在于是一切照常,直到旧钥退役当天
|
|
184
|
+
* 全量回决一起 401 —— 故障与配置错误之间隔着几周,没有任何线索指回来。
|
|
185
|
+
*
|
|
186
|
+
* 走 CONFIG_WARNINGS(不是 throw):拒启会把一台**本来还能用旧钥服务**的 worker 直接打死,而这两口的
|
|
187
|
+
* 语义就是「集合里能用几把用几把」。`raw` 只写条目序号 + 缺陷字段名 —— 值不进日志(HMAC 那把是对称
|
|
188
|
+
* 密钥),`used` 恒不带(这不是夹取形,没有"采用值"可言)。
|
|
189
|
+
*
|
|
190
|
+
* 返回一只闭包(捕获 env 名)⇒ 按仓规命名 `announce*` 而非 `build*`。
|
|
191
|
+
*/
|
|
192
|
+
function announceKeySetRejection(envName) {
|
|
193
|
+
return (r) => CONFIG_WARNINGS.push({
|
|
194
|
+
env: envName,
|
|
195
|
+
raw: "<withheld: this env holds credential material>",
|
|
196
|
+
// ⚠️ 整份坏 ≠ 掉了一条(codex 自审 R3-F1 真修):`index: "-"` 说的是**整个** key-set 没解析出来,
|
|
197
|
+
// 于是信任锚**全空** —— 直连门整条关死、所有回决当场失效;而 `entry-rejected` 只是轮换里少进来
|
|
198
|
+
// 一把钥,旧钥还在服务。两者是不同量级的事故,告警规则必须分得开,不能挤在一个事件名下。
|
|
199
|
+
kind: r.index === "-" ? "set-rejected" : "entry-rejected",
|
|
200
|
+
detail: r.index === "-" ? r.reason : `entry #${r.index}: ${r.reason}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
186
203
|
/** Boot NOTICES ([792]④): non-error decisions loadConfig makes that the operator should see once (e.g. the
|
|
187
204
|
* MODEL_PROVIDER inference below). Same pre-logger collection pattern as CONFIG_WARNINGS, but each entry
|
|
188
205
|
* carries its own event name + fields (the warnings drain has one fixed "invalid, using default" message
|
|
@@ -220,8 +237,33 @@ function buildExecLaneNotice(remoteExec) {
|
|
|
220
237
|
* after (main.ts, run-local.ts) — a new entry that forgets it would silently drop operator-facing diagnostics
|
|
221
238
|
* (run-local historically did exactly that), so the drain lives beside the collectors, not in one entrypoint. */
|
|
222
239
|
export function logConfigDiagnostics(logger) {
|
|
223
|
-
|
|
224
|
-
|
|
240
|
+
// #210:四种诊断各有自己的事件名 —— 「invalid, using the default」对一个**合法但越界**的值、对一个被
|
|
241
|
+
// 保留的空串、对一条被丢弃的钥,都是错的描述,而运维的告警规则正是按事件名写的。穷尽 switch:词表加
|
|
242
|
+
// 一个种类而这里漏接 ⇒ `never` 赋值编译红(CLAUDE.md 闭集词表纪律)。
|
|
243
|
+
for (const w of drainConfigWarnings()) {
|
|
244
|
+
const kind = w.kind ?? "invalid-default";
|
|
245
|
+
switch (kind) {
|
|
246
|
+
case "clamped":
|
|
247
|
+
logger.warn("config_env_out_of_range_clamped", { env: w.env, raw: w.raw, used: w.used });
|
|
248
|
+
break;
|
|
249
|
+
case "empty-preserved":
|
|
250
|
+
logger.warn("config_env_empty_kept_prior_value", { env: w.env, used: w.used });
|
|
251
|
+
break;
|
|
252
|
+
case "entry-rejected":
|
|
253
|
+
logger.warn("config_env_entry_rejected", { env: w.env, entry: w.detail });
|
|
254
|
+
break;
|
|
255
|
+
case "set-rejected":
|
|
256
|
+
logger.warn("config_env_set_rejected", { env: w.env, problem: w.detail });
|
|
257
|
+
break;
|
|
258
|
+
case "invalid-default":
|
|
259
|
+
logger.warn("config_env_invalid_using_default", { env: w.env, raw: w.raw, ...(w.used !== undefined ? { used: w.used } : {}) });
|
|
260
|
+
break;
|
|
261
|
+
default: {
|
|
262
|
+
const unreachable = kind;
|
|
263
|
+
throw new Error(`unhandled config warning kind: ${String(unreachable)}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
225
267
|
for (const n of drainConfigNotices())
|
|
226
268
|
logger.warn(n.event, n.fields);
|
|
227
269
|
// #157 F 类观测:执行车道落点一行(见 EXEC_LANE_NOTICE 头注)。无条件发 —— boot 期本来就只跑一次,
|
|
@@ -264,6 +306,74 @@ function firstSetEnv(...names) {
|
|
|
264
306
|
}
|
|
265
307
|
return undefined;
|
|
266
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* #210(黑板 [3425]③)—— **E 类**旋钮的唯一夹取口:`numEnv` 的坏值拒启 + 越界**夹取并点名**。
|
|
311
|
+
*
|
|
312
|
+
* 为什么抽一只而不是 24 处各写各的:改前这 24 处清一色是 `Math.max(floor, numEnv(...))`,夹取本身是
|
|
313
|
+
* 刻意的(存量部署配了越界值也不该拒启),但**夹取事件零留痕** —— operator 把 `DRAIN_GRACE_MS` 设成
|
|
314
|
+
* `100`(以为单位是秒)后,进程按 10s 排空、日志里一个字都没有,直到某次滚动升级把在飞任务砍了才回头
|
|
315
|
+
* 查。24 处手写 `CONFIG_WARNINGS.push` 必然漂(有人忘记带 `used`、有人写错 env 名),判据只有一份才咬得住。
|
|
316
|
+
*
|
|
317
|
+
* 返回纯数值 ⇒ 按仓规叫 `clamp*` 而非 `create*`。
|
|
318
|
+
*
|
|
319
|
+
* ⚠️ `max` 缺省 `+Infinity` 而**不是**一个大整数上帽(codex 自审 R2-F2 真修):这 22 处里只有
|
|
320
|
+
* APPROVAL_AUTO_BUDGET 原本真有 `Math.min` 上帽,其余全是「只有地板」。给它们塞一个有限上界等于凭空长出
|
|
321
|
+
* 一条上限 —— 一个拿超大有限值当「实际上无限」哨兵的存量部署会被无预告改小,而本批对外宣称的正是「夹取
|
|
322
|
+
* 行为逐字保留」。上界只写在原本就有上界的那一处。
|
|
323
|
+
* (`Infinity` 本身进不来:`numEnv` 的 `Number.isFinite` 门已经把 `1e400` 拒在启动期。)
|
|
324
|
+
*/
|
|
325
|
+
function clampEnvWithWarn(name, fallback, min, max = Number.POSITIVE_INFINITY) {
|
|
326
|
+
const n = numEnv(name, fallback);
|
|
327
|
+
const used = Math.min(max, Math.max(min, n));
|
|
328
|
+
if (used !== n)
|
|
329
|
+
CONFIG_WARNINGS.push({ env: name, raw: env(name, fallback), used: String(used), kind: "clamped" });
|
|
330
|
+
return used;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* #210 —— `numEnv` 的「fallback 不是字符串而是一个**已算出来的数**」变体。用在 cheap-model 的
|
|
334
|
+
* 窗口/上限两键上:未设时继承**主模型**的对应值,而不是继承一个字面量默认。
|
|
335
|
+
*
|
|
336
|
+
* 改前是 `process.env.X ? Number(X) : main` —— 真值门本身没错(空串已经落在「继承主模型」那一侧,本函数
|
|
337
|
+
* 逐字保留),错的是给了值之后的裸 `Number()`:`MODEL_CHEAP_CONTEXT_WINDOW=128k` 会静默铸出 NaN 窗口,
|
|
338
|
+
* 而窗口是被拿去做大小比较的,NaN 让每一处比较恒假 ⇒ 窗口守卫整条消失。
|
|
339
|
+
*
|
|
340
|
+
* ⚠️ cheap 的**单价**四键不走这里,走 {@link cheapCostEnv}:它们改前是 `?? ` 形,空串会落 0 而不是继承,
|
|
341
|
+
* 那条既有语义必须逐字保留(见该函数头注)。两族的空串语义不同源,别合并。
|
|
342
|
+
*/
|
|
343
|
+
function numEnvOr(name, fallback) {
|
|
344
|
+
const raw = process.env[name];
|
|
345
|
+
if (raw === undefined || raw === "")
|
|
346
|
+
return fallback;
|
|
347
|
+
return parseNumOrFail(name, raw);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* #210 —— cheap 单价四键**专用**的变体(codex 自审 R1-F2 真修)。
|
|
351
|
+
*
|
|
352
|
+
* 与 `numEnvOr` 只差空串一条,而这条差别是有理由的:这四键改前写的是 `Number(env ?? main)`,空串会走进
|
|
353
|
+
* `Number("")` = **0**,也就是「这条车道按零计价」——那是一个**已经在生效**的语义,不是坏值。把它归一
|
|
354
|
+
* 成「继承主模型单价」会让一台存量部署的 cheap 车道账面单价从 0 跳到 PRO 价,配额可能当场把在跑的任务
|
|
355
|
+
* 拒掉。律条治的是**坏值静默**,不是顺手改既有语义,所以行为逐字保留、改为响亮:空串照旧落 0,但
|
|
356
|
+
* CONFIG_WARNINGS 点名(它几乎总是 compose `${VAR:-}` 漏配的产物,而不是有人真想要免费计价)。
|
|
357
|
+
*/
|
|
358
|
+
function cheapCostEnv(name, mainPrice) {
|
|
359
|
+
const raw = process.env[name];
|
|
360
|
+
if (raw === undefined)
|
|
361
|
+
return mainPrice;
|
|
362
|
+
if (raw === "") {
|
|
363
|
+
CONFIG_WARNINGS.push({ env: name, raw: "", used: "0", kind: "empty-preserved" });
|
|
364
|
+
return 0; // 改前 `Number("")` 的逐字行为
|
|
365
|
+
}
|
|
366
|
+
return parseNumOrFail(name, raw);
|
|
367
|
+
}
|
|
368
|
+
/** #210 —— 「真值门 + 裸 `Number()`」那一族(执行车道八旋钮)的收口:门照旧(未设/空串 ⇒ 键缺席,
|
|
369
|
+
* 与改前逐字相同),但**给了值**就必须是数,否则 boot 拒。NaN 超时进 adapter = 守卫整条消失,而车道
|
|
370
|
+
* 旋钮在安全轴上(CLAUDE.md「执行车道禁静默 fail-open」)。 */
|
|
371
|
+
function optNumEnvStrict(name) {
|
|
372
|
+
const raw = process.env[name];
|
|
373
|
+
if (!raw)
|
|
374
|
+
return undefined; // 真值门原样保留:"" 与 "0" 皆视作未设(改前行为,本批不动这条语义)
|
|
375
|
+
return parseNumOrFail(name, raw);
|
|
376
|
+
}
|
|
267
377
|
function optFinitePositiveEnv(name) {
|
|
268
378
|
const raw = process.env[name];
|
|
269
379
|
if (raw === undefined || raw === "")
|
|
@@ -426,12 +536,15 @@ function buildExtraBody() {
|
|
|
426
536
|
}
|
|
427
537
|
Object.assign(body, parsed);
|
|
428
538
|
}
|
|
539
|
+
// #210 A 档:与本函数自己的纪律对齐(坏 JSON 已经是 boot throw)—— 裸 `Number()` 的 NaN 会原样进
|
|
540
|
+
// `extraBody`,`JSON.stringify` 把它写成 `null`,网关要么 400 要么按缺省跑,两种都不是 operator 配的
|
|
541
|
+
// 那个值,而且哪一种都不会说出来。
|
|
429
542
|
const fp = process.env.MODEL_FREQUENCY_PENALTY;
|
|
430
543
|
const pp = process.env.MODEL_PRESENCE_PENALTY;
|
|
431
544
|
if (fp !== undefined && fp !== "")
|
|
432
|
-
body.frequency_penalty =
|
|
545
|
+
body.frequency_penalty = parseNumOrFail("MODEL_FREQUENCY_PENALTY", fp);
|
|
433
546
|
if (pp !== undefined && pp !== "")
|
|
434
|
-
body.presence_penalty =
|
|
547
|
+
body.presence_penalty = parseNumOrFail("MODEL_PRESENCE_PENALTY", pp);
|
|
435
548
|
return Object.keys(body).length > 0 ? body : undefined;
|
|
436
549
|
}
|
|
437
550
|
/** [818]① 1M dual-window derivation (core 1.289 `Model.autoCompactTokens`): an EXPLICIT positive value wins
|
|
@@ -521,7 +634,9 @@ function parseStoreDomain(ctx) {
|
|
|
521
634
|
tidb: needsDb && dbBackend === "mysql"
|
|
522
635
|
? {
|
|
523
636
|
host: env("MYSQL_HOST"),
|
|
524
|
-
|
|
637
|
+
// #210 A 档:裸 Number ⇒ NaN 端口直进 driver(行为因驱动而异,多数是「连不上」而不是「说清
|
|
638
|
+
// 为什么」)。区间 [1,65535] 一并钉死 —— 端口的合法域是全域公理,不是本仓口味。
|
|
639
|
+
port: numEnvBounded("MYSQL_PORT", "3306", 1, 65535),
|
|
525
640
|
user: env("MYSQL_USER"),
|
|
526
641
|
password: env("MYSQL_PASSWORD", ""),
|
|
527
642
|
database: env("MYSQL_DATABASE"),
|
|
@@ -535,7 +650,7 @@ function parseStoreDomain(ctx) {
|
|
|
535
650
|
pg: needsDb && dbBackend === "pg"
|
|
536
651
|
? {
|
|
537
652
|
host: env("PG_HOST"),
|
|
538
|
-
port:
|
|
653
|
+
port: numEnvBounded("PG_PORT", "5432", 1, 65535), // #210 A 档,同 MYSQL_PORT 族
|
|
539
654
|
user: env("PG_USER"),
|
|
540
655
|
password: env("PG_PASSWORD", ""),
|
|
541
656
|
database: env("PG_DATABASE"),
|
|
@@ -666,21 +781,25 @@ function parseModelDomain() {
|
|
|
666
781
|
// historical behavior (spend reads $0) — set MODEL_COST_* on a deploy to make `model_cost_micro_usd`
|
|
667
782
|
// + the Fleet card's costUsd real. Numbers are the gateway's contract price (e.g. the review gateway's
|
|
668
783
|
// deepseek-pro), owned by the operator — the center never stores them (design/15: secrets+billing in env).
|
|
784
|
+
// #210 A 档(黑板 [3425]③):四项单价与两项窗口一律 `numEnv`(坏值 boot 拒),不再裸 `Number()`。
|
|
785
|
+
// NaN 单价是本族里后果最重的一形:它顺着 `modelCostToPricing` 一路进 `costUsd`,而 `NaN > ceiling`
|
|
786
|
+
// 与 `NaN < ceiling` **同时为假** —— MAX_TASK_COST_USD / MAX_PRINCIPAL_COST_USD 两道天花板不是变松,
|
|
787
|
+
// 是**整条消失**,且账面读数就是空的,没有任何一处会响。
|
|
669
788
|
cost: {
|
|
670
|
-
input:
|
|
671
|
-
output:
|
|
672
|
-
cacheRead:
|
|
673
|
-
cacheWrite:
|
|
789
|
+
input: numEnv("MODEL_COST_INPUT", "0"),
|
|
790
|
+
output: numEnv("MODEL_COST_OUTPUT", "0"),
|
|
791
|
+
cacheRead: numEnv("MODEL_COST_CACHE_READ", "0"),
|
|
792
|
+
cacheWrite: numEnv("MODEL_COST_CACHE_WRITE", "0"),
|
|
674
793
|
},
|
|
675
|
-
contextWindow:
|
|
794
|
+
contextWindow: numEnv("MODEL_CONTEXT_WINDOW", "262144"),
|
|
676
795
|
// [854]③a: env 车道缺省 4096 → 32000。4096 对 reasoning 模型太小(一次完整思考+答案都放不下,长输出
|
|
677
796
|
// 任务被腰斩;上游同类缺省 32000)。per-model clamp:缺省值不越过本 entry 自己声明的 contextWindow
|
|
678
797
|
// (操作员配了小窗口模型时,缺省输出上限不得超过窗口本身 —— env 车道 model entry 就在此装配,唯一可用
|
|
679
798
|
// 约束即 MODEL_CONTEXT_WINDOW)。显式 MODEL_MAX_TOKENS 一如既往原样生效(显式配置=操作员意图,不 clamp);
|
|
680
799
|
// catalog(config.d/models.json)与 center 车道不动(那是显式配置,BL-8 继承链读的是这里的最终值)。
|
|
681
800
|
maxTokens: process.env.MODEL_MAX_TOKENS !== undefined && process.env.MODEL_MAX_TOKENS !== ""
|
|
682
|
-
?
|
|
683
|
-
: Math.min(32000,
|
|
801
|
+
? parseNumOrFail("MODEL_MAX_TOKENS", process.env.MODEL_MAX_TOKENS)
|
|
802
|
+
: Math.min(32000, numEnv("MODEL_CONTEXT_WINDOW", "262144")),
|
|
684
803
|
// design/123 D2 (core 1.226): structural token-estimate coefficient (chars ≈ 1 token) feeding every
|
|
685
804
|
// non-usage-anchored estimate in core (compaction trigger fallback, clearStale/trim defenses, cut-point
|
|
686
805
|
// accounting). UNSET → omit = core default 4 (byte-compatible chars/4 heuristic). CJK-heavy models
|
|
@@ -725,17 +844,19 @@ function parseModelDomain() {
|
|
|
725
844
|
// inherits the MAIN model's `cost` and subagent/summarize spend (council lenses, compaction) is
|
|
726
845
|
// metered at the PRO per-token price → cost metric + per-principal quota over-count. Set
|
|
727
846
|
// MODEL_CHEAP_COST_* to the real cheap price; unset → inherit the main cost (prior behavior, no change).
|
|
847
|
+
// #210 A 档:非数值 boot 拒(改前静默 NaN)。空串走 `cheapCostEnv` 的专门臂——它逐字保留改前的
|
|
848
|
+
// `Number("")`=0 并点名,理由见该函数头注(codex 自审 R1-F2)。
|
|
728
849
|
cost: {
|
|
729
|
-
input:
|
|
730
|
-
output:
|
|
731
|
-
cacheRead:
|
|
732
|
-
cacheWrite:
|
|
850
|
+
input: cheapCostEnv("MODEL_CHEAP_COST_INPUT", model.cost.input),
|
|
851
|
+
output: cheapCostEnv("MODEL_CHEAP_COST_OUTPUT", model.cost.output),
|
|
852
|
+
cacheRead: cheapCostEnv("MODEL_CHEAP_COST_CACHE_READ", model.cost.cacheRead),
|
|
853
|
+
cacheWrite: cheapCostEnv("MODEL_CHEAP_COST_CACHE_WRITE", model.cost.cacheWrite),
|
|
733
854
|
},
|
|
734
855
|
// systematic-audit: the cheap model also inherited the MAIN model's contextWindow/maxTokens/reasoning verbatim
|
|
735
856
|
// via the `...model` spread — wrong if MODEL_CHEAP_ID points at a model with different limits/capability. Declare
|
|
736
857
|
// them per cheap (MODEL_CHEAP_*); UNSET → inherit main (the documented "same gateway, just a different id" case).
|
|
737
|
-
contextWindow:
|
|
738
|
-
maxTokens:
|
|
858
|
+
contextWindow: numEnvOr("MODEL_CHEAP_CONTEXT_WINDOW", model.contextWindow),
|
|
859
|
+
maxTokens: numEnvOr("MODEL_CHEAP_MAX_TOKENS", model.maxTokens),
|
|
739
860
|
// design/123 D2: charsPerToken per cheap slot — the spread inherits the MAIN model's coefficient,
|
|
740
861
|
// wrong when the cheap slot is a different tokenizer family (e.g. CJK-dense deepseek cheap under an
|
|
741
862
|
// anthropic main). MODEL_CHEAP_CHARS_PER_TOKEN overrides; UNSET → inherit main (same knob family).
|
|
@@ -850,7 +971,11 @@ function parseModelDomain() {
|
|
|
850
971
|
degrade: process.env.MODEL_DEGRADE_TO
|
|
851
972
|
? {
|
|
852
973
|
to: env("MODEL_DEGRADE_TO"),
|
|
853
|
-
atCostFraction
|
|
974
|
+
// #210 A 档:本族里最必须拒的一条 —— NaN 阈值让 `spent/budget >= atCostFraction` 恒假,**降级
|
|
975
|
+
// 整条永久静默关闭**,而 operator 恰恰是配了 MODEL_DEGRADE_TO 才走到这里(他确信降级开着)。
|
|
976
|
+
// 不加 [0,1] 上下界:>1 是「实际上别降级」的合法表达,收窄会破已配了它的存量部署,而律条要治的
|
|
977
|
+
// 是坏值静默,不是把区间一起收了。
|
|
978
|
+
atCostFraction: numEnv("MODEL_DEGRADE_AT_COST_FRACTION", "0.7"),
|
|
854
979
|
reactive: boolEnv("MODEL_DEGRADE_REACTIVE", false),
|
|
855
980
|
// core 5.1.0([2390]②):词表=DegradeReason 全集;缺席时 core 默认含 server_error(默认门变宽),
|
|
856
981
|
// 本 env 是运维收窄/放宽的唯一旋钮。拼错词 boot 响亮拒——盲铸静默折无效成员是 TASK_TIMEOUT_SEC 同族病。
|
|
@@ -995,7 +1120,7 @@ function parseApprovalDomain(ctx) {
|
|
|
995
1120
|
const { postureOn } = ctx; // 跨域入参②:posture 三态(single-user turnkey ⇒ HITL 面默认 ON)
|
|
996
1121
|
// design/80 D-G: the direct-connect approval door anchors (all must be present to ACTIVATE; fail-closed).
|
|
997
1122
|
const directApprovalDoor = boolEnv("DIRECT_APPROVAL_DOOR", false);
|
|
998
|
-
const dgHmacKeys = parseApprovalHmacKeys(process.env.APPROVAL_HMAC_KEYS);
|
|
1123
|
+
const dgHmacKeys = parseApprovalHmacKeys(process.env.APPROVAL_HMAC_KEYS, announceKeySetRejection("APPROVAL_HMAC_KEYS"));
|
|
999
1124
|
// DURABLE_APPROVAL 既是本域字段,也是直连门的硬前置 —— 那条前置的理由与断言在装配层的跨域不变量段。
|
|
1000
1125
|
const durableApproval = boolEnv("DURABLE_APPROVAL", false);
|
|
1001
1126
|
// [2380] core 5.0.0 RB-476:approval 名单里「永不匹配」的名字**装配期拒启**(带修正指引)。core 自己的
|
|
@@ -1013,8 +1138,9 @@ function parseApprovalDomain(ctx) {
|
|
|
1013
1138
|
approvalDeny: rejectUnmatchableToolNames("APPROVAL_DENY", csv("APPROVAL_DENY")),
|
|
1014
1139
|
durableApproval,
|
|
1015
1140
|
resourceSuspend: boolEnv("RESOURCE_SUSPEND", false),
|
|
1016
|
-
|
|
1017
|
-
|
|
1141
|
+
// #210 B 档:夹取行为逐字保留(存量部署配了越界值不该拒启),但夹取当场点名(env+原值+采用值)。
|
|
1142
|
+
resourceSuspendTtlSec: clampEnvWithWarn("RESOURCE_SUSPEND_TTL_SEC", "0", 0), // 0 ⇒ core default (30d)
|
|
1143
|
+
approvalAutoBudget: clampEnvWithWarn("APPROVAL_AUTO_BUDGET", "0", 0, 10000), // clamp [0,10000]; 0 = off
|
|
1018
1144
|
approvalNeverAuto: rejectUnmatchableToolNames("APPROVAL_NEVER_AUTO", csv("APPROVAL_NEVER_AUTO")),
|
|
1019
1145
|
approvalHmacKeys: dgHmacKeys, // D-G: WIRED (see the field's JSDoc); empty ⇒ inactive, not unimplemented
|
|
1020
1146
|
directApprovalDoor,
|
|
@@ -1028,13 +1154,45 @@ function parseApprovalDomain(ctx) {
|
|
|
1028
1154
|
return [];
|
|
1029
1155
|
return trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1030
1156
|
})(process.env.SENSITIVE_WRITE_PATTERNS),
|
|
1031
|
-
// [1557]§四 → #153: opt-in only —
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1157
|
+
// [1557]§四 → #153: opt-in only — the two real gate shapes ride; `off`/empty/unset leave the field ABSENT
|
|
1158
|
+
// (no key on the config object at all), so `applyRuntimeGovernance` never receives the knob and THIS knob
|
|
1159
|
+
// contributes nothing to the governance pass. Note that pass can still set `TaskSpec.shellGate` on its own
|
|
1160
|
+
// account — `AUTONOMY=ask` derives "always" independently, and (design/201) an explicit client permission
|
|
1161
|
+
// mode now supplies the BASE档 this knob tightens from.
|
|
1162
|
+
//
|
|
1163
|
+
// design/201 §4 —— 词表与非法值:合法值 = `always | classify | off | 空串/未设`,**其余 boot 拒启**。
|
|
1164
|
+
// 动机是把**既有**风险变响亮而不是防将来:改前任何手滑值(`clasify`/`true`/`ON`)与未设置等效,
|
|
1165
|
+
// 于是「我配了闸」的部署实际在无闸运行,且这件事零留痕。同层先例=`rejectUnmatchableToolNames`
|
|
1166
|
+
// (上方,解析层拒启;#177 的 `assertGuardPatternsUsable` 在 deployment-governance 层,不是本层)。
|
|
1167
|
+
//
|
|
1168
|
+
// 🔴 **与兄弟旋钮的语义不对称是刻意的,禁止将来"统一"**:`SENSITIVE_WRITE_PATTERNS=off` 是一根
|
|
1169
|
+
// **真放松杆**(清空 server 自己的产品默认推荐集 —— off = 对那份默认的显式退出);
|
|
1170
|
+
// `MANUAL_MODE_SHELL_GATE=off` 是 **no-op**(归一成键缺席),因为本键的缺省不属于 server 产品默认,
|
|
1171
|
+
// 而属于**车道翻译轴**(design/201 的 permissionMode 表),env 在这条轴上只有收紧半场、没有放松方向。
|
|
1172
|
+
// 把 off 做成"关闸"就等于给部署级旋钮开一条放松通道,那正是 #153 修掉的病。所以 off 不静默:
|
|
1173
|
+
// 归一的同时发一条 boot 诊断行(下面的 CONFIG_NOTICES),让运维不会按兄弟旋钮的直觉误读。
|
|
1174
|
+
...((raw) => {
|
|
1175
|
+
const trimmed = (raw ?? "").trim();
|
|
1176
|
+
if (trimmed === "always" || trimmed === "classify")
|
|
1177
|
+
return { manualModeShellGate: trimmed };
|
|
1178
|
+
if (trimmed === "" || trimmed === "off") {
|
|
1179
|
+
if (trimmed === "off") {
|
|
1180
|
+
CONFIG_NOTICES.push({
|
|
1181
|
+
event: "manual_mode_shell_gate_off_is_noop",
|
|
1182
|
+
fields: {
|
|
1183
|
+
env: "MANUAL_MODE_SHELL_GATE",
|
|
1184
|
+
raw: trimmed,
|
|
1185
|
+
effect: "accepted as a no-op (normalized to the knob being absent) — this knob has a TIGHTEN half only, so `off` cannot relax anything; " +
|
|
1186
|
+
"the effective shell gate still comes from AUTONOMY and the client's permission mode. NOT symmetric with SENSITIVE_WRITE_PATTERNS=off, which really does clear a server-owned default set",
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
return {};
|
|
1191
|
+
}
|
|
1192
|
+
throw new Error(`env MANUAL_MODE_SHELL_GATE="${trimmed.slice(0, 120)}" is not a recognized value — use "always" (every shell call is gated), "classify" (core's classifier decides per call), or "off"/unset (this knob contributes nothing). ` +
|
|
1193
|
+
`The words are matched EXACTLY (lowercase, surrounding whitespace trimmed): "OFF"/"Classify" land here too, deliberately — a closed word table with no case-folding is what keeps "it looked accepted" from meaning "it did nothing". ` +
|
|
1194
|
+
`Refusing to boot rather than treating a misspelled value as "unset": that silently ran the deployment with NO shell gate while the operator believed one was configured`);
|
|
1195
|
+
})(process.env.MANUAL_MODE_SHELL_GATE),
|
|
1038
1196
|
mcpElicitation: {
|
|
1039
1197
|
enabled: boolEnv("MCP_ELICITATION_ENABLED", false), // E23 inbound elicitation; default OFF (fail-closed)
|
|
1040
1198
|
throttle: {
|
|
@@ -1053,10 +1211,16 @@ function parseApprovalDomain(ctx) {
|
|
|
1053
1211
|
ttlMs: numEnvBounded("QUESTION_TTL_MS", String(DEFAULT_QUESTION_THROTTLE.ttlMs), 1_000, 3_600_000),
|
|
1054
1212
|
},
|
|
1055
1213
|
toolApprovalEnabled: postureOn("TOOL_APPROVAL_ENABLED"), // [816]/[820]② live tool-approval HITL; posture-gated (single-user → ON), mirrors askQuestion
|
|
1056
|
-
// #154
|
|
1057
|
-
// `ServiceApprovalConfig.permissionRulesEnabled
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1214
|
+
// #154 车二 → #203:持久化权限规则车道的总开关。**默认 ON**(clay [3396] 裁),理由与它此前默认
|
|
1215
|
+
// OFF 的**唯一**前置(撤销面缺席)逐字见 `ServiceApprovalConfig.permissionRulesEnabled`。
|
|
1216
|
+
// **不**做 posture 派生:这条轴与「单用户交钥匙」无关(多租户同样要它),所以它是一条与部署形态
|
|
1217
|
+
// 正交的普通 opt-out 旋钮,而不是 `postureOn` 那一族。
|
|
1218
|
+
permissionRulesEnabled: boolEnv("PERMISSION_RULES_ENABLED", true),
|
|
1219
|
+
// 🔴 「运维**显式**表过态吗」是与取值分开的一位(同 `dbBackendExplicit` 的姿势)。它只有一个消费者:
|
|
1220
|
+
// boot 期的休眠行审计(`boot/permission-rules-audit.ts`)——对显式开过的部署说「N 条规则随默认 ON
|
|
1221
|
+
// 激活」是假话,那些行本来就是活的。判据与 `boolEnv` 认的字面量集**同源**(只有 "true"/"false"
|
|
1222
|
+
// 算表态;空串按未设,拼错的值走 boolEnv 的告警臂并落回默认 ⇒ 也不算表态)。
|
|
1223
|
+
permissionRulesEnabledExplicit: ((raw) => raw === "true" || raw === "false")(process.env["PERMISSION_RULES_ENABLED"]),
|
|
1060
1224
|
// #151 车2(design/172 §3.3 D3):ToolApprovalCoordinator 窗长三元的安全余量。numEnv fail-loud 形,
|
|
1061
1225
|
// 照邻居旋钮(approvalTimeoutSec 等)抄——坏形(非数字)在启动期炸,不静默折成 NaN。
|
|
1062
1226
|
streamAskWindowMarginMs: numEnv("STREAM_ASK_WINDOW_MARGIN_MS", "10000"),
|
|
@@ -1071,6 +1235,49 @@ function parseApprovalDomain(ctx) {
|
|
|
1071
1235
|
approvalTimeoutSec: numEnv("APPROVAL_TIMEOUT_SEC", "0"),
|
|
1072
1236
|
};
|
|
1073
1237
|
}
|
|
1238
|
+
/**
|
|
1239
|
+
* #210:MEMORY_ENGINE 的**双族**闭集(codex 自审 R1-F3 真修,把第一版的 `on|off` 两词放宽)。
|
|
1240
|
+
*
|
|
1241
|
+
* 病灶只在**关**的那一侧:改前判据 `!== "off"` 让 `OFF` / `false` / `disabled` / `0` 全部读成「开」——
|
|
1242
|
+
* operator 明确表达了「关掉记忆面」,引擎却照常往每个任务里注入记忆目录,而且零留痕。这一侧必须响亮。
|
|
1243
|
+
*
|
|
1244
|
+
* **开**的那一侧则相反:`true` / `1` / `yes` 改前就落在「开」上,意图与结果一致,把它们改成拒启是**纯
|
|
1245
|
+
* 粹的新增故障**——一台按 `MEMORY_ENGINE=true` 跑了很久的部署会在升级时 boot 红,而它的配置从来没错过。
|
|
1246
|
+
* 所以两族都收进词表,只有**两族都不认得**的词才拒(那种词无从推断意图,静默倒向任何一侧都是猜)。
|
|
1247
|
+
*/
|
|
1248
|
+
const MEMORY_ENGINE_ON_WORDS = ["on", "true", "1", "yes", "enabled"];
|
|
1249
|
+
const MEMORY_ENGINE_OFF_WORDS = ["off", "false", "0", "no", "disabled"];
|
|
1250
|
+
function parseMemoryEngineWord(word) {
|
|
1251
|
+
const w = word.trim().toLowerCase();
|
|
1252
|
+
if (MEMORY_ENGINE_ON_WORDS.includes(w))
|
|
1253
|
+
return true;
|
|
1254
|
+
if (MEMORY_ENGINE_OFF_WORDS.includes(w))
|
|
1255
|
+
return false;
|
|
1256
|
+
throw new Error(`env MEMORY_ENGINE="${word}" is not a known on/off word — use one of: ${MEMORY_ENGINE_ON_WORDS.join(" | ")} ` +
|
|
1257
|
+
`(engine ON, the default) or ${MEMORY_ENGINE_OFF_WORDS.join(" | ")} (engine OFF). ` +
|
|
1258
|
+
`An unrecognized value used to read as "on", so a misspelled kill-switch silently kept memory injection running.`);
|
|
1259
|
+
}
|
|
1260
|
+
/** #210:SANDBOX_PKG_SOURCE 的闭集校验(词表 = README/MIGRATION 成文的四词)。`none` 由调用点的三元
|
|
1261
|
+
* 链先摘走(字段整个缺席),留在这里的只可能是前三词或一个手滑值。 */
|
|
1262
|
+
const SANDBOX_PKG_SOURCES = ["global", "cn", "custom", "none"];
|
|
1263
|
+
function pkgSourceEnv() {
|
|
1264
|
+
const v = env("SANDBOX_PKG_SOURCE", "global"); // 空串/未设 ⇒ "global"(改前 `|| "global"` 的逐字语义)
|
|
1265
|
+
if (!SANDBOX_PKG_SOURCES.includes(v)) {
|
|
1266
|
+
throw new Error(`env SANDBOX_PKG_SOURCE="${v}" must be one of: ${SANDBOX_PKG_SOURCES.join(", ")}`);
|
|
1267
|
+
}
|
|
1268
|
+
return v;
|
|
1269
|
+
}
|
|
1270
|
+
/** #210:TOC 同步腿的一条**条数上限**。未设 ⇒ 键缺席(core 默认,改前行为);设了就必须是 ≥1 的整数,
|
|
1271
|
+
* 否则 boot 拒 —— 「设了上限却静默丢弃」等价于「无上限」,方向与 operator 的意图相反。 */
|
|
1272
|
+
function syncEntryCap(name, field) {
|
|
1273
|
+
const raw = process.env[name];
|
|
1274
|
+
if (raw === undefined || raw === "")
|
|
1275
|
+
return {};
|
|
1276
|
+
const n = parseNumOrFail(name, raw);
|
|
1277
|
+
if (!Number.isInteger(n) || n < 1)
|
|
1278
|
+
throw new Error(`env ${name}="${raw}" must be an integer >= 1 (it is an entries-per-batch cap; an ignored cap means NO cap)`);
|
|
1279
|
+
return { [field]: n };
|
|
1280
|
+
}
|
|
1074
1281
|
/** 域:memory(记忆面 + TOC 同步腿)—— 记忆引擎开关/方言/根目录、scope 缺省、同步 client 三键、项目记忆。 */
|
|
1075
1282
|
function parseMemoryDomain(ctx) {
|
|
1076
1283
|
const { requirePrincipal } = ctx; // 跨域入参①:多租户下记忆面 DARK(scope 缺省不铸)
|
|
@@ -1078,7 +1285,15 @@ function parseMemoryDomain(ctx) {
|
|
|
1078
1285
|
// (core RunnerDeps.memoryBackend), default ON with MEMORY_ENGINE=off as the explicit kill-switch.
|
|
1079
1286
|
// The legacy MEMORY_BACKEND/EMBEDDING_* store plane was dropped without migration (those env vars are
|
|
1080
1287
|
// no longer read). Single-user gating lives in main.ts (the engine is never wired multi-tenant).
|
|
1081
|
-
|
|
1288
|
+
// #210 A 档(黑板 [3425]③):判据从 `!== "off"` 改**闭集**(姿势仿下面的 MEMORY_ENGINE_BACKEND)。
|
|
1289
|
+
// 旧判据的病:任何非 `off` 的字面量都读成「开」—— `MEMORY_ENGINE=OFF` / `=false` / `=disabled` 是
|
|
1290
|
+
// operator 明确表达「关掉记忆面」的三种最常见写法,每一种都让引擎**照常开着**并继续往任务里注入记忆
|
|
1291
|
+
// 目录。这是「拼错的关词 = 静默不关」,与安全轴的词表纪律同族,所以拒启而不是 warn:一个自以为关了
|
|
1292
|
+
// 记忆的部署,和一个明知开着的部署,不是同一个隐私姿态。
|
|
1293
|
+
// ⚠️ 走 `env()` 而不是 `enumEnv()`:后者拿 `??` 取值,会把**空串**当成一个要比对词表的值 —— 而空串
|
|
1294
|
+
// 在本仓一律等同未设(compose 的 `${MEMORY_ENGINE:-}` 模板天天产它),改前 `"" !== "off"` 也确实读成
|
|
1295
|
+
// 「开」。词表化不该顺手把空串变成拒启。
|
|
1296
|
+
const memoryEngineEnabled = parseMemoryEngineWord(env("MEMORY_ENGINE", "on"));
|
|
1082
1297
|
// S3-TOB(边界重切后的 backend 选择,设计 §1.3):记忆持久面方言。缺省 file=现状零变化;
|
|
1083
1298
|
// pg/tidb=DB 真身(零卷主档)——多租户点亮的唯一门(显式 opt-in,复审 F5:绝不静默翻转)。
|
|
1084
1299
|
const memoryEngineBackendRaw = process.env.MEMORY_ENGINE_BACKEND ?? "file";
|
|
@@ -1122,7 +1337,17 @@ function parseMemoryDomain(ctx) {
|
|
|
1122
1337
|
if (scope === undefined) {
|
|
1123
1338
|
throw new Error("MEMORY_SYNC_URL is set but no sync scope is derivable (memory engine off or multi-tenant without an explicit scope) — set MEMORY_SYNC_SCOPE, or run the single-user file memory engine so MEMORY_SCOPE (default \"local\") provides one");
|
|
1124
1339
|
}
|
|
1125
|
-
|
|
1340
|
+
// #210 A 档:两条上限改**拒启**(与本块自己的「半配置 fail-loud」纪律同源)。旧形是
|
|
1341
|
+
// `Number.isInteger(v) && v>=1` 的三态过滤——不满足就**整个键消失**,而键消失 = core 走自己的默认
|
|
1342
|
+
// = 「没有这条上限」。也就是说 operator 设了一条**收紧**用的上限、结果得到的是最松的那一档,而且
|
|
1343
|
+
// 三种手滑(单位后缀 / 小数 / 0)都落到同一个静默出口。
|
|
1344
|
+
memorySync = {
|
|
1345
|
+
url: memorySyncUrl,
|
|
1346
|
+
token: memorySyncToken,
|
|
1347
|
+
scope,
|
|
1348
|
+
...syncEntryCap("MEMORY_SYNC_MAX_PUSH_ENTRIES", "maxPushEntries"),
|
|
1349
|
+
...syncEntryCap("MEMORY_SYNC_MAX_PULL_ENTRIES", "maxPullEntries"),
|
|
1350
|
+
};
|
|
1126
1351
|
}
|
|
1127
1352
|
// design/158 B4 ② 的 D 家族四枚里,唯一落在 ServiceConfig 上的就是这枚(其余三枚只在使用点懒读,
|
|
1128
1353
|
// 预登记留在 orchestration 域 —— 顺序与改前一致:本枚的弃用通知仍先于那三枚)。
|
|
@@ -1157,17 +1382,22 @@ function parseMemoryDomain(ctx) {
|
|
|
1157
1382
|
memoryOrgGrantTtlMs,
|
|
1158
1383
|
memoryOrgUnavailableBackoffMs,
|
|
1159
1384
|
projectMemoryEnabled, // design/113 C4 opt-out, flipped positive in design/158 B4 (3.0.0 起 legacy PROJECT_MEMORY_DISABLED = fail-loud 墓碑)
|
|
1160
|
-
syncImportLeaseStaleSec:
|
|
1385
|
+
syncImportLeaseStaleSec: clampEnvWithWarn("SYNC_IMPORT_LEASE_STALE_SEC", "600", 0), // #210 B 档
|
|
1161
1386
|
};
|
|
1162
1387
|
}
|
|
1163
1388
|
/** 域:auth(身份/凭证/绑址)—— service token 面、CORS、principal 头与开关、JWT 锚、autonomy、绑址。 */
|
|
1164
1389
|
function parseAuthDomain(ctx) {
|
|
1165
1390
|
const { requirePrincipal } = ctx; // 装配层已解析(它是四个域的共同入参),本域只负责把它落到字段上
|
|
1166
1391
|
// design/80 D-G 直连门的 principal-JWT 锚:验签材料属 auth 域,门的激活与半开门不变量在装配层。
|
|
1167
|
-
const principalJwtPubkeys = parsePrincipalJwks(process.env.PRINCIPAL_JWT_PUBKEYS);
|
|
1392
|
+
const principalJwtPubkeys = parsePrincipalJwks(process.env.PRINCIPAL_JWT_PUBKEYS, announceKeySetRejection("PRINCIPAL_JWT_PUBKEYS"));
|
|
1168
1393
|
const principalJwtIss = process.env.PRINCIPAL_JWT_ISS || undefined;
|
|
1169
1394
|
const principalJwtAud = process.env.PRINCIPAL_JWT_AUD || undefined;
|
|
1170
|
-
|
|
1395
|
+
// #210:E 类里**唯一升档为拒启**的一条(其余 23 条保留夹取+点名)。理由是夹取方向与旋钮语义相反:
|
|
1396
|
+
// `0` 在这条上是「无上限」的**显式表达**,于是 `Math.max(0, -1)` 把一个想收紧到「负数=全拒」的手滑
|
|
1397
|
+
// 值,悄悄翻译成了整条上限里**最松**的那一档。收紧意图落成最松结果 ⇒ 夹取在这里是错的补偿,拒启才是。
|
|
1398
|
+
// 复用既有原语 `parseNumOrFailNonNegative`(而不是 numEnvBounded 配一个假上界):它说的正是这条旋钮要
|
|
1399
|
+
// 的判据「非负」,报错文案也直说;凭空塞一个上帽是 R2-F2 那一类的同族错。
|
|
1400
|
+
const principalJwtMaxTtlSec = parseNumOrFailNonNegative("PRINCIPAL_JWT_MAX_TTL_SEC", env("PRINCIPAL_JWT_MAX_TTL_SEC", "120")); // 0 = no cap; default ≤120s
|
|
1171
1401
|
// Per-system service credentials (token → system name). Parsed as a DOMAIN field (not inline in the return
|
|
1172
1402
|
// literal) so the bake boot invariant at the assembly layer can assert the runner-credential→runnerPrincipal
|
|
1173
1403
|
// mapping exists.
|
|
@@ -1274,12 +1504,12 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1274
1504
|
provider: "e2b",
|
|
1275
1505
|
apiKey: process.env.E2B_API_KEY,
|
|
1276
1506
|
...(process.env.E2B_TEMPLATE ? { template: process.env.E2B_TEMPLATE } : {}),
|
|
1277
|
-
...(
|
|
1507
|
+
...((n) => (n !== undefined ? { timeoutMs: n } : {}))(optNumEnvStrict("E2B_TIMEOUT_MS")), // #210 A 档(见 optNumEnvStrict)
|
|
1278
1508
|
// Idle/liveness bound for a command (default 120s, the hang-fix). A long SILENT build —
|
|
1279
1509
|
// gradle's first dependency download produces no output for minutes (gate#2 P1 finding) — looks
|
|
1280
1510
|
// like a hang and gets killed; raise this for workers that run such tools. Agents should still
|
|
1281
1511
|
// prefer background+poll, but this is the operator escape hatch.
|
|
1282
|
-
...(
|
|
1512
|
+
...((n) => (n !== undefined ? { livenessMs: n } : {}))(optNumEnvStrict("E2B_LIVENESS_MS")), // #210 A 档
|
|
1283
1513
|
...(process.env.E2B_ALLOW_NET ? { allowInternetAccess: boolEnv("E2B_ALLOW_NET", true) } : {}),
|
|
1284
1514
|
// Out-of-band secret/config env injected sandbox-wide (OA Phase-B, design/48 §5 gap 7):
|
|
1285
1515
|
// E2B_SANDBOX_ENV is a CSV of THIS worker's OWN env-var NAMES to forward into the sandbox under the
|
|
@@ -1307,7 +1537,7 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1307
1537
|
...(process.env.K8S_NAMESPACE ? { namespace: process.env.K8S_NAMESPACE } : {}),
|
|
1308
1538
|
...(process.env.K8S_RUNTIME_CLASS != null ? { runtimeClass: process.env.K8S_RUNTIME_CLASS } : {}),
|
|
1309
1539
|
...(process.env.K8S_MOUNT_PATH ? { mountPath: process.env.K8S_MOUNT_PATH } : {}),
|
|
1310
|
-
...(
|
|
1540
|
+
...((n) => (n !== undefined ? { timeoutMs: n } : {}))(optNumEnvStrict("K8S_TIMEOUT_MS")), // #210 A 档
|
|
1311
1541
|
// Resource profile knob (resources are profile data, not mechanism — center/service
|
|
1312
1542
|
// config, never wire-hardcoded). Found live: 25-module gradle integration OOM'd the 2Gi default.
|
|
1313
1543
|
...(process.env.K8S_MEMORY ? { memory: process.env.K8S_MEMORY } : {}),
|
|
@@ -1323,7 +1553,7 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1323
1553
|
secretKey: process.env.MINIO_SECRET_KEY,
|
|
1324
1554
|
...(process.env.MINIO_REGION ? { region: process.env.MINIO_REGION } : {}),
|
|
1325
1555
|
...(process.env.WORKSPACE_SNAPSHOT_PREFIX ? { keyPrefix: process.env.WORKSPACE_SNAPSHOT_PREFIX } : {}),
|
|
1326
|
-
...(
|
|
1556
|
+
...((n) => (n !== undefined ? { presignTtlSec: n } : {}))(optNumEnvStrict("WORKSPACE_SNAPSHOT_TTL_SEC")), // #210 A 档:NaN 会签出 X-Amz-Expires=NaN 的死链
|
|
1327
1557
|
},
|
|
1328
1558
|
}
|
|
1329
1559
|
: {}),
|
|
@@ -1335,7 +1565,7 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1335
1565
|
host: process.env.SSH_HOST,
|
|
1336
1566
|
username: process.env.SSH_USER,
|
|
1337
1567
|
privateKey: normalizeSshPrivateKey(process.env.SSH_PRIVATE_KEY),
|
|
1338
|
-
...(
|
|
1568
|
+
...((n) => (n !== undefined ? { port: n } : {}))(optNumEnvStrict("SSH_PORT")), // #210 A 档
|
|
1339
1569
|
...(process.env.SSH_MOUNT_PATH ? { mountPath: process.env.SSH_MOUNT_PATH } : {}),
|
|
1340
1570
|
}
|
|
1341
1571
|
: // design/61: ADB → a real device (serial or host:port). adb binary must be on PATH in the image.
|
|
@@ -1351,7 +1581,7 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1351
1581
|
? {
|
|
1352
1582
|
provider: "host",
|
|
1353
1583
|
...(process.env.HOST_WORKSPACE_BASE ? { workspaceBase: process.env.HOST_WORKSPACE_BASE } : {}),
|
|
1354
|
-
...(
|
|
1584
|
+
...((n) => (n !== undefined ? { commandTimeoutMs: n } : {}))(optNumEnvStrict("HOST_COMMAND_TIMEOUT_MS")), // #210 A 档
|
|
1355
1585
|
}
|
|
1356
1586
|
: // DUAL-MODE §5: `local-docker` = a per-task container on THIS machine's docker daemon (isolation:true,
|
|
1357
1587
|
// suspendable:false). DOCKER_IMAGE is REQUIRED — no docker.io default (domestic-images iron rule); a
|
|
@@ -1367,7 +1597,7 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1367
1597
|
...(process.env.DOCKER_PATH ? { dockerPath: process.env.DOCKER_PATH } : {}),
|
|
1368
1598
|
...(process.env.DOCKER_HOST ? { dockerHost: process.env.DOCKER_HOST } : {}),
|
|
1369
1599
|
...(process.env.DOCKER_MEMORY ? { memory: process.env.DOCKER_MEMORY } : {}),
|
|
1370
|
-
...(
|
|
1600
|
+
...((n) => (n !== undefined ? { cpus: n } : {}))(optNumEnvStrict("DOCKER_CPUS")), // #210 A 档
|
|
1371
1601
|
// 鲁棒性批5 A1(2026-08-05):此前裸 `Number(process.env.DOCKER_PIDS_LIMIT)`——非数字值(如
|
|
1372
1602
|
// 手滑的 "512;") → NaN,而消费端(local-docker 执行环境)对 `pidsLimit` 的守卫是
|
|
1373
1603
|
// `?? 512`(只挡 null/undefined),NaN 穿透守卫;随后 `NaN > 0` 恒假,`--pids-limit` 整个
|
|
@@ -1378,7 +1608,7 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1378
1608
|
...(process.env.DOCKER_NETWORK
|
|
1379
1609
|
? { network: enumEnv("DOCKER_NETWORK", "bridge", ["none", "bridge", "host"]) }
|
|
1380
1610
|
: {}),
|
|
1381
|
-
...(
|
|
1611
|
+
...((n) => (n !== undefined ? { commandTimeoutMs: n } : {}))(optNumEnvStrict("DOCKER_COMMAND_TIMEOUT_MS")), // #210 A 档
|
|
1382
1612
|
...(process.env.DOCKER_SANDBOX_ENV ? { env: resolveEnvForward(process.env.DOCKER_SANDBOX_ENV) } : {}),
|
|
1383
1613
|
}
|
|
1384
1614
|
: // TOC first-run default: in LOCAL config mode (CONFIG_PROVIDER=local — the
|
|
@@ -1416,21 +1646,38 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1416
1646
|
selfOrchestrationWorkerIsolation: boolEnv("SELF_ORCHESTRATION_WORKER_ISOLATION", false),
|
|
1417
1647
|
...(workflowSizeGuideline ? { workflowSizeGuideline } : {}),
|
|
1418
1648
|
workflowRunStoreBackend: enumEnv("WORKFLOW_RUN_STORE", "auto", ["auto", "file", "memory"]), // SVC-1 + P1: auto = SQL backend when present (cross-replica), else File
|
|
1419
|
-
workflowOrphanGraceMs:
|
|
1420
|
-
workflowJournalRetentionMs:
|
|
1421
|
-
workflowRunRetentionMs:
|
|
1649
|
+
workflowOrphanGraceMs: clampEnvWithWarn("WORKFLOW_ORPHAN_GRACE_MS", String(24 * 60 * 60 * 1000), 60_000), // SVC-1: 24h default, floor 1m(#210 B 档:夹取点名)
|
|
1650
|
+
workflowJournalRetentionMs: clampEnvWithWarn("WORKFLOW_JOURNAL_RETENTION_MS", String(7 * 24 * 60 * 60 * 1000), 60_000), // SVC-2: 7d default, floor 1m
|
|
1651
|
+
workflowRunRetentionMs: clampEnvWithWarn("WORKFLOW_RUN_RETENTION_MS", String(30 * 24 * 60 * 60 * 1000), 60_000), // 30d default, floor 1m; SQL twins only
|
|
1422
1652
|
...(usageWindows ? { usageWindows } : {}), // design/166-T1:USAGE_WINDOWS 治理窗(fail-loud 已在 parseUsageWindows)
|
|
1423
|
-
backgroundAgentRetentionMs:
|
|
1424
|
-
backgroundAgentStaleRunningMs:
|
|
1425
|
-
backgroundAgentParkClaimStaleMs:
|
|
1426
|
-
rosterRetentionMs:
|
|
1653
|
+
backgroundAgentRetentionMs: clampEnvWithWarn("BG_AGENT_RETENTION_MS", String(7 * 24 * 60 * 60 * 1000), 60_000), // core 1.364: 7d default, floor 1m
|
|
1654
|
+
backgroundAgentStaleRunningMs: clampEnvWithWarn("BG_AGENT_STALE_RUNNING_MS", String(15 * 60_000), 10 * 60_000), // core 契约 ≥10min 硬钳(60s 写者心跳)
|
|
1655
|
+
backgroundAgentParkClaimStaleMs: clampEnvWithWarn("BG_AGENT_PARK_CLAIM_STALE_MS", String(60 * 60_000), 60_000), // [1575] F1 修:default 1h,floor 1m(零/负值都不该让整块清算失活)
|
|
1656
|
+
rosterRetentionMs: clampEnvWithWarn("ROSTER_RETENTION_MS", String(30 * 24 * 60 * 60 * 1000), 60_000), // [1522] MED2: 30d default, floor 1m; SQL twins only
|
|
1427
1657
|
// 修8: scratchpad sweep TTL — 0(or negative)= disabled; enabled values floor at 1m (a sub-minute TTL would
|
|
1428
1658
|
// race in-flight tasks' scratchpad writes for no operational gain).
|
|
1429
|
-
|
|
1659
|
+
// #210 B 档,只对**下限夹取**点名:`≤0 ⇒ 0` 是上面那条设计注写明的「显式关本腿」,是 operator 的
|
|
1660
|
+
// 意图本身,不是夹取,warn 它等于给正常配置刷噪声。
|
|
1661
|
+
scratchpadSweepTtlMs: ((n, raw) => {
|
|
1662
|
+
if (n <= 0)
|
|
1663
|
+
return 0;
|
|
1664
|
+
if (n < 60_000) {
|
|
1665
|
+
// ⚠️ `raw` 记**环境原文**而不是 `String(n)`(codex 自审 R1-F4 真修):`1e3` / `+1000` / 带空白的
|
|
1666
|
+
// ` 1000 ` 归一后都是 "1000",而 operator 要在 env 文件里找的正是他写下的那串字符。
|
|
1667
|
+
CONFIG_WARNINGS.push({ env: "SCRATCHPAD_SWEEP_TTL_MS", raw, used: "60000", kind: "clamped" });
|
|
1668
|
+
return 60_000;
|
|
1669
|
+
}
|
|
1670
|
+
return n;
|
|
1671
|
+
})(numEnv("SCRATCHPAD_SWEEP_TTL_MS", String(7 * 24 * 60 * 60 * 1000)), env("SCRATCHPAD_SWEEP_TTL_MS", String(7 * 24 * 60 * 60 * 1000))),
|
|
1430
1672
|
// RFC B5 region package-source selector — 🆕 default `global` (clay 拍 2026-07-13 海外为准:官方源 env-first
|
|
1431
1673
|
// 对存量镜像即生效);国内部署显式 SANDBOX_PKG_SOURCE=cn;`none` = 完全不注入(旧 unset 行为,字节级不变)。
|
|
1674
|
+
// #210 A 档:补闭集。此前**任何**词都原样透传进沙箱注入层——`SANDBOX_PKG_SOURCE=china`(拼错的
|
|
1675
|
+
// `cn`)在国内部署上得到的是「一个认不出的源名」,注入层认不出就什么都不注入 = 静默回到官方源,
|
|
1676
|
+
// 于是每次 pip/npm 都走境外、任务超时,而 operator 明明"配了国内源"。词表与 README 的四词同源。
|
|
1677
|
+
// ⚠️ 不直接用 `enumEnv`:它把空串当一个**值**去比对词表,而这条旋钮改前是 `|| "global"`(空串=未设,
|
|
1678
|
+
// compose 的 `${VAR:-}` 模板天天产空串)。走 `env(name, "global")` 先把空串归一成缺省,再验词表。
|
|
1432
1679
|
...(process.env.SANDBOX_PKG_SOURCE !== "none"
|
|
1433
|
-
? { sandboxPkgSource:
|
|
1680
|
+
? { sandboxPkgSource: pkgSourceEnv() }
|
|
1434
1681
|
: {}),
|
|
1435
1682
|
sessionAutoTitle: boolEnv("SESSION_AUTO_TITLE", true), // session auto-title: default ON (one cheap call per session)
|
|
1436
1683
|
selectEnvironmentTool: boolEnv("SELECT_ENVIRONMENT_TOOL", true), // RFC A2: default ON (mount additionally gated on k8s+catalog)
|
|
@@ -1445,33 +1692,53 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1445
1692
|
registry: process.env.BAKE_IMAGE_REGISTRY || null,
|
|
1446
1693
|
cacheBase: process.env.BAKE_CACHE_BASE || null,
|
|
1447
1694
|
defaultBaseRef: process.env.SANDBOX_BASE_REF || null,
|
|
1448
|
-
leaseMs:
|
|
1449
|
-
staleMs:
|
|
1450
|
-
submitRateMax:
|
|
1451
|
-
submitRateWindowSec:
|
|
1695
|
+
leaseMs: clampEnvWithWarn("BAKE_LEASE_MS", "90000", 1000), // #210 B 档(四条同族)
|
|
1696
|
+
staleMs: clampEnvWithWarn("BAKE_STALE_MS", "180000", 1000),
|
|
1697
|
+
submitRateMax: clampEnvWithWarn("BAKE_SUBMIT_RATE_MAX", "10", 0),
|
|
1698
|
+
submitRateWindowSec: clampEnvWithWarn("BAKE_SUBMIT_RATE_WINDOW_SEC", "60", 1),
|
|
1452
1699
|
},
|
|
1453
1700
|
};
|
|
1454
1701
|
}
|
|
1455
1702
|
/** 域:limits+http(限额与 HTTP 面)—— 端口、附件/工作区体积与 TTL、成本与速率天花板、排空窗、回收周期。 */
|
|
1456
1703
|
function parseLimitsHttpDomain() {
|
|
1457
1704
|
return {
|
|
1458
|
-
|
|
1705
|
+
// #210 A 档:裸 Number ⇒ NaN 直进 `server.listen(NaN)`(Node 当成 0 = 随机端口),于是服务起来了、
|
|
1706
|
+
// 健康检查在**另一个**端口上、注册进 fleet 的地址是错的,全程零告警。[0,65535] 里 0 是合法的
|
|
1707
|
+
// 「随机端口」(测试在用),不收窄。
|
|
1708
|
+
port: numEnvBounded("PORT", "8090", 0, 65535),
|
|
1459
1709
|
// D-1 孤儿对象 GC grace(复审 F10;0=关掉本腿,负/坏值走默认)。
|
|
1460
|
-
|
|
1710
|
+
// #210 C 档(不升拒的理由写在这里,不是复述行为):这条旋钮的坏值后果**轻**——回落到 1h 宽限,方向
|
|
1711
|
+
// 是「更晚回收」,不删多余的东西,不打开任何门;而它落在 D-1 附件面这种存量部署普遍设过的位置上,
|
|
1712
|
+
// 拒启的破坏面明显大于收益。所以保留回默认,补 CONFIG_WARNINGS 点名(原值 + 采用值),让 operator
|
|
1713
|
+
// 排空日志时看得见。`0` 是文档化的「关本腿」,合法且零 warn。
|
|
1714
|
+
attachmentOrphanGraceMs: ((raw) => {
|
|
1715
|
+
// ⚠️ 只把 `undefined` 当未设(**不是** `undefined || ""`):改前的判据是 `!== undefined` + `Number("")`
|
|
1716
|
+
// = 0 = 「关本腿」,空串在这条上是一个**已生效的合法值**。加上一条空串归一化会静默把一台已经关了
|
|
1717
|
+
// 孤儿清扫的部署改成 1h 宽限 —— 律条治的是坏值静默,不是顺手改既有语义。
|
|
1718
|
+
if (raw === undefined)
|
|
1719
|
+
return 3_600_000;
|
|
1720
|
+
const v = Number(raw);
|
|
1721
|
+
if (Number.isFinite(v) && v >= 0)
|
|
1722
|
+
return v;
|
|
1723
|
+
CONFIG_WARNINGS.push({ env: "ATTACHMENT_ORPHAN_GRACE_MS", raw, used: "3600000", kind: "invalid-default" });
|
|
1724
|
+
return 3_600_000;
|
|
1725
|
+
})(process.env.ATTACHMENT_ORPHAN_GRACE_MS),
|
|
1461
1726
|
// workspace 浏览面(#3,[1894]①)单文件读上限。
|
|
1462
1727
|
workspaceFileMaxBytes: optFinitePositiveEnv("WORKSPACE_FILE_MAX_BYTES") ?? 8 * 1024 * 1024,
|
|
1463
|
-
attachmentMaxBytes:
|
|
1728
|
+
attachmentMaxBytes: clampEnvWithWarn("ATTACHMENT_MAX_BYTES", String(32 * 1024 * 1024), 1024), // #210 B 档
|
|
1464
1729
|
...(process.env.ATTACHMENT_MIME_ALLOWLIST
|
|
1465
1730
|
? { attachmentMimeAllowlist: process.env.ATTACHMENT_MIME_ALLOWLIST.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) }
|
|
1466
1731
|
: {}),
|
|
1467
|
-
attachmentUnboundTtlMs:
|
|
1732
|
+
attachmentUnboundTtlMs: clampEnvWithWarn("ATTACHMENT_UNBOUND_TTL_MS", String(24 * 3600 * 1000), 60_000), // #210 B 档
|
|
1468
1733
|
infraCostRates: {
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1734
|
+
// #210 B 档:负单价夹到 0 是对的(负成本会让账面倒扣),但夹取必须点名——不然 operator 只看到
|
|
1735
|
+
// 基建成本恒 0,查不到是自己那个负号被吃掉了。
|
|
1736
|
+
toolCallMicroUsd: clampEnvWithWarn("INFRA_COST_TOOL_CALL", "0", 0),
|
|
1737
|
+
sandboxSecMicroUsd: clampEnvWithWarn("INFRA_COST_SANDBOX_SEC", "0", 0),
|
|
1738
|
+
egressGbMicroUsd: clampEnvWithWarn("INFRA_COST_EGRESS_GB", "0", 0),
|
|
1472
1739
|
},
|
|
1473
|
-
drainGraceMs:
|
|
1474
|
-
sighupIdleGraceMs:
|
|
1740
|
+
drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), 10_000), // SIGTERM drain window, default 10min, floor 10s(#210 B 档:把 600 当成"秒"填进来的手滑最常见,夹取后必须点名)
|
|
1741
|
+
sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000), // B3: SIGHUP idle-shutdown window, default 2min, floor 5s
|
|
1475
1742
|
// BL-9: numEnv (not bare Number) — a non-numeric typo FAILS at startup instead of becoming NaN, which would
|
|
1476
1743
|
// silently DISABLE the operator cost/rate ceiling (every `> ceiling` comparison against NaN is false).
|
|
1477
1744
|
rateLimitPerMin: numEnv("RATE_LIMIT_RPM", "0"),
|
|
@@ -1495,7 +1762,9 @@ function parseObservabilityDomain() {
|
|
|
1495
1762
|
otel: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
1496
1763
|
? {
|
|
1497
1764
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT.replace(/\/$/, ""),
|
|
1498
|
-
|
|
1765
|
+
// #210 A 档:NaN 周期进 setInterval ⇒ Node 归一成 1ms,出口从「每 15s 一批」变成**每毫秒**一批
|
|
1766
|
+
// (打满 collector 且把本进程 CPU 吃光),而这是一条运维为了"看见"才配的旋钮。
|
|
1767
|
+
intervalMs: numEnv("OTEL_EXPORT_INTERVAL_MS", "15000"),
|
|
1499
1768
|
serviceName: env("OTEL_SERVICE_NAME", "sema-server"),
|
|
1500
1769
|
// `OTEL_EXPORTER_OTLP_HEADERS` = comma-separated `k=v` pairs (e.g. an auth header).
|
|
1501
1770
|
headers: Object.fromEntries(csv("OTEL_EXPORTER_OTLP_HEADERS").map((kv) => {
|
|
@@ -1547,7 +1816,7 @@ function parseIntegrationsDomain() {
|
|
|
1547
1816
|
return {
|
|
1548
1817
|
// plugins 域 clone host 白名单(契约 [1361]①;缺省 github.com,逗号扩 https 镜像域)。
|
|
1549
1818
|
pluginsAllowHosts: (process.env.PLUGINS_ALLOW_HOSTS ?? "github.com").split(",").map((h) => h.trim()).filter(Boolean),
|
|
1550
|
-
configBootFetchBudgetMs:
|
|
1819
|
+
configBootFetchBudgetMs: clampEnvWithWarn("CONFIG_BOOT_FETCH_BUDGET_MS", "1500", 0), // boot 首拉抢答窗;过窗转后台补齐(clay 2026-07-17:本地 5s 启动=黑洞中心同步等待)。#210 B 档
|
|
1551
1820
|
gitApiBaseUrl: process.env.GIT_API_BASEURL,
|
|
1552
1821
|
gitApiToken: process.env.GIT_API_TOKEN,
|
|
1553
1822
|
gitApiKind: parseGitApiKind(process.env.GIT_API_KIND, process.env.GIT_API_BASEURL),
|
|
@@ -1582,7 +1851,7 @@ const MODEL_PLANE_GROUP_KEYS = [
|
|
|
1582
1851
|
const APPROVAL_GROUP_KEYS = [
|
|
1583
1852
|
"approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
|
|
1584
1853
|
"approvalHmacKeys", "durableApproval", "directApprovalDoor", "directDoorActive", "resourceSuspend",
|
|
1585
|
-
"resourceSuspendTtlSec", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "streamAskWindowMarginMs", "streamApproval", "mcpElicitation",
|
|
1854
|
+
"resourceSuspendTtlSec", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "mcpElicitation",
|
|
1586
1855
|
"sensitiveWritePatterns", "manualModeShellGate",
|
|
1587
1856
|
];
|
|
1588
1857
|
const MEMORY_GROUP_KEYS = [
|