@sema-agent/server 3.21.0 → 3.23.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/dist/approval-hmac.d.ts +9 -9
- package/dist/approval-hmac.js +0 -31
- package/dist/approval.js +8 -1
- package/dist/bake-runner/main.d.ts +2 -2
- package/dist/bake-runner/main.js +32 -9
- package/dist/boot/config-center.d.ts +3 -2
- package/dist/boot/resolve-spec.js +4 -4
- package/dist/boot/session-faces.js +3 -2
- package/dist/boot/workflow-orchestration.js +1 -1
- package/dist/budget.d.ts +2 -2
- package/dist/budget.js +11 -6
- package/dist/capabilities/center-plugins.js +1 -1
- package/dist/capabilities/skills.d.ts +12 -1
- package/dist/capabilities/skills.js +31 -6
- package/dist/config.d.ts +12 -0
- package/dist/config.js +17 -0
- package/dist/fleet/fleet-bus.js +4 -1
- package/dist/hooks/hook-llm.d.ts +2 -2
- package/dist/hooks/hook-llm.js +2 -2
- package/dist/hooks/hook-runner.d.ts +4 -0
- package/dist/hooks/hook-runner.js +4 -4
- package/dist/http/principal-gate.d.ts +7 -3
- package/dist/http/principal-gate.js +7 -5
- package/dist/http/route-ctx.d.ts +34 -25
- package/dist/http/routes/approvals-assistant.js +5 -5
- package/dist/http/routes/images.js +8 -8
- package/dist/http/routes/runs.js +6 -1
- package/dist/http/server.d.ts +2 -2
- package/dist/http/server.js +10 -6
- package/dist/key-resolver.d.ts +7 -1
- package/dist/key-resolver.js +0 -23
- package/dist/leader/wire.d.ts +19 -1
- package/dist/leader/wire.js +48 -18
- package/dist/lsp/e2b-bridge.js +14 -1
- package/dist/lsp/e2b-manager.d.ts +8 -4
- package/dist/lsp/e2b-manager.js +55 -23
- package/dist/main.js +2 -2
- package/dist/parked-decide.js +4 -1
- package/dist/plugins/file-run-store.js +5 -5
- package/dist/plugins/host-platform.d.ts +11 -24
- package/dist/plugins/host-platform.js +14 -0
- package/dist/plugins/memory-engine-tidb.js +16 -0
- package/dist/plugins/memory-run-store.js +5 -5
- package/dist/plugins/remote-env-adb.js +12 -5
- package/dist/plugins/remote-env-local-docker.js +3 -7
- package/dist/plugins/remote-env-ssh.js +17 -2
- package/dist/plugins/run-store-sql.js +12 -12
- package/dist/plugins/store-backend.d.ts +1 -1
- package/dist/plugins/store-backend.js +2 -2
- package/dist/plugins/tool-result-store-sql.d.ts +7 -1
- package/dist/plugins/tool-result-store-sql.js +9 -8
- package/dist/plugins/workflow-run-store-sql.d.ts +11 -6
- package/dist/plugins/workflow-run-store-sql.js +18 -8
- package/dist/session-sync.js +6 -3
- package/package.json +1 -1
package/dist/approval-hmac.d.ts
CHANGED
|
@@ -30,13 +30,19 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
|
|
|
30
30
|
* 之所以到今天才把规则写死:此前四个字段全是**受限字母表**(uuid / 调用 id / 十六进制 / 枚举),转义怎么
|
|
31
31
|
* 写都一样、规则不写也测不出来;`reason` 是第一个进入签名载荷的自由文本,转义从"无所谓"变成"承重"。
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
/** The canonical 5-field envelope both {@link approvalHmacMessage} (the signer's/verifier's shared byte-rule) and
|
|
34
|
+
* {@link verifyApprovalHmac} take — ONE named type so the two signatures cannot silently drift into two
|
|
35
|
+
* structurally-similar-but-not-identical inline shapes (drift there would only surface at RUNTIME as a MAC
|
|
36
|
+
* mismatch, never at compile time — see the file-header canonical-message note for why each field's presence
|
|
37
|
+
* is load-bearing). */
|
|
38
|
+
export interface ApprovalEnvelope {
|
|
34
39
|
sessionId: string;
|
|
35
40
|
boundCallId?: string | null;
|
|
36
41
|
boundInputHash?: string | null;
|
|
37
42
|
decision: string;
|
|
38
43
|
reason?: string | null;
|
|
39
|
-
}
|
|
44
|
+
}
|
|
45
|
+
export declare function approvalHmacMessage(env: ApprovalEnvelope): string;
|
|
40
46
|
/** 签名载荷里 `reason` 的字符上限。**拒**而不是截 —— 截断在这条路径上**结构上不可行**:服务端截过的字节
|
|
41
47
|
* 与签名方签的字节必然不同,MAC 恒不匹配,"截断"只会把一个可诊断的 413 变成一个费解的 401。
|
|
42
48
|
* 取 4096 与本仓既有的人写文本上限同档(`MAX_ELICIT_MESSAGE_CHARS` / `MAX_AGENT_TEXT_CHARS`)。 */
|
|
@@ -47,11 +53,5 @@ export declare const MAX_APPROVAL_REASON_CHARS = 4096;
|
|
|
47
53
|
* signed with a key being rotated out still verifies during the overlap). `timingSafeEqual` avoids a timing
|
|
48
54
|
* oracle. An empty key-set ⇒ false (caller decides whether absence = skip-because-inactive or fail-closed).
|
|
49
55
|
*/
|
|
50
|
-
export declare function verifyApprovalHmac(env:
|
|
51
|
-
sessionId: string;
|
|
52
|
-
boundCallId?: string | null;
|
|
53
|
-
boundInputHash?: string | null;
|
|
54
|
-
decision: string;
|
|
55
|
-
reason?: string | null;
|
|
56
|
-
}, mac: string, kid: string | undefined, keys: ApprovalHmacKey[]): boolean;
|
|
56
|
+
export declare function verifyApprovalHmac(env: ApprovalEnvelope, mac: string, kid: string | undefined, keys: ApprovalHmacKey[]): boolean;
|
|
57
57
|
//# sourceMappingURL=approval-hmac.d.ts.map
|
package/dist/approval-hmac.js
CHANGED
|
@@ -25,37 +25,6 @@
|
|
|
25
25
|
* already-resolved token → the resolve CAS fails already_resolved), so no separate replay cache is needed.
|
|
26
26
|
*/
|
|
27
27
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
28
|
-
/**
|
|
29
|
-
* The canonical message an approval MAC signs — a fixed-order, JSON-escaped tuple so no field value (an id, a
|
|
30
|
-
* hash, a free-text note) can ambiguate the boundary with the next (same discipline as the approvals-stream diff
|
|
31
|
-
* key). 🔴 Bound to CLIENT-VISIBLE values only: `sessionId` (in the /decide URL) + `boundCallId`/`boundInputHash`
|
|
32
|
-
* (surfaced by listPending) + `reason` (the caller's own body). It must NOT include the checkpointToken — that is
|
|
33
|
-
* the unexposed resume credential the client can never see (it resumes by sessionId), so a checkpointToken-bound
|
|
34
|
-
* message would be uncomputable by the signer.
|
|
35
|
-
*
|
|
36
|
-
* ── `reason` 入签(三方裁 (a):cli [1797] / core [1801]§四 / server;clay「干净切」令 [1802])─────────────
|
|
37
|
-
* `reason` 是随决定落账的自由文本(审计面的一部分)。它此前**在信封之外** ⇒ 能改线上字节的一方(反代 /
|
|
38
|
-
* 被劫持的客户端 —— 正是 HMAC 要防的威胁模型)可以在操作员批准同一个动作的那次决定里,把落账理由换成
|
|
39
|
-
* 别的:动作没变,**审计记录变了**。三方裁定纳入签名载荷,不留 `reasonUnsigned` 宽限代际(未上生产)。
|
|
40
|
-
*
|
|
41
|
-
* ── 🔴 字节构造规则(权威定义;跨仓签名器照此实现,docs/ASSISTANT-WIRE-CONTRACT.md §6.2 同源)──────────
|
|
42
|
-
* 被 HMAC 的是下面这个字符串的 **UTF-8 字节**:
|
|
43
|
-
* 1. **恒 5 元素、定序**的紧凑 JSON 数组(元素间**无空格**):
|
|
44
|
-
* `[sessionId, boundCallId|null, boundInputHash|null, decision, reason|null]`
|
|
45
|
-
* —— 恒定长度是刻意的:签名器里少一个"有 reason 才追加"的条件分支,就少一类跨实现漂移。
|
|
46
|
-
* 2. 缺席 ⇒ `null`;**`""`(空串)是与 `null` 不同的被签值**(否则"把理由抹成空"不改变 MAC)。
|
|
47
|
-
* 3. 字符串转义 = RFC 8259 最小集:`"`→`\"`、`\`→`\\`、U+0000–U+001F → `\n`/`\t`/`\uXXXX`。
|
|
48
|
-
* 4. **非 ASCII 原样输出 UTF-8,不转 `\uXXXX`**。⚠️ Python 的 `json.dumps` 缺省 `ensure_ascii=True` 会踩
|
|
49
|
-
* —— 必须 `json.dumps(t, ensure_ascii=False, separators=(",", ":"))`。
|
|
50
|
-
* 5. **`<` `>` `&` 不转义**。⚠️ Go 的 `json.Marshal` 缺省 HTML 转义会踩 —— 必须
|
|
51
|
-
* `enc := json.NewEncoder(w); enc.SetEscapeHTML(false)`(并去掉它追加的换行)。
|
|
52
|
-
* 6. 孤代理项按 ES2019 well-formed 语义转成 `\udXXX`(JS 原生行为,其它语言按同规则)。
|
|
53
|
-
* 7. MAC = HMAC-SHA256(上述字节),**小写十六进制**上 wire。
|
|
54
|
-
* 逐字节黄金向量(含中文/引号/换行/制表/`<`/`&`/emoji)钉在 `test/approval-hmac.test.ts`,签名器对拍用。
|
|
55
|
-
*
|
|
56
|
-
* 之所以到今天才把规则写死:此前四个字段全是**受限字母表**(uuid / 调用 id / 十六进制 / 枚举),转义怎么
|
|
57
|
-
* 写都一样、规则不写也测不出来;`reason` 是第一个进入签名载荷的自由文本,转义从"无所谓"变成"承重"。
|
|
58
|
-
*/
|
|
59
28
|
export function approvalHmacMessage(env) {
|
|
60
29
|
return JSON.stringify([env.sessionId, env.boundCallId ?? null, env.boundInputHash ?? null, env.decision, env.reason ?? null]);
|
|
61
30
|
}
|
package/dist/approval.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* dropped at the task deadline instead of holding the worker — design/06 F4 self-protection.
|
|
9
9
|
*/
|
|
10
10
|
import { createApprovalPolicy, canonicalToolName, uuidv7 } from "@sema-agent/core";
|
|
11
|
+
import { createLogger } from "./observability/logger.js";
|
|
12
|
+
const logger = createLogger();
|
|
11
13
|
/**
|
|
12
14
|
* 运维**是否表达了门意图** —— boot 的单用户 allow-all 基线只在"零门意图"时才允许铺开,
|
|
13
15
|
* 所以这个谓词漏一格 = 那一格的意图被 allow-all 静默吞掉。
|
|
@@ -170,7 +172,12 @@ export async function waitForDecision(store, id, scope, signal, pollMs) {
|
|
|
170
172
|
let consecutiveErrors = 0;
|
|
171
173
|
for (;;) {
|
|
172
174
|
if (signal?.aborted) {
|
|
173
|
-
|
|
175
|
+
// C1(failloud):最小对症=失败留痕,不必重试——这是最后一次尝试把行标 expired,任务本身已经在
|
|
176
|
+
// abort 路径上收尾,重试没有下一个时机。裸吞会让一次真实的店写故障(TiDB blip)在观测面上
|
|
177
|
+
// 与"成功标了 expired"毫无区别;补 error 留痕,行为(deny)不变。
|
|
178
|
+
await store.decide(id, scope, "expired", "task aborted before decision", null).catch((e) => {
|
|
179
|
+
logger.error("approval_expire_write_failed", { id, scope, err: e instanceof Error ? e.message : String(e) });
|
|
180
|
+
});
|
|
174
181
|
return false;
|
|
175
182
|
}
|
|
176
183
|
let s;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ImageApiClient, type ChildSpawner, type HostOps } from "./runner.js";
|
|
1
|
+
import { type ImageApiClient, type ChildSpawner, type HostOps, type BakeRunnerLogger } from "./runner.js";
|
|
2
2
|
interface RunnerEnv {
|
|
3
3
|
imageApiBase: string;
|
|
4
4
|
token: string;
|
|
@@ -14,7 +14,7 @@ interface RunnerEnv {
|
|
|
14
14
|
declare function loadEnv(): RunnerEnv;
|
|
15
15
|
/** The real image-api HTTP client. Authenticates claim/heartbeat with the runner bearer; ingest additionally
|
|
16
16
|
* carries the per-bake ingest secret header (so image-api can reject a stale/rogue runner — §P2.4e). */
|
|
17
|
-
declare function makeApiClient(env: RunnerEnv): ImageApiClient;
|
|
17
|
+
declare function makeApiClient(env: RunnerEnv, log: BakeRunnerLogger): ImageApiClient;
|
|
18
18
|
/** The real child spawner: `setsid`-style detached process GROUP so a cancel/deadline kill signals the whole tree
|
|
19
19
|
* (channel-close / a plain kill of the immediate child does NOT stop a detached build — §P2.11). */
|
|
20
20
|
declare function makeSpawner(): ChildSpawner;
|
package/dist/bake-runner/main.js
CHANGED
|
@@ -11,6 +11,7 @@ import { spawn, exec as execCb } from "node:child_process";
|
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { createLogger } from "../observability/logger.js";
|
|
14
|
+
import { parseNumOrFailNonNegative } from "../config.js";
|
|
14
15
|
import { BakeRunner, } from "./runner.js";
|
|
15
16
|
const exec = promisify(execCb);
|
|
16
17
|
function loadEnv() {
|
|
@@ -28,14 +29,22 @@ function loadEnv() {
|
|
|
28
29
|
recipeDir: process.env.RECIPE_DIR || "/opt/recipes",
|
|
29
30
|
buildShPath: process.env.BUILD_SH_PATH || "e2b-template/dev-sandbox/build.sh",
|
|
30
31
|
dataDir: process.env.BAKE_DATA_DIR || "/data",
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// A2/E5 (gap-sweep 2026-08-01): these were bare `Number(process.env.X || default)` — a unit-suffixed or
|
|
33
|
+
// typo'd value (`"30s"`) silently became NaN, and NaN survives every downstream `??`/`?? default` guard
|
|
34
|
+
// (those only catch null/undefined). The fallout is a SILENT-LOOSENING pair: `clock.sleep(NaN)` resolves
|
|
35
|
+
// ~immediately (`setTimeout(fn, NaN)` behaves like a 0ms timer) turning the lease heartbeat into a tight
|
|
36
|
+
// POST loop against image-api, and `freeGb < NaN` is always false so the pre-flight disk guard silently
|
|
37
|
+
// never fires. `parseNumOrFailNonNegative` (config.ts, shared with leader/wire.ts's four resource knobs)
|
|
38
|
+
// fails loud at bake-runner startup instead — non-numeric OR negative both throw; unset/empty still falls
|
|
39
|
+
// back to the documented default (zero behavior change on the happy path).
|
|
40
|
+
heartbeatMs: parseNumOrFailNonNegative("BAKE_HEARTBEAT_MS", process.env.BAKE_HEARTBEAT_MS || "30000"),
|
|
41
|
+
idlePollMs: parseNumOrFailNonNegative("BAKE_IDLE_POLL_MS", process.env.BAKE_IDLE_POLL_MS || "5000"),
|
|
42
|
+
minFreeGb: parseNumOrFailNonNegative("IMAGE_BAKE_MIN_FREE_GB", process.env.IMAGE_BAKE_MIN_FREE_GB || "20"),
|
|
34
43
|
};
|
|
35
44
|
}
|
|
36
45
|
/** The real image-api HTTP client. Authenticates claim/heartbeat with the runner bearer; ingest additionally
|
|
37
46
|
* carries the per-bake ingest secret header (so image-api can reject a stale/rogue runner — §P2.4e). */
|
|
38
|
-
function makeApiClient(env) {
|
|
47
|
+
function makeApiClient(env, log) {
|
|
39
48
|
const auth = { authorization: `Bearer ${env.token}`, "content-type": "application/json" };
|
|
40
49
|
return {
|
|
41
50
|
// Long-poll claim (§P2.12): the bare `POST …/bakes/claim` finds the oldest queued bake AND leases it in one
|
|
@@ -46,13 +55,27 @@ function makeApiClient(env) {
|
|
|
46
55
|
headers: auth,
|
|
47
56
|
body: JSON.stringify({ runnerId: env.runnerId }),
|
|
48
57
|
});
|
|
49
|
-
if (res.status === 204
|
|
50
|
-
return null; // empty queue
|
|
51
|
-
|
|
58
|
+
if (res.status === 204)
|
|
59
|
+
return null; // empty queue — the routine "nothing to do" case, no log line
|
|
60
|
+
// C6/C1 (2026-08-01): 401 (bad runner credential) / 409 (lost the single-flight CAS to another runner
|
|
61
|
+
// replica) / 5xx previously fell into this SAME silent-`null` branch as an empty queue — a misconfigured
|
|
62
|
+
// BAKE_RUNNER_TOKEN and a healthy idle runner were indistinguishable from the outside (both just idle-poll
|
|
63
|
+
// forever with zero log lines). `bake_claim_error` (loop()'s catch) only fires on a THROWN error — a non-ok
|
|
64
|
+
// HTTP response never throws — so this warn is the only trace a non-2xx claim response leaves anywhere.
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
log.warn("bake_claim_rejected", { status: res.status });
|
|
52
67
|
return null;
|
|
68
|
+
}
|
|
53
69
|
const c = (await res.json());
|
|
54
|
-
if (!c.bakeId || !Array.isArray(c.argv) || !c.ingestSecret)
|
|
70
|
+
if (!c.bakeId || !Array.isArray(c.argv) || !c.ingestSecret) {
|
|
71
|
+
// A malformed 2xx body is worse than a rejection: image-api's claim call ATOMICALLY leases the bake
|
|
72
|
+
// server-side before returning it, so a shape we refuse to trust here is a bake already checked OUT
|
|
73
|
+
// from the queue that we are about to drop on the floor. We still can't safely act on an untrusted
|
|
74
|
+
// shape (no bakeId ⇒ no way to release/fail it), so this warn is the only surfacing point until
|
|
75
|
+
// image-api's stale-lease reaper reclaims it (§P2.7) — see runner.ts module doc for the reaper backstop.
|
|
76
|
+
log.warn("bake_claim_malformed", { status: res.status, bakeId: typeof c.bakeId === "string" ? c.bakeId : undefined });
|
|
55
77
|
return null;
|
|
78
|
+
}
|
|
56
79
|
return {
|
|
57
80
|
bakeId: c.bakeId,
|
|
58
81
|
argv: c.argv.map(String),
|
|
@@ -223,7 +246,7 @@ async function main() {
|
|
|
223
246
|
minFreeGb: env.minFreeGb,
|
|
224
247
|
}); // NB: the token + per-bake ingest secret are NEVER logged (§P2.4e)
|
|
225
248
|
const runner = new BakeRunner({
|
|
226
|
-
api: makeApiClient(env),
|
|
249
|
+
api: makeApiClient(env, log),
|
|
227
250
|
spawner: makeSpawner(),
|
|
228
251
|
host: makeHostOps(env),
|
|
229
252
|
clock: makeClock(),
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { CenterPromptSource
|
|
1
|
+
import { CenterPromptSource } from "@sema-agent/core";
|
|
2
2
|
import { buildPricing } from "../budget.js";
|
|
3
3
|
import { type PromptsDomainFaces } from "../capabilities/center-prompts.js";
|
|
4
4
|
import { type Scenario, type ScenarioDeps, type ScenarioDetail } from "../capabilities/scenarios.js";
|
|
5
5
|
import type { LoadedSkill } from "../capabilities/skills.js";
|
|
6
6
|
import { type ServiceConfig } from "../config.js";
|
|
7
|
+
import { type ModelKeyRef } from "../key-resolver.js";
|
|
7
8
|
import type { Logger } from "../observability/logger.js";
|
|
8
9
|
import type { Metrics } from "../observability/metrics.js";
|
|
9
10
|
import { type RestartSignal } from "../config-center/facade.js";
|
|
@@ -42,7 +43,7 @@ export interface ConfigCenterRuntime {
|
|
|
42
43
|
pricing: ReturnType<typeof buildPricing>;
|
|
43
44
|
}): void;
|
|
44
45
|
/** 晚绑取值:refresh 热应用会整个换引用(hook LLM / resolveSpec 每次现取)。 */
|
|
45
|
-
getKeyResolver(): ((model:
|
|
46
|
+
getKeyResolver(): ((model: ModelKeyRef) => Promise<{
|
|
46
47
|
apiKey: string;
|
|
47
48
|
} | undefined>) | undefined;
|
|
48
49
|
/** 晚绑取值:center 提示词面(热采用,新任务边界生效)。 */
|
|
@@ -30,7 +30,7 @@ import { applyLongtailDefer } from "../capabilities/tool-defer.js";
|
|
|
30
30
|
import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane } from "../env-facts.js";
|
|
31
31
|
import { FleetEventBus } from "../fleet/fleet-bus.js";
|
|
32
32
|
import { composeHooks, createTaskHooks } from "../hooks/hook-runner.js";
|
|
33
|
-
import {
|
|
33
|
+
import { explicitOperatorOk } from "../http/server.js";
|
|
34
34
|
import { matchCatalogModel, modelSupportsImages, resolveTaskModel } from "../model-select.js";
|
|
35
35
|
import { PerTaskImageRegistry, resolveSandboxImageRef } from "../per-task-image.js";
|
|
36
36
|
import { isRemoteScratchpadLane, remoteScratchpadDirFor } from "../plugins/remote-scratchpad.js";
|
|
@@ -533,7 +533,7 @@ export function createResolveSpec(ctx) {
|
|
|
533
533
|
// design/131 (core 1.246): per-task resilience INTENT flags. allowDegrade/allowFailover are
|
|
534
534
|
// caller-facing (a bench/eval run wants true failure shapes); bypassBreaker is operator-only (normalizer
|
|
535
535
|
// drops it for non-operators — it punches through a SHARED breaker). All-absent = byte-compat.
|
|
536
|
-
resilience: normalizeResilience(body.resilience,
|
|
536
|
+
resilience: normalizeResilience(body.resilience, explicitOperatorOk(auth?.principal, config.operatorPrincipals)),
|
|
537
537
|
// design/132 (core 1.249): one-shot end-game verification nudge ("re-run the final artifact through
|
|
538
538
|
// its real entrypoint before finishing"). OPT-IN by core's own judgment (default ON lost the evidence case:
|
|
539
539
|
// +1 turn on every interactive write task) — the AUTONOMY caller declares it (harness/scheduler lanes).
|
|
@@ -962,11 +962,11 @@ export function createResolveSpec(ctx) {
|
|
|
962
962
|
profile: taskImageProfile,
|
|
963
963
|
...(capsNeeded && capsNeeded.length > 0 ? { capabilitiesNeeded: capsNeeded } : {}),
|
|
964
964
|
// principal comes from the auth channel (trusted header / verified JWT), NEVER the body — so a caller
|
|
965
|
-
// cannot widen its own visibility.
|
|
965
|
+
// cannot widen its own visibility. explicitOperatorOk (NOT isOperator): an empty OPERATOR_PRINCIPALS must
|
|
966
966
|
// yield operator=false here — isOperator([],p)=true-for-all would let any caller resolve tenant-scoped
|
|
967
967
|
// images on an operator-less deployment (adversarial-review HIGH-1; the bake/direct-door boot guards do
|
|
968
968
|
// NOT cover per-task image selection).
|
|
969
|
-
viewer: { operator:
|
|
969
|
+
viewer: { operator: explicitOperatorOk(auth.principal, config.operatorPrincipals), tenantId: auth.principal ?? null },
|
|
970
970
|
index: imageIndex,
|
|
971
971
|
});
|
|
972
972
|
if (!resolved.ok)
|
|
@@ -63,8 +63,9 @@ export function createSessionFaces(ctx) {
|
|
|
63
63
|
})
|
|
64
64
|
: undefined;
|
|
65
65
|
// S2 fast path([1208]③ 兑现):同副本 append 落点(tidb/pg persist post-commit)经 leaf-bus 直推
|
|
66
|
-
// notifyLocal——单副本部署零延迟;owner
|
|
67
|
-
// (
|
|
66
|
+
// notifyLocal——单副本部署零延迟;owner 随 bus 传(commit-time row owner,session-leaf-bus 头注契约:
|
|
67
|
+
// "Owner IS carried"),notifyLocal 据此做 generation 围栏(旧 A 世代不会假匹配新租户的叶子)。
|
|
68
|
+
// local lane 无写钩,探针道照旧。
|
|
68
69
|
setLeafAdvanceListener(sessionWatchRegistry ? (sid, leaf, owner, seq) => sessionWatchRegistry.notifyLocal(sid, leaf, owner, seq) : undefined);
|
|
69
70
|
const purgeSession = ownerAware.deleteSession
|
|
70
71
|
? async (sessionId, owner) => {
|
|
@@ -33,7 +33,7 @@ export function createWorkflowOrchestration(ctx) {
|
|
|
33
33
|
// present — a failover-landed session sees the run's history + its pending completion push on ANY replica.
|
|
34
34
|
// Explicit WORKFLOW_RUN_STORE=file/memory still wins (single-box File posture unchanged: local backend has
|
|
35
35
|
// no workflowRun() so auto falls to File there).
|
|
36
|
-
const sqlWorkflowRunStore = config.workflowRunStoreBackend === "auto" ? backend?.workflowRun?.() : undefined;
|
|
36
|
+
const sqlWorkflowRunStore = config.workflowRunStoreBackend === "auto" ? backend?.workflowRun?.((msg, meta) => logger.warn(msg, meta)) : undefined; // C5: oversize-slim 留痕接 logger(completionInbox :82 同款)
|
|
37
37
|
const baseWorkflowRunStore = config.selfOrchestrationEnabled
|
|
38
38
|
? config.workflowRunStoreBackend === "memory"
|
|
39
39
|
? new InMemoryWorkflowRunStore()
|
package/dist/budget.d.ts
CHANGED
|
@@ -83,7 +83,7 @@ export { type PromptManifestRecord, promptManifestRecordOf, configAssembledRecor
|
|
|
83
83
|
* fires for EVERY call (top-level + async + council sub-tasks) → `model_cost_micro_usd_total` is the authoritative
|
|
84
84
|
* spend, and (when a {@link ModelUsageTracker} is supplied) the per-task `modelUsage` echo's source.
|
|
85
85
|
*/
|
|
86
|
-
export declare function createTracer(metrics: Pick<Metrics, "inc" | "observe">, costQuota?: QuotaTracker, modelUsage?: ModelUsageTracker, fleetUsage?: FleetUsageAccumulator, fleetLease?: FleetLeaseManager, quotaWeightFor?: (model: string) => number, promptManifests?: PromptManifestTracker): TracerHook;
|
|
86
|
+
export declare function createTracer(metrics: Pick<Metrics, "inc" | "observe">, costQuota?: QuotaTracker, modelUsage?: ModelUsageTracker, fleetUsage?: Pick<FleetUsageAccumulator, "record">, fleetLease?: Pick<FleetLeaseManager, "recordSpend">, quotaWeightFor?: (model: string) => number, promptManifests?: PromptManifestTracker): TracerHook;
|
|
87
87
|
/**
|
|
88
88
|
* [1469] side-query 记账 seam(codex R3 high:sideQuery 不走 core tracer——brain.stream 直调不发
|
|
89
89
|
* brain.call——createTracer 的四路 sink(costQuota/model_cost 指标/fleetUsage 批报/fleetLease 本地扣减)
|
|
@@ -106,7 +106,7 @@ export declare function cacheFamilyOfMirror(model: {
|
|
|
106
106
|
promptCacheFamily?: string;
|
|
107
107
|
};
|
|
108
108
|
} | undefined): "input-includes-cached" | "input-excludes-cached" | undefined;
|
|
109
|
-
export declare function createSideQueryAccountant(metrics: Pick<Metrics, "inc">, costQuota?: QuotaTracker, fleetUsage?: FleetUsageAccumulator, fleetLease?: FleetLeaseManager, quotaWeightFor?: (model: string) => number): (principal: string | undefined, r: {
|
|
109
|
+
export declare function createSideQueryAccountant(metrics: Pick<Metrics, "inc">, costQuota?: QuotaTracker, fleetUsage?: Pick<FleetUsageAccumulator, "record">, fleetLease?: Pick<FleetLeaseManager, "recordSpend">, quotaWeightFor?: (model: string) => number): (principal: string | undefined, r: {
|
|
110
110
|
model: string;
|
|
111
111
|
family?: "input-includes-cached" | "input-excludes-cached";
|
|
112
112
|
usage?: {
|
package/dist/budget.js
CHANGED
|
@@ -320,7 +320,9 @@ export function cacheFamilyOfMirror(model) {
|
|
|
320
320
|
return "input-includes-cached";
|
|
321
321
|
return model.api === "anthropic-messages" || model.api === "bedrock-converse-stream" ? "input-excludes-cached" : "input-includes-cached";
|
|
322
322
|
}
|
|
323
|
-
export function createSideQueryAccountant(metrics, costQuota,
|
|
323
|
+
export function createSideQueryAccountant(metrics, costQuota,
|
|
324
|
+
// 依赖面收窄到真消费的方法(§X 同 ModelKeyRef 一式):全形对象照传(结构超集),测试桩零铸型。
|
|
325
|
+
fleetUsage, fleetLease, quotaWeightFor) {
|
|
324
326
|
return (principal, r) => {
|
|
325
327
|
const input = typeof r.usage?.input === "number" ? r.usage.input : 0;
|
|
326
328
|
const output = typeof r.usage?.output === "number" ? r.usage.output : 0;
|
|
@@ -331,8 +333,11 @@ export function createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetL
|
|
|
331
333
|
// 大幅少收(lease 可被 cache-heavy side query 绕穿)。family 未知(catalog 查不到)走**保守臂**
|
|
332
334
|
// (含 cache——OpenAI 族此臂多收,方向与 cost 残差一致:宁多勿少,记档同一条)。
|
|
333
335
|
const promptTokens = r.family === "input-includes-cached" ? input : input + cacheRead + cacheWrite;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
+
// RB-368/#59 同判据(createTracer 上方注释同款纪律):`usage.cost.total` 整键缺席 = 未知(unpriced 部署的
|
|
337
|
+
// side query 真形),不是「花了 $0」——折 0 就是把「不知道」编造成「免费」。已知(哪怕显式 0)才带键。
|
|
338
|
+
const costTotal = typeof r.usage?.cost?.total === "number" ? r.usage.cost.total : undefined;
|
|
339
|
+
const micro = costTotal !== undefined ? Math.round(costTotal * 1_000_000) : undefined;
|
|
340
|
+
if (micro !== undefined && micro > 0) {
|
|
336
341
|
metrics.inc("model_cost_micro_usd_total", { model: r.model }, micro);
|
|
337
342
|
if (principal)
|
|
338
343
|
costQuota?.add(principal, micro);
|
|
@@ -340,9 +345,9 @@ export function createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetL
|
|
|
340
345
|
const qwRaw = quotaWeightFor?.(r.model);
|
|
341
346
|
const qw = typeof qwRaw === "number" && Number.isFinite(qwRaw) && qwRaw > 0 ? qwRaw : 1;
|
|
342
347
|
const weightedTokens = Math.round((promptTokens + output) * qw);
|
|
343
|
-
if (weightedTokens > 0 || micro > 0) {
|
|
344
|
-
fleetUsage?.record(principal, r.model, "", { inputTokens: promptTokens, outputTokens: output, costMicroUsd: micro, weightedTokens, quotaWeightAtUse: qw }); // taskId ""(无 run 语义;accumulator 对 "" 不计 runsSeen);inputTokens=归一化总输入含 cache(fleet-bus 同口径)
|
|
345
|
-
fleetLease?.recordSpend(principal, { weightedTokens, costMicroUsd: micro });
|
|
348
|
+
if (weightedTokens > 0 || (micro !== undefined && micro > 0)) {
|
|
349
|
+
fleetUsage?.record(principal, r.model, "", { inputTokens: promptTokens, outputTokens: output, ...(micro !== undefined ? { costMicroUsd: micro } : {}), weightedTokens, quotaWeightAtUse: qw }); // taskId ""(无 run 语义;accumulator 对 "" 不计 runsSeen);inputTokens=归一化总输入含 cache(fleet-bus 同口径);RB-368 透传缺席
|
|
350
|
+
fleetLease?.recordSpend(principal, { weightedTokens, ...(micro !== undefined ? { costMicroUsd: micro } : {}) }); // RB-368 透传缺席
|
|
346
351
|
}
|
|
347
352
|
};
|
|
348
353
|
}
|
|
@@ -180,7 +180,7 @@ export async function applyCenterPlugins(baseline, eff, opts) {
|
|
|
180
180
|
}
|
|
181
181
|
if (!rootCheck.dir)
|
|
182
182
|
continue; // 无 skills 目录:合法(commands-only 插件,cli 壳的半场)
|
|
183
|
-
const loaded = loadSkills(rootCheck.dir);
|
|
183
|
+
const loaded = loadSkills(rootCheck.dir, { confineTo: liveDir }); // 不可信 lane:逐条目约束在 clone root 内(根守卫只守了 skills 根,根下 symlink 可逃逸) // 不可信 lane:逐条目约束在 clone root 内(根守卫只守了 skills 根,根下 symlink 可逃逸) // 不可信 lane:逐条目约束在 clone root 内(根守卫只守了 skills 根,根下 symlink 可逃逸)
|
|
184
184
|
for (const skill of loaded) {
|
|
185
185
|
if (taken.has(skill.spec.name)) {
|
|
186
186
|
// 收紧④:plugin 让位(方向与 center-wins 相反,理由见模块顶注)。
|
|
@@ -31,7 +31,18 @@ export interface LoadedSkill {
|
|
|
31
31
|
/** Scenarios this skill applies to; empty = global (every scenario). */
|
|
32
32
|
scenarios: string[];
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** 加载 lane 的信任级。缺席(受控 lane:烤制进镜像的 skills 目录)= 维持既有语义,symlink 照跟
|
|
35
|
+
* ——那个便利是有意的(`foo.md -> 共享文件`)。`confineTo` 在场(**不可信 lane**:第三方 git checkout,
|
|
36
|
+
* 见 center-plugins 的 plugin 装载)= 每个被读条目 realpath 后必须仍在该根之内,逃逸即抛。
|
|
37
|
+
* 🔴 为什么信任级必须是**参数**而不是注释里的假设:本函数的头注原写着「skills dir 全受控」,而
|
|
38
|
+
* center-plugins 把它原样复用在 attacker-controlled 的 git checkout 上——git 原样保留仓里的 symlink,
|
|
39
|
+
* 于是 checkout 里某个 skill 条目(`任意 .md -> /宿主/任意文件`)会被读进 spec.files 进入提示词。resolvePluginSkillsRoot
|
|
40
|
+
* 只 realpath 守了 skills **根**,根下逐条目没守。 */
|
|
41
|
+
export interface LoadSkillsOptions {
|
|
42
|
+
/** 不可信 lane 的约束根(通常=clone root)。缺席=受控 lane,不做逐条目约束。 */
|
|
43
|
+
confineTo?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function loadSkills(dir: string, opts?: LoadSkillsOptions): LoadedSkill[];
|
|
35
46
|
/** Skills applicable to a scenario: those tagged with it, plus untagged (global) ones. */
|
|
36
47
|
export declare function skillsForScenario(loaded: LoadedSkill[], scenario: string): SkillSpec[];
|
|
37
48
|
export declare function parseFrontmatter(raw: string): {
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
/** `p` 的 realpath 必须落在 `rootReal` 之内(含自身),否则抛。`sep` 边界判定防 `/a/root-evil` 撞
|
|
4
|
+
* `/a/root` 前缀。目标不存在 ⇒ realpath 抛 ENOENT,同样是拒绝(悬空链接不该被读)。 */
|
|
5
|
+
function assertConfined(p, rootReal) {
|
|
6
|
+
let real;
|
|
7
|
+
try {
|
|
8
|
+
real = realpathSync(p);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
throw new Error(`skill entry ${p} is unreadable (dangling symlink or missing): ${e instanceof Error ? e.message : String(e)}`);
|
|
12
|
+
}
|
|
13
|
+
if (real !== rootReal && !real.startsWith(rootReal + sep)) {
|
|
14
|
+
throw new Error(`skill entry ${p} escapes the plugin root (resolves outside ${rootReal}) — untrusted checkouts may not link to host files`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function loadSkills(dir, opts) {
|
|
4
18
|
let entries;
|
|
5
19
|
try {
|
|
6
20
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -21,17 +35,22 @@ export function loadSkills(dir) {
|
|
|
21
35
|
// sort 保证加载顺序稳定(冲突报错的"先到者"也因此确定)。dotfile/dot 目录跳过(.DS_Store/.git 类
|
|
22
36
|
// 环境杂物,不是 skill 内容);isFileLike 兼容 symlink(Dirent.isFile() 对 symlink 恒 false,而旧扁平
|
|
23
37
|
// 实现 readFileSync 是跟链接的——不兼容会让 skills 目录下 `foo.md -> 共享文件` 这种软链无声消失)。
|
|
38
|
+
const confineRoot = opts?.confineTo !== undefined ? realpathSync(opts.confineTo) : undefined;
|
|
24
39
|
const isFileLike = (entry, p) => entry.isFile() || (entry.isSymbolicLink() && statSync(p).isFile());
|
|
25
40
|
const isDirLike = (entry, p) => entry.isDirectory() || (entry.isSymbolicLink() && statSync(p).isDirectory());
|
|
26
41
|
for (const entry of entries.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
27
42
|
if (entry.name.startsWith("."))
|
|
28
43
|
continue;
|
|
29
44
|
if (isFileLike(entry, join(dir, entry.name)) && entry.name.endsWith(".md")) {
|
|
45
|
+
if (confineRoot !== undefined)
|
|
46
|
+
assertConfined(join(dir, entry.name), confineRoot);
|
|
30
47
|
const raw = readFileSync(join(dir, entry.name), "utf8");
|
|
31
48
|
push(parseSkill(raw, entry.name.replace(/\.md$/, "")), join(dir, entry.name));
|
|
32
49
|
}
|
|
33
50
|
else if (isDirLike(entry, join(dir, entry.name))) {
|
|
34
51
|
const skillDir = join(dir, entry.name);
|
|
52
|
+
if (confineRoot !== undefined)
|
|
53
|
+
assertConfined(skillDir, confineRoot);
|
|
35
54
|
let raw;
|
|
36
55
|
try {
|
|
37
56
|
raw = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
|
@@ -41,8 +60,10 @@ export function loadSkills(dir) {
|
|
|
41
60
|
// 整个 skill 无声丢掉 → fail-loud
|
|
42
61
|
throw new Error(`skill directory ${skillDir} has no SKILL.md`);
|
|
43
62
|
}
|
|
63
|
+
if (confineRoot !== undefined)
|
|
64
|
+
assertConfined(join(skillDir, "SKILL.md"), confineRoot);
|
|
44
65
|
const skill = parseSkill(raw, entry.name);
|
|
45
|
-
const files = collectAttachments(skillDir, "");
|
|
66
|
+
const files = collectAttachments(skillDir, "", confineRoot);
|
|
46
67
|
if (files.length > 0)
|
|
47
68
|
skill.spec.files = files;
|
|
48
69
|
push(skill, join(skillDir, "SKILL.md"));
|
|
@@ -53,7 +74,7 @@ export function loadSkills(dir) {
|
|
|
53
74
|
/** 目录形态附件:递归收 SKILL.md 之外的一切文件,path=相对 skill 目录(posix 斜杠),sort 稳定。
|
|
54
75
|
* dotfile/dot 目录跳过(交叉评审 M3):skill 目录若从别的 checkout 整拷,.git/config、.env 类隐藏物
|
|
55
76
|
* 会连凭据一起进 spec.files 变成提示词可见附件——附件只收显式内容文件。symlink 跟链接(同扁平兼容)。 */
|
|
56
|
-
function collectAttachments(skillDir, rel) {
|
|
77
|
+
function collectAttachments(skillDir, rel, confineRoot) {
|
|
57
78
|
const files = [];
|
|
58
79
|
const here = rel === "" ? skillDir : join(skillDir, rel);
|
|
59
80
|
for (const entry of readdirSync(here, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
@@ -64,9 +85,13 @@ function collectAttachments(skillDir, rel) {
|
|
|
64
85
|
const dirLike = entry.isDirectory() || (entry.isSymbolicLink() && statSync(abs).isDirectory());
|
|
65
86
|
const fileLike = entry.isFile() || (entry.isSymbolicLink() && statSync(abs).isFile());
|
|
66
87
|
if (dirLike) {
|
|
67
|
-
|
|
88
|
+
if (confineRoot !== undefined)
|
|
89
|
+
assertConfined(abs, confineRoot);
|
|
90
|
+
files.push(...collectAttachments(skillDir, relPath, confineRoot));
|
|
68
91
|
}
|
|
69
92
|
else if (fileLike && relPath !== "SKILL.md") {
|
|
93
|
+
if (confineRoot !== undefined)
|
|
94
|
+
assertConfined(abs, confineRoot);
|
|
70
95
|
files.push({ path: relPath, content: readFileSync(abs, "utf8") });
|
|
71
96
|
}
|
|
72
97
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -25,6 +25,18 @@ export declare function numEnv(name: string, fallback: string): number;
|
|
|
25
25
|
* `undefined`/空串 ⇒ 交给调用方的 fallback 语义处理(本函数只判「给了值但不是数」)。
|
|
26
26
|
*/
|
|
27
27
|
export declare function parseNumOrFail(name: string, raw: string | undefined): number;
|
|
28
|
+
/**
|
|
29
|
+
* `parseNumOrFail` layered with a non-negative floor — the SAME judgment `leader/wire.ts`'s (private,
|
|
30
|
+
* file-local) `parseNumOrFailNonNegative` already applies to its four resource knobs, exported here so a
|
|
31
|
+
* second call site (bake-runner's heartbeat/idle-poll/disk-guard knobs, gap-sweep 2026-08-01) can reuse the
|
|
32
|
+
* one shared primitive instead of re-deriving its own "reject a bad number" scheme. A negative value is
|
|
33
|
+
* JS-truthy, so a bare `|| default` fallback lets it straight through; for a duration knob that means
|
|
34
|
+
* `clock.sleep(-N)` fires ~immediately (the same tight-loop failure NaN causes), and for a threshold knob
|
|
35
|
+
* (`freeGb < minFreeGb`) a negative floor makes the comparison vacuously true/false depending on sign —
|
|
36
|
+
* either way the guard it configures goes silently slack. `undefined`/unset stays NaN (unchanged) so
|
|
37
|
+
* existing `|| fallback` chains built on `parseNumOrFail` are untouched by this stricter sibling.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseNumOrFailNonNegative(name: string, raw: string | undefined): number;
|
|
28
40
|
export declare function drainConfigWarnings(): Array<{
|
|
29
41
|
env: string;
|
|
30
42
|
raw: string;
|
package/dist/config.js
CHANGED
|
@@ -94,6 +94,23 @@ export function parseNumOrFail(name, raw) {
|
|
|
94
94
|
throw new Error(`env ${name}="${raw}" must be a number`);
|
|
95
95
|
return n;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* `parseNumOrFail` layered with a non-negative floor — the SAME judgment `leader/wire.ts`'s (private,
|
|
99
|
+
* file-local) `parseNumOrFailNonNegative` already applies to its four resource knobs, exported here so a
|
|
100
|
+
* second call site (bake-runner's heartbeat/idle-poll/disk-guard knobs, gap-sweep 2026-08-01) can reuse the
|
|
101
|
+
* one shared primitive instead of re-deriving its own "reject a bad number" scheme. A negative value is
|
|
102
|
+
* JS-truthy, so a bare `|| default` fallback lets it straight through; for a duration knob that means
|
|
103
|
+
* `clock.sleep(-N)` fires ~immediately (the same tight-loop failure NaN causes), and for a threshold knob
|
|
104
|
+
* (`freeGb < minFreeGb`) a negative floor makes the comparison vacuously true/false depending on sign —
|
|
105
|
+
* either way the guard it configures goes silently slack. `undefined`/unset stays NaN (unchanged) so
|
|
106
|
+
* existing `|| fallback` chains built on `parseNumOrFail` are untouched by this stricter sibling.
|
|
107
|
+
*/
|
|
108
|
+
export function parseNumOrFailNonNegative(name, raw) {
|
|
109
|
+
const n = parseNumOrFail(name, raw);
|
|
110
|
+
if (n < 0)
|
|
111
|
+
throw new Error(`env ${name}=${n} must not be negative`);
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
97
114
|
/** Numeric env with a [min,max] bound (BL-17): an out-of-range value FAILS at startup instead of being
|
|
98
115
|
* silently clamped/applied (e.g. a multi-hour MCP_ELICITATION_TTL_MS would silently never expire). */
|
|
99
116
|
function numEnvBounded(name, fallback, min, max) {
|
package/dist/fleet/fleet-bus.js
CHANGED
|
@@ -203,9 +203,12 @@ export function fleetRunPublisher(bus, run) {
|
|
|
203
203
|
elapsedDonor.add(cid);
|
|
204
204
|
const elapsed = elapsedDonor.has(cid) ? Date.now() - childStartedAt.get(cid) : undefined;
|
|
205
205
|
if (donorName !== undefined || elapsed !== undefined) {
|
|
206
|
+
// cli [2297]①:donorName 只进 `name` 位。run-leg tick 手上没有独立的类型名([1364]③/core 1.350:
|
|
207
|
+
// name=display 标签、agentType=类型名),把 display 值同时写进 agentType 会让 fleet 的 TYPE 列显示
|
|
208
|
+
// description。缺源的键不铸(与「匿名行不铸假名」同一条原则);BCE 行自己的帧带真 agentType 时自然补上。
|
|
206
209
|
bus.publishTask({
|
|
207
210
|
id: bceRowId,
|
|
208
|
-
...(donorName !== undefined ? { name: donorName
|
|
211
|
+
...(donorName !== undefined ? { name: donorName } : {}),
|
|
209
212
|
...(elapsed !== undefined ? { elapsedMs: elapsed } : {}),
|
|
210
213
|
});
|
|
211
214
|
}
|
package/dist/hooks/hook-llm.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ModelKeyRef } from "../key-resolver.js";
|
|
2
2
|
import type { ServiceConfig } from "../config-types.js";
|
|
3
3
|
import type { HookLlmCall } from "./hook-runner.js";
|
|
4
4
|
/**
|
|
@@ -32,7 +32,7 @@ export interface HookLlm {
|
|
|
32
32
|
export declare function createHookLlm(deps: {
|
|
33
33
|
config: ServiceConfig;
|
|
34
34
|
/** 与 brain 同一 hot-refreshed per-model key 面(main.ts 热应用会整个换引用,故取函数不取值)。 */
|
|
35
|
-
getKeyResolver: () => ((model:
|
|
35
|
+
getKeyResolver: () => ((model: ModelKeyRef) => Promise<{
|
|
36
36
|
apiKey: string;
|
|
37
37
|
} | undefined>) | undefined;
|
|
38
38
|
metrics: {
|
package/dist/hooks/hook-llm.js
CHANGED
|
@@ -74,7 +74,7 @@ export function createHookLlm(deps) {
|
|
|
74
74
|
let perModelKey;
|
|
75
75
|
if (keyResolver) {
|
|
76
76
|
try {
|
|
77
|
-
perModelKey = (await keyResolver({ name: pick.catalogRef }))?.apiKey;
|
|
77
|
+
perModelKey = (await keyResolver({ name: pick.catalogRef }))?.apiKey; // ModelKeyRef: legal mint, no cast (B1)
|
|
78
78
|
}
|
|
79
79
|
catch (e) {
|
|
80
80
|
return { ok: false, error: `per-model key resolution for "${pick.catalogRef}" failed — refusing to fall back to the shared route credential (${String(e)})` };
|
|
@@ -160,7 +160,7 @@ export function createHookLlm(deps) {
|
|
|
160
160
|
text = j.choices?.[0]?.message?.content ?? undefined;
|
|
161
161
|
}
|
|
162
162
|
metrics.inc("hook_llm_calls_total", { type: "prompt" });
|
|
163
|
-
return typeof text === "string" ? { ok: true, text } : { ok: false, error: "hook llm returned no content" };
|
|
163
|
+
return typeof text === "string" ? { ok: true, text } : { ok: false, error: "hook llm returned no content", code: "no_content" };
|
|
164
164
|
}
|
|
165
165
|
catch (e) {
|
|
166
166
|
// 网络层失败原样只有 "TypeError: fetch failed"(不是人话):带上端点与 cause(ECONNREFUSED 等),
|
|
@@ -91,7 +91,11 @@ export type HookLlmCall = (opts: {
|
|
|
91
91
|
} | {
|
|
92
92
|
ok: false;
|
|
93
93
|
error: string;
|
|
94
|
+
code?: HookLlmFailureCode;
|
|
94
95
|
}>;
|
|
96
|
+
/** 失败判别码(B8:判别一律走码,禁按 error 文案分支——v3.1 批2,统检第二波 high)。error 仍是给人看的
|
|
97
|
+
* 自由文本;code 是给控制流的。缺席=未分类失败(不重试、不特判)。 */
|
|
98
|
+
export type HookLlmFailureCode = "no_content" | "hard_timeout";
|
|
95
99
|
/**
|
|
96
100
|
* 阶段三a:`http` 条目——契约语义 = POST hook 输入 JSON 到 `url`;headers 里的 `$NAME` 仅当 NAME 列在
|
|
97
101
|
* `allowedEnvVars` 才从 worker 进程 env 插值(配置本身绝不携带密钥值,契约同边界)。
|
|
@@ -383,7 +383,7 @@ async function runLlmHook(entry, payload, ctx, extra) {
|
|
|
383
383
|
// 顺序是承重的 —— 包装句逐字预设会话在上文,放反了那句话本身就是在骗模型。
|
|
384
384
|
const prompt = extra ? `${extra.transcript}\n\n---\n\n${wrapCondition("Stop", substituted)}` : substituted;
|
|
385
385
|
// 载体外再包一层硬顶:契约说载体自己兜超时,但一个部署组装 bug 不该能挂死工具门(纵深)。
|
|
386
|
-
const hardTop = new Promise((r) => setTimeout(() => r({ ok: false, error: "
|
|
386
|
+
const hardTop = new Promise((r) => setTimeout(() => r({ ok: false, error: "hook llm hard-timeout backstop fired (carrier did not settle within timeoutMs+5s — deployment assembly bug)", code: "hard_timeout" }), timeoutMs + 5_000).unref?.());
|
|
387
387
|
const invoke = () => Promise.race([
|
|
388
388
|
call({
|
|
389
389
|
prompt,
|
|
@@ -397,17 +397,17 @@ async function runLlmHook(entry, payload, ctx, extra) {
|
|
|
397
397
|
// 🔴 **"一个字都没吐出来"重试一次**(仅 CC 评估者路)。这个失败形的后果是**判词被丢弃 ⇒ fail-open**
|
|
398
398
|
// (该拦没拦),而它是**瞬态**的(推理档模型偶尔把额度用在 thinking 上)。
|
|
399
399
|
// ⚠️ 只重试**无内容**,不重试"有内容但读不懂" —— 后者重试一次多半还是读不懂,而且那一格按设计就该放行。
|
|
400
|
-
if (extra && !res.ok && res.
|
|
400
|
+
if (extra && !res.ok && res.code === "no_content") { // B8:判别走码不走文案(v3.1 批2)
|
|
401
401
|
ctx.logger.warn("hook_llm_no_content_retry", { event: "Stop" });
|
|
402
402
|
res = await invoke();
|
|
403
403
|
// 重试之后**仍然**没有内容 ⇒ 这一轮的守卫确实没能评估。发观测帧(纯 observe,不改变运行)——
|
|
404
404
|
// 方向仍是 fail-open,但用户/壳侧要能知道「这轮没看住」,否则那个放行与「已达成」无法区分。
|
|
405
|
-
if (!res.ok && res.
|
|
405
|
+
if (!res.ok && res.code === "no_content") {
|
|
406
406
|
ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "no_content", detail: "carrier returned no content (after one retry)" });
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
if (!res.ok) {
|
|
410
|
-
return res.
|
|
410
|
+
return res.code === "hard_timeout"
|
|
411
411
|
? { code: null, stdout: "", stderr: "", timedOut: true }
|
|
412
412
|
: { code: null, stdout: "", stderr: "", timedOut: false, spawnError: clip(res.error, 500) };
|
|
413
413
|
}
|
|
@@ -14,7 +14,10 @@ export declare function bearerPresentedButUnverified(req: IncomingMessage, confi
|
|
|
14
14
|
export declare function gatedPrincipal(req: IncomingMessage, config: ServiceConfig): string | undefined;
|
|
15
15
|
/** The EXPLICIT operator gate (IMAGE-API-DESIGN.md §P2.4a MUST-FIX) — NOT the bare `isOperator` (whose empty-list
|
|
16
16
|
* "true-for-all" is a world-writable RCE door the instant OPERATOR_PRINCIPALS is empty). Requires a non-empty
|
|
17
|
-
* operator set AND a present principal IN it. The same form the preempt/resume endpoints already use.
|
|
17
|
+
* operator set AND a present principal IN it. The same form the preempt/resume endpoints already use.
|
|
18
|
+
* 🔴 SINGLE mint point (E1): a byte-identical twin `explicitOperator` used to live below (image-visibility lane)
|
|
19
|
+
* and two more hand-inlined copies sat in approvals-assistant — a future hardening of this judgment would have
|
|
20
|
+
* missed them. All call sites now consume THIS function; do not re-introduce a sibling. */
|
|
18
21
|
export declare function explicitOperatorOk(principal: string | undefined, operatorPrincipals: readonly string[]): boolean;
|
|
19
22
|
/** May `principal` act as an OPERATOR on the F4 approval queue? Empty `operatorPrincipals` = legacy
|
|
20
23
|
* behavior (the shared service token IS the operator boundary → everyone authenticated is an operator).
|
|
@@ -24,6 +27,7 @@ export declare function isOperator(principal: string | undefined, operatorPrinci
|
|
|
24
27
|
* yields `false` (no one is an operator). Use this where empty-operators-means-all would be a SECURITY hole rather
|
|
25
28
|
* than a back-compat convenience: image VISIBILITY scoping (a tenant-scoped image must not leak to every caller
|
|
26
29
|
* just because OPERATOR_PRINCIPALS is unset). Adversarial-review HIGH: `isOperator([], p)=true` made
|
|
27
|
-
* `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment.
|
|
28
|
-
|
|
30
|
+
* `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment.
|
|
31
|
+
* (The byte-identical `explicitOperator` twin that used to live here was folded into `explicitOperatorOk` above —
|
|
32
|
+
* one judgment, one mint point.) */
|
|
29
33
|
//# sourceMappingURL=principal-gate.d.ts.map
|