@sema-agent/server 7.21.0-rc.1 → 7.22.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.
@@ -30,7 +30,6 @@ export declare function createBudgetAndTracing(ctx: BudgetTracingCtx): {
30
30
  tracer: import("@sema-agent/core").TracerHook;
31
31
  sideQueryAccounting: (principal: string | undefined, r: {
32
32
  model: string;
33
- family?: "input-includes-cached" | "input-excludes-cached";
34
33
  usage?: {
35
34
  input?: number;
36
35
  output?: number;
@@ -432,7 +432,9 @@ export function createResolveSpec(ctx) {
432
432
  // 的 auth 从 checkpoint 重建、无此字段 → 回落持久化 body.projectId(原始提交已过形状门,与 memoryWrite
433
433
  // 的 rides-the-persisted-body 姿势一致),让种子在 resume 腿等价重放。查表 miss(未登记/已下架)= 无种子,
434
434
  // 不 fail(登记簿是加性配置面,不是准入门)。
435
- const s4ProjectId = auth?.resolvedProjectId ?? (typeof body.projectId === "string" && body.projectId ? body.projectId : undefined);
435
+ // A-033.1(#256):回落腿同样折小写 —— authorizer 那一格自 core `/i` 词法起收下大写形并归一,
436
+ // 若这条回落腿原样传,同一个 projectId 在首提交与 resume 两腿会查两个不同的 `config.projects` 键。
437
+ const s4ProjectId = auth?.resolvedProjectId ?? (typeof body.projectId === "string" && body.projectId ? body.projectId.toLowerCase() : undefined);
436
438
  const s4DefaultScopes = s4ProjectId ? config.projects[s4ProjectId]?.defaultScopes : undefined;
437
439
  return { resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes };
438
440
  };
package/dist/budget.d.ts CHANGED
@@ -102,19 +102,8 @@ export declare function createTracer(metrics: Pick<Metrics, "inc" | "observe">,
102
102
  * (保守,绝不少收);根修=core 在 SideQueryResult 上回权威 costMicroUsd(已提货 core)。model 标签/
103
103
  * weight 键用**路由身份 r.model**(池配置有界)非 servedModel(provider 上报,无界)。
104
104
  */
105
- /** core `usage-accounting.cacheFamilyOf` 的私拷镜像(core exports map 只开 barrel,该函数未导出——
106
- * 已提货 core barrel 导出,到货即换,normalizeAgentName 同款纪律)。逐字对拍源:
107
- * dist/core/runner/usage-accounting.js(override 别名 anthropic/openai + api 判 anthropic-messages/
108
- * bedrock-converse-stream)。undefined 入参(catalog 查不到)→ undefined,调用方走保守臂。 */
109
- export declare function cacheFamilyOfMirror(model: {
110
- api?: string;
111
- params?: {
112
- promptCacheFamily?: string;
113
- };
114
- } | undefined): "input-includes-cached" | "input-excludes-cached" | undefined;
115
105
  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: {
116
106
  model: string;
117
- family?: "input-includes-cached" | "input-excludes-cached";
118
107
  usage?: {
119
108
  input?: number;
120
109
  output?: number;
package/dist/budget.js CHANGED
@@ -341,20 +341,6 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
341
341
  * (保守,绝不少收);根修=core 在 SideQueryResult 上回权威 costMicroUsd(已提货 core)。model 标签/
342
342
  * weight 键用**路由身份 r.model**(池配置有界)非 servedModel(provider 上报,无界)。
343
343
  */
344
- /** core `usage-accounting.cacheFamilyOf` 的私拷镜像(core exports map 只开 barrel,该函数未导出——
345
- * 已提货 core barrel 导出,到货即换,normalizeAgentName 同款纪律)。逐字对拍源:
346
- * dist/core/runner/usage-accounting.js(override 别名 anthropic/openai + api 判 anthropic-messages/
347
- * bedrock-converse-stream)。undefined 入参(catalog 查不到)→ undefined,调用方走保守臂。 */
348
- export function cacheFamilyOfMirror(model) {
349
- if (!model)
350
- return undefined;
351
- const o = model.params?.promptCacheFamily;
352
- if (o === "input-excludes-cached" || o === "anthropic")
353
- return "input-excludes-cached";
354
- if (o === "input-includes-cached" || o === "openai")
355
- return "input-includes-cached";
356
- return model.api === "anthropic-messages" || model.api === "bedrock-converse-stream" ? "input-excludes-cached" : "input-includes-cached";
357
- }
358
344
  export function createSideQueryAccountant(metrics, costQuota,
359
345
  // 依赖面收窄到真消费的方法(§X 同 ModelKeyRef 一式):全形对象照传(结构超集),测试桩零铸型。
360
346
  fleetUsage, fleetLease, quotaWeightFor) {
@@ -363,14 +349,16 @@ fleetUsage, fleetLease, quotaWeightFor) {
363
349
  const output = typeof r.usage?.output === "number" ? r.usage.output : 0;
364
350
  const cacheRead = typeof r.usage?.cacheRead === "number" ? r.usage.cacheRead : 0;
365
351
  const cacheWrite = typeof r.usage?.cacheWrite === "number" ? r.usage.cacheWrite : 0;
366
- // codex R4 high:prompt tokens 必须按 cache family 折算(core promptTokensOf 同式)——Anthropic 族
367
- // (input-excludes-cached)的 cache 命中几乎全部在 input 之外,不加 cacheRead/cacheWrite 就是配额轴
368
- // 大幅少收(lease 可被 cache-heavy side query 绕穿)。family 未知(catalog 查不到)走**保守臂**
369
- // (含 cache——OpenAI 族此臂多收,方向与 cost 残差一致:宁多勿少,记档同一条)。
370
- // core 3.0.0 核实(S1 提货站):此段的输入源是 `SideQueryResult.usage`(llm 层生料 `Usage`,
371
- // engine/llm/types.d.ts)——不是 3.0.0 翻新过的 `brain.call`/`turn_end` trace 事件面,3.0.0 未动这个类型,
372
- // 本折算继续承重(零改动)。
373
- const promptTokens = r.family === "input-includes-cached" ? input : input + cacheRead + cacheWrite;
352
+ // #257(codex #256-R3 high 定谳):core side-query 出口已把 `usage.input` **归一为 uncached 量**
353
+ // ——对每个 provider 族(side-query.d.ts:「`input` NORMALIZED to the uncached prompt quantity …
354
+ // for EVERY provider family」,与 run loop usage-accounting 同一道门)。所以配额轴折算恒为
355
+ // input + cacheRead + cacheWrite,**不再按 cache family 分臂**:曾经的
356
+ // `family === "input-includes-cached" ? input : …` 分支建立在「OpenAI 族 input 仍含 cache」这个
357
+ // 已被 core 出口归一推翻的前提上,每次 cache 命中都把 OpenAI 族配额/lease 少收 cacheRead+cacheWrite
358
+ // (红先钉 = budget.test「#257 OpenAI 族 cache 命中不许少计」格)。family 判定链
359
+ // (cacheFamilyOfMirror + catalog 快照)随本修整体退役——LEDGER cacheFamilyOf/CacheFamily
360
+ // 两行同步改 skip(消费点消失,理由=core 出口归一)。
361
+ const promptTokens = input + cacheRead + cacheWrite;
374
362
  // RB-368/#59 同判据(createTracer 上方注释同款纪律):`usage.cost.total` 整键缺席 = 未知(unpriced 部署的
375
363
  // side query 真形),不是「花了 $0」——折 0 就是把「不知道」编造成「免费」。已知(哪怕显式 0)才带键。
376
364
  const costTotal = typeof r.usage?.cost?.total === "number" ? r.usage.cost.total : undefined;
@@ -1,4 +1,4 @@
1
- import { CODE_AGENT_PROMPT, CODE_SYSTEM_PROMPT, DEFAULT_SYSTEM_PROMPT, assembleCodeTools, defaultTaskRegistry } from "@sema-agent/core";
1
+ import { CODE_AGENT_PROMPT, CODE_SYSTEM_PROMPT, DEFAULT_SYSTEM_PROMPT, WEB_FETCH_TOOL_NAME, assembleCodeTools, defaultTaskRegistry } from "@sema-agent/core";
2
2
  import { HttpError } from "../security.js";
3
3
  import { nowTool } from "./builtin-tools.js";
4
4
  import { parseRepo, repoToolsFor } from "./repo-tools.js";
@@ -164,7 +164,10 @@ export function buildScenarios(deps) {
164
164
  : {}),
165
165
  },
166
166
  ...(webSearch ? { webSearch } : {}),
167
- }).filter((t) => t.name !== "WebFetch" || deps.requirePrincipal !== true),
167
+ // A-033.1(#256):工具名取 core `WEB_FETCH_TOOL_NAME` 而不是裸串 —— 这是**多租户闸**
168
+ // (allowHosts 未设 = 全公网可达 ⇒ 一个租户能把另一个租户的内网可达面当出口)。core 改名该工具时,
169
+ // 裸串比对恒不相等,闸门整条不生效且零症状:名单里照样挂着一只全公网 WebFetch。
170
+ }).filter((t) => t.name !== WEB_FETCH_TOOL_NAME || deps.requirePrincipal !== true),
168
171
  ],
169
172
  // default/code 是手带并集的**故意消费者**(上方 rank-1 BLOCKER 注:CC agent loop MUST have the
170
173
  // full-body roster)——#196 判别位在这里表态 full,收窄只落在声明了 none 的场景上。
@@ -4,9 +4,14 @@ import type { RunRecord } from "../plugins/store-contracts.js";
4
4
  * `onChildTerminal`(fleet「subagent 树」只渲 agent 子代)。`background_bash`/`monitor`/`external`
5
5
  * 不属 agent fleet 树——它们此前每条都打,fleet-bus 的「无 tick 无 claim」臂给 b\* 与 m\* handle 合成
6
6
  * agent 形瞬态终帧,壳终态留存池把这帧渲 60s = footer 幽灵子行;monitor 的 `status:"event"` 还被
7
- * 终态映射折成 `failed`。**两侧都正向枚举**(新 task_type/新 status 默认不铸行=安全方向)。
8
- * 与 core `isDelegatedAgentTerminal`(task-notification.js,未从包根导出)语义同源——core 根导出后
9
- * 换装 import 删本镜像(提货件已上板) */
7
+ * 终态映射折成 `failed`。
8
+ *
9
+ * 🔴 A-033.1(#256):判别**本体已换 core 的 {@link isDelegatedAgentTerminal}**。此前这里是一份手写语义
10
+ * 孪生(自己枚举 `completed|failed|killed|cancelled`),头注自陈「与 core 语义同源——core 根导出后换装
11
+ * import 删本镜像」。core 现已从包根导出(`index.d.ts:109`),且它的终态集与 pending 店的驱逐偏好共用
12
+ * 同一份 `TERMINAL_STATUSES`(core 原话:「so "terminal" can never mean two things in this module」)——
13
+ * 孪生留在这里等于让 fleet 面板对「终态」的理解可以和引擎分家。本函数保留为**具名的 fleet 语义位**
14
+ * (调用点读的是「这条通知该不该打 fleet 的 onChildTerminal」,不是「core 怎么判终态」)。 */
10
15
  export declare function isFleetAgentTerminalNotification(n: Pick<TaskNotificationPayload, "task_type" | "status">): boolean;
11
16
  /** The fleet vocabulary the shell renders (MF-Fleet `FleetTask.status`). The service maps its run/workflow status
12
17
  * onto this neutral set; `awaiting approval` = a needs-review/plan-approval park, `waiting` = a durable suspend.
@@ -21,18 +21,23 @@
21
21
  * replica-local). A cross-replica fleet roll-up is a fleet-token-gated trace-API concern (separate), not this.
22
22
  */
23
23
  import { EventEmitter } from "node:events";
24
- import { deriveAgentDisplayStatus } from "@sema-agent/core";
24
+ import { deriveAgentDisplayStatus, isDelegatedAgentTerminal } from "@sema-agent/core";
25
25
  import { recordFailOpen } from "../observability/fail-open.js";
26
26
  import { redactSecrets } from "../trace/redact.js";
27
27
  /** [2687-cli] 幽灵行案的单源判别:一条 `task_notification` 只有在 **agent 族 × 终态** 时才允许打
28
28
  * `onChildTerminal`(fleet「subagent 树」只渲 agent 子代)。`background_bash`/`monitor`/`external`
29
29
  * 不属 agent fleet 树——它们此前每条都打,fleet-bus 的「无 tick 无 claim」臂给 b\* 与 m\* handle 合成
30
30
  * agent 形瞬态终帧,壳终态留存池把这帧渲 60s = footer 幽灵子行;monitor 的 `status:"event"` 还被
31
- * 终态映射折成 `failed`。**两侧都正向枚举**(新 task_type/新 status 默认不铸行=安全方向)。
32
- * 与 core `isDelegatedAgentTerminal`(task-notification.js,未从包根导出)语义同源——core 根导出后
33
- * 换装 import 删本镜像(提货件已上板) */
31
+ * 终态映射折成 `failed`。
32
+ *
33
+ * 🔴 A-033.1(#256):判别**本体已换 core 的 {@link isDelegatedAgentTerminal}**。此前这里是一份手写语义
34
+ * 孪生(自己枚举 `completed|failed|killed|cancelled`),头注自陈「与 core 语义同源——core 根导出后换装
35
+ * import 删本镜像」。core 现已从包根导出(`index.d.ts:109`),且它的终态集与 pending 店的驱逐偏好共用
36
+ * 同一份 `TERMINAL_STATUSES`(core 原话:「so "terminal" can never mean two things in this module」)——
37
+ * 孪生留在这里等于让 fleet 面板对「终态」的理解可以和引擎分家。本函数保留为**具名的 fleet 语义位**
38
+ * (调用点读的是「这条通知该不该打 fleet 的 onChildTerminal」,不是「core 怎么判终态」)。 */
34
39
  export function isFleetAgentTerminalNotification(n) {
35
- return n.task_type === "background_agent" && (n.status === "completed" || n.status === "failed" || n.status === "killed" || n.status === "cancelled");
40
+ return isDelegatedAgentTerminal(n);
36
41
  }
37
42
  /**
38
43
  * durable {@link WorkflowRun} → 一条 fleet workflow 行的**纯投影**(`build*`=纯数据,无行为、不发布)。
@@ -1,7 +1,6 @@
1
1
  import { expandTiers, isThinkingLevel } from "@sema-agent/core";
2
2
  import { HttpError } from "../../security.js";
3
3
  import { isModelAllowlisted } from "../../model-select.js";
4
- import { cacheFamilyOfMirror } from "../../budget.js";
5
4
  import { redactSecrets } from "../../trace/redact.js";
6
5
  import { sendJson, sendError, httpErrorCode, msg } from "../send.js";
7
6
  import { gatedPrincipal } from "../principal-gate.js";
@@ -162,24 +161,16 @@ async function handleSideQueryBody(req, res, url, ctx, miss) {
162
161
  signal: ac.signal,
163
162
  };
164
163
  try {
165
- // codex R6:catalog 快照必须取在 brain 调用**之前**(三字段值拷贝,防热刷新原位替换)——请求在
166
- // 版本 A 下发、版本 B 下完账会把 A 产生的 cache 用量按 B 的 family 折算(同 id 换族=配额轴少收)。
167
- const catalogSnapshot = Object.values(deps.runner.agentCatalog?.models ?? {}).map((m) => ({ id: m?.id, api: m?.api, params: m?.params?.promptCacheFamily ? { promptCacheFamily: m.params.promptCacheFamily } : undefined }));
168
164
  const result = await deps.runner.sideQuery(spec);
169
165
  // 记账(codex R2 high→R3 收敛):走与 createTracer 同源装配的四路 sink seam(costQuota+指标+
170
166
  // fleetUsage 批报+fleetLease 本地扣减)——sideQuery 不发 brain.call,tracer 链不经过这里。
171
167
  // usageMissing/无 cost 面=0 记账,诚实缺席。
168
+ // #257:曾经这里在请求前拍 catalog 快照、按 Model 判 cache family 传给记账口(codex R4-R6 三轮
169
+ // 打磨的判定链)——整链随 #257 退役:core 在 side-query 出口已把 `usage.input` 归一为 uncached 量
170
+ // (每个 provider 族,side-query.d.ts 成文),折算恒 input+cacheRead+cacheWrite,family 不再是记账输入。
172
171
  const rr = result;
173
172
  const modelId = typeof rr.model === "string" ? rr.model : "unknown";
174
- // cache family 取自路由身份在 catalog 里的 Model(codex R4:Anthropic prompt tokens 在 input
175
- // 之外——family 决定配额轴折算)。codex R5:result.model=**Model.id**,而 agentCatalog.models
176
- // 按 catalog ref(池名)做键——必须按 id 扫值域,不能按键取(registry 车道 name≠id,键取恒 miss
177
- // →OpenAI 族全走保守臂多收)。同 id 多条且 family 相斥=当 unknown(保守臂);查不到=undefined。
178
- // codex R6:扫的是**请求前快照**(见上),完账口径钉在发起时的模型态。
179
- const byId = catalogSnapshot.filter((m) => m.id === modelId);
180
- const families = new Set(byId.map((m) => cacheFamilyOfMirror(m)));
181
- const family = families.size === 1 ? [...families][0] : undefined;
182
- deps.sideQueryAccounting?.(principal, { model: modelId, ...(family ? { family } : {}), usage: rr.usage });
173
+ deps.sideQueryAccounting?.(principal, { model: modelId, usage: rr.usage });
183
174
  deps.metrics?.inc?.("side_query_total", { result: "ok" });
184
175
  if (!res.writableEnded)
185
176
  sendJson(res, 200, result);
@@ -39,9 +39,10 @@ export interface RequestAuth {
39
39
  principal?: string;
40
40
  sessionId: string;
41
41
  memoryScope?: string;
42
- /** 142-S4:the SHAPE-VALIDATED `body.projectId`(authorizer 的 PROJECT_ID_REGEX 门通过后回传)。
43
- * main.ts 拿它查 config.projects 的 defaultScopes(memory.scopes 种子)。resume 腿(checkpoint 重建
44
- * auth)缺席 — main.ts 回落持久化 body.projectId(原始提交已验形)。 */
42
+ /** 142-S4:the SHAPE-VALIDATED **且已归一(小写)** 的 `body.projectId`(authorizer 的 core
43
+ * `PROJECT_ID_REGEX` 门通过后折小写回传;A-033.1/#256 宽读严写)。main.ts 拿它查 config.projects 的
44
+ * defaultScopes(memory.scopes 种子)。resume 腿(checkpoint 重建 auth)缺席 — 回落持久化的
45
+ * body.projectId(原始提交已验形),那条回落腿自己再折一次小写,两腿查同一个键。 */
45
46
  resolvedProjectId?: string;
46
47
  }
47
48
  /**
@@ -1,7 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { once } from "node:events";
3
3
  import { createHash } from "node:crypto";
4
- import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow, GLOBAL_USAGE_KEY, usageRetryAfterMs } from "@sema-agent/core";
4
+ import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow, GLOBAL_USAGE_KEY, usageRetryAfterMs, RUN_WORKFLOW_TOOL_NAME } from "@sema-agent/core";
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
6
  import { isModelAllowlisted, matchCatalogModel } from "../model-select.js";
7
7
  import {} from "../config-center/facade.js";
@@ -1560,8 +1560,11 @@ export function createHttpServer(rawDeps) {
1560
1560
  // posture as the hand-tools union above (a per-principal allowWorkflows=false revoked between suspend and
1561
1561
  // approve slips this pre-check; core's own resume path then fails loudly — the pre-check is a UX guard,
1562
1562
  // not the authority).
1563
+ // A-033.1(#256):名字从 core 的 `RUN_WORKFLOW_TOOL_NAME` 取,不再手抄 —— 手抄的名字在 core 改名后
1564
+ // 与实挂名分家,这一格会把**每一次**已批准的 workflow 赎回打成 422(pendingTool 是实挂名,集合里
1565
+ // 却是旧名),而 task-settings 那三处同族手抄的后果更重(ask/deny 门整条静默不触发)。
1563
1566
  if (spec.selfOrchestration === true && deps.config.selfOrchestrationEnabled) {
1564
- availableTools.add("Workflow");
1567
+ availableTools.add(RUN_WORKFLOW_TOOL_NAME);
1565
1568
  }
1566
1569
  if (decision === "approve" && pendingToolCanon && !availableTools.has(pendingToolCanon)) {
1567
1570
  return {
@@ -1948,6 +1951,26 @@ export function createHttpServer(rawDeps) {
1948
1951
  if (rsV && taskId)
1949
1952
  verifySink.onForwardEvent(e);
1950
1953
  },
1954
+ // 🔴 **#255(core 5.34.0 #253 / 判据帖 [3967] G1):status 第五席**。这条臂没有事件循环 ——
1955
+ // 旁边 resumeStream 臂的顶层 status 走它自己那条 for-await 的 `case "status"`(driveResume
1956
+ // switch),本臂在 5.33 时代整段重试期是**黑的**(座位那时还不存在)。core 5.34.0 的
1957
+ // `statusEmit` 双写(queue 照旧 + 座位喂**同一个** frozen 帧)把这条腿补齐,这里接的是
1958
+ // **同一条**投影链:`brain_retry_total` 计数 + 共享 `verifySink` 的白名单 builder/单写串行链。
1959
+ // ⚠️ 帧 frozen ⇒ 这条链上不许写帧字段;同步回调不能 await ⇒ fire-and-forget。下面那个
1960
+ // `.catch` 买的是**留痕**不是进程安全:`appendStatus` 返回的链步在 `LedgerSink.append` 内部
1961
+ // 已被挂上处理器(「失败不断链」)⇒ 没有逃逸的 rejection(实测:摘掉它 §4⑥b 仍绿);
1962
+ // 判别力由 §4⑥b 的「诊断行在场」断言看守(与 `src/runs.ts` 的 `statusSeat` 同款注)。
1963
+ // ⚠️ 与 `src/runs.ts` 的 `statusSeat` 同一条纪律:**只**挂在「只回结果」的这条臂上,
1964
+ // 旁边 resumeStream 臂那只 internals 不带它(带了就是同一帧两行账本 + 计数翻倍)。
1965
+ // 🔴 走 sink 的 **`appendStatus` 同步入链门**,不是 `onEvent` —— 后者先 `await flush()`
1966
+ // 才入链,同一拍后到的 `onForwardEvent`(同步入链)会抢到更小的序号,账本时间线倒挂
1967
+ // (codex 对抗复审 R2 [medium],红先复现后修;本臂与 `src/runs.ts` 的 `statusSeat` 同病同修)。
1968
+ onStatusEvent: (e) => {
1969
+ deps.metrics?.inc("brain_retry_total", { phase: String(e.phase) });
1970
+ if (rsV && taskId) {
1971
+ void verifySink.appendStatus(e).catch((err) => deps.logger?.warn?.("status_event_append_failed", { route: "resume-verify", taskId, err: err instanceof Error ? err.message : String(err) }));
1972
+ }
1973
+ },
1951
1974
  // bg 子代终态:与 resumeStream 臂逐字同款(理由全在那份孪生注里)。
1952
1975
  onTaskNotification: (n) => {
1953
1976
  if (n.task_type === "workflow")
@@ -297,7 +297,9 @@ export interface TaskRequestBody {
297
297
  /** 142-S4 projectId 线程化(design/142 §2):这个 run 归属的项目(center 登记簿键,generic lowercase
298
298
  * UUID — core S1 `resolveProjectId` marker 或 S3 无仓 mint 的产物;客户端只透传,零铸造权威)。是「哪个
299
299
  * 项目」的选择器,不是身份/capability — scope 的 tenant 段永远来自 verified principal。SHAPE 门在
300
- * authorizer(PROJECT_ID_REGEX,不合形状=422 typed `invalid_project_id`)。多租户 DB 记忆面下它把派生
300
+ * authorizer(core `PROJECT_ID_REGEX`,不合形状=422 typed `invalid_project_id`)。**大小写不敏感收、
301
+ * 归一小写用**(A-033.1/#256:词法与 core 的格式单源对齐,带 /i;收下后折小写再派生 scope/查表 ——
302
+ * 同一个 id 的两种大小写写法落同一只记忆盘)。多租户 DB 记忆面下它把派生
301
303
  * scope 从 user 盘切到项目盘(`proj:<tenant>/<projectId>`)并以 config.projects[projectId].defaultScopes
302
304
  * 作 memory.scopes 种子;单用户部署忽略派生(memoryScopeFor 不变),仅 defaultScopes 种子生效。
303
305
  * Rides the persisted body onto resume legs(与 memoryWrite 同姿势)。 */
@@ -66,6 +66,15 @@ export interface E2bLspManagerOptions {
66
66
  inc(name: string, labels?: Record<string, string>): void;
67
67
  };
68
68
  }
69
+ /**
70
+ * §DESIGN-V2 F1/F8/F9 —— 本文件曾**手抄**一份 core 的 `settleOnAbort`(注释自陈 core「un-exported」,并写下
71
+ * 「一旦 core 导出即 find-and-delete」的计划)。A-033.1(#256)执行了那句话:core 现已从包根导出它
72
+ * (`index.d.ts:27`,来自 `core/lsp.js`),手抄件遂删除,本文件的两处调用直接消费 core 的实现。
73
+ *
74
+ * 它的语义(读点在 `open` 的两处调用):给**这一个**调用方一个自己的 settle —— 调用方的 `signal` 让它拿到
75
+ * `onAbort()` 的返回,而**共享作业本身不受打扰**(别的调用方可能还等着它)。铁律仍在:绝不能把 signal 穿进
76
+ * 作业里(F9:共享的 open/heal 链不接受 per-caller signal,见 `open` 的文档)。
77
+ */
69
78
  export declare class E2bLspManager implements LspServerManager {
70
79
  /** Per-sandbox session cache: env (weak) → language → the (self-contained, see `startJob`) shared job. */
71
80
  private readonly perEnv;
@@ -11,7 +11,7 @@
11
11
  * (§DESIGN-V2 F7: "reason 词表=模型可见面", short + stable — changing a word IS a behavior change).
12
12
  */
13
13
  import path from "node:path";
14
- import { TransportLspSession, LspDiagnosticsRegistry } from "@sema-agent/core";
14
+ import { TransportLspSession, LspDiagnosticsRegistry, settleOnAbort } from "@sema-agent/core";
15
15
  /** Duck-type check: is this ExecutionEnv an E2B adapter with the LSP surface? */
16
16
  export function isLspCapable(env) {
17
17
  const e = env;
@@ -36,32 +36,14 @@ const DEFAULT_EXT_TO_LANG = {
36
36
  ".rs": "rust",
37
37
  };
38
38
  /**
39
- * §DESIGN-V2 F1/F8/F9: literal-aligned with core's (un-exported, `dist/core/lsp.js`) `settleOnAbort` — same
40
- * signature as the PUBLIC `core/lsp.d.ts:47` declaration, so a future dedup (once core exports it) is a
41
- * find-and-delete, not a rewrite. Bounds how long *this one caller* waits for a shared job; it must NEVER be
42
- * threaded into the job itself (F9: the shared open/heal chain accepts no per-caller signal — see `open`'s
43
- * doc). `job` is assumed never to reject (every failure path here resolves to `undefined`); the `.catch` on
44
- * the already-aborted branch is defensive parity with core's version, not a behavior this file relies on.
39
+ * §DESIGN-V2 F1/F8/F9 —— 本文件曾**手抄**一份 core `settleOnAbort`(注释自陈 core「un-exported」,并写下
40
+ * 「一旦 core 导出即 find-and-delete」的计划)。A-033.1(#256)执行了那句话:core 现已从包根导出它
41
+ * (`index.d.ts:27`,来自 `core/lsp.js`),手抄件遂删除,本文件的两处调用直接消费 core 的实现。
42
+ *
43
+ * 它的语义(读点在 `open` 的两处调用):给**这一个**调用方一个自己的 settle —— 调用方的 `signal` 让它拿到
44
+ * `onAbort()` 的返回,而**共享作业本身不受打扰**(别的调用方可能还等着它)。铁律仍在:绝不能把 signal 穿进
45
+ * 作业里(F9:共享的 open/heal 链不接受 per-caller signal,见 `open` 的文档)。
45
46
  */
46
- function settleOnAbort(job, signal, onAbort) {
47
- if (!signal)
48
- return job;
49
- if (signal.aborted) {
50
- void job.catch(() => undefined);
51
- return Promise.resolve(onAbort());
52
- }
53
- return new Promise((resolve, reject) => {
54
- const onAbortEvent = () => resolve(onAbort());
55
- signal.addEventListener("abort", onAbortEvent, { once: true });
56
- job.then((v) => {
57
- signal.removeEventListener("abort", onAbortEvent);
58
- resolve(v);
59
- }, (e) => {
60
- signal.removeEventListener("abort", onAbortEvent);
61
- reject(e);
62
- });
63
- });
64
- }
65
47
  export class E2bLspManager {
66
48
  /** Per-sandbox session cache: env (weak) → language → the (self-contained, see `startJob`) shared job. */
67
49
  perEnv = new WeakMap();
package/dist/main.js CHANGED
@@ -24,6 +24,7 @@ import { createToolTracer, createPermissionDeniedMeter } from "./observability/t
24
24
  import { createRegistryJwtVerifier } from "./auth-bridge.js";
25
25
  import { createMetrics } from "./observability/metrics.js";
26
26
  import { installFailOpenRecorder } from "./observability/fail-open.js";
27
+ import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
27
28
  import { setRedactionObserver, redactSecrets } from "./trace/redact.js";
28
29
  import { RateLimiter } from "./observability/rate-limit.js";
29
30
  import { createHttpServer, explicitOperatorOk } from "./http/server.js";
@@ -118,6 +119,9 @@ async function main() {
118
119
  // 排在这里而不是更晚:兜底臂遍布装配链,越早接上,越少的 fail-open 只剩探针行没有结构化痕迹。
119
120
  // 装配前已累计的计数与已喊过的 tag 由 install 继承,不重置也不重喊。
120
121
  installFailOpenRecorder({ logger, metrics });
122
+ // A-033.3(#256):同一处接上密钥形 env 剥除的留痕面(core `scrubSecretEnv` 的 findings 收集器半场)。
123
+ // 同因排在这里:两条剥除腿(host shell 继承 env / project-memory 的 git 子进程)都在没有观测句柄的深处。
124
+ installSecretEnvScrubRecorder({ logger, metrics });
121
125
  // S20 (SILENT-FALLBACK P1) + [792]④ boot notices: config parsing runs before the logger exists — drain both
122
126
  // pre-logger collectors via the shared helper (F10: run-local uses the same one, so entrypoints can't drift).
123
127
  logConfigDiagnostics(logger);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A-033.3(#256)—— **密钥形 env 剥除的留痕面**(core D1 `scrubSecretEnv` 的可观测半场)。
3
+ *
4
+ * ## 它解决的问题
5
+ * `scrubSecretEnv` 是本仓两条 shell/子进程腿的**安全默认**:模型可驱动的 shell(`inheritEnv:"scrub"`)与
6
+ * git 子进程(钩子/凭证助手)拿到的 env 里,`*_KEY`/`*_TOKEN`/`*_SECRET`/`*_PASSWORD`… 一律先丢掉。
7
+ * 两处都以**单参形**调用,于是「这一次到底丢了哪些 key、按哪条规则(suffix-rule / exact-name)、置信度多少」
8
+ * **零留痕** —— 部署方既证明不了保护生效过,也发现不了它悄悄不生效了(词表漂移 / 一个新的密钥命名法从
9
+ * 规则的缝里穿过去 / 某条腿被改成 `inheritEnv:"all"`)。core 早就把收集口开好了(`findings?:
10
+ * SecretEnvFinding[]`,d.ts 原话:the VALUE is never carried — this record is meant to be loggable),
11
+ * 缺的只是本仓这半场。本模块就是那半场,**不是**一个新的执法面:它一个字节也不改变剥除行为。
12
+ *
13
+ * ## 形(照 `fail-open.ts` 的仓内先例)
14
+ * 1. **计数(逐次)** —— `secret_env_scrubbed_total{site,kind,confidence}`;
15
+ * 2. **结构化披露(每 site×key 一次)** —— 首次在某条腿上丢掉某个 key 时一条 `info`,之后同 key 只走计数。
16
+ * 不是「每 site 一次」:一个**新**的密钥形 key 后来才出现(部署加了一个凭证)时,那才是最该看见的一行。
17
+ * 值永不进任何一件(core 的 finding 本身就不携带值)。
18
+ *
19
+ * ## 为什么是进程级自由函数而不是注入
20
+ * 与 `recordFailOpen` 同因:两个调用点都在**没有 logger/metrics 句柄**的深处
21
+ * (`plugins/remote-env-host.ts` 的 `mergeEnv` 全文零观测通道;`project-memory.ts` 的 `git()` 是个自由函数)。
22
+ * 装配层用 {@link installSecretEnvScrubRecorder} 把句柄接上,装配之前命中的次数不会丢(计数照常累加,
23
+ * install 时一次性补进 metrics),披露行则在下一次命中时补发(未成功披露的 key 不进 `disclosed` 集)。
24
+ */
25
+ import { type SecretEnvFinding } from "@sema-agent/core";
26
+ import type { Logger } from "./logger.js";
27
+ import type { Metrics } from "./metrics.js";
28
+ /** 登记过的剥除现场。闭集:未登记的 site 传不进 {@link recordSecretEnvScrub}(编译期拒,同 FAIL_OPEN_TAGS 纪律)。 */
29
+ export declare const SECRET_ENV_SCRUB_SITES: {
30
+ readonly "server.remote-env-host.inherit-scrub": "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令";
31
+ readonly "server.project-memory.git-subprocess": "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行";
32
+ };
33
+ /** 词表键推导的闭集类型。 */
34
+ export type SecretEnvScrubSite = keyof typeof SECRET_ENV_SCRUB_SITES;
35
+ interface Sink {
36
+ logger?: Logger;
37
+ metrics?: Metrics;
38
+ }
39
+ /**
40
+ * 记一次剥除。**总不抛** —— 观测本身绝不能变成故障(同 `recordFailOpen` / reapers 的判据)。
41
+ * `findings` 为空(这一次没丢任何 key)时什么也不做:那不是事件,是常态。
42
+ */
43
+ export declare function recordSecretEnvScrub(site: SecretEnvScrubSite, findings: readonly SecretEnvFinding[]): void;
44
+ /** 装配层把 logger/metrics 接上(main.ts,与 `installFailOpenRecorder` 同一处)。装配前累计的次数在此补进 metrics。 */
45
+ export declare function installSecretEnvScrubRecorder(deps: Sink): void;
46
+ /** 已留驻的「现场×键」条数(测试/诊断读面)。恒 ≤ {@link DISCLOSED_CAP}。 */
47
+ export declare function secretEnvScrubDisclosedSize(): number;
48
+ /** 本进程各桶的累计次数(测试/诊断读面)。 */
49
+ export declare function secretEnvScrubCounts(): ReadonlyMap<string, number>;
50
+ /** 测试用重置(进程级状态在同一个 vitest worker 里跨用例存活)。 */
51
+ export declare function resetSecretEnvScrubRecorderForTest(deps?: Sink): void;
52
+ export {};
53
+ //# sourceMappingURL=secret-env-scrub.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * A-033.3(#256)—— **密钥形 env 剥除的留痕面**(core D1 `scrubSecretEnv` 的可观测半场)。
3
+ *
4
+ * ## 它解决的问题
5
+ * `scrubSecretEnv` 是本仓两条 shell/子进程腿的**安全默认**:模型可驱动的 shell(`inheritEnv:"scrub"`)与
6
+ * git 子进程(钩子/凭证助手)拿到的 env 里,`*_KEY`/`*_TOKEN`/`*_SECRET`/`*_PASSWORD`… 一律先丢掉。
7
+ * 两处都以**单参形**调用,于是「这一次到底丢了哪些 key、按哪条规则(suffix-rule / exact-name)、置信度多少」
8
+ * **零留痕** —— 部署方既证明不了保护生效过,也发现不了它悄悄不生效了(词表漂移 / 一个新的密钥命名法从
9
+ * 规则的缝里穿过去 / 某条腿被改成 `inheritEnv:"all"`)。core 早就把收集口开好了(`findings?:
10
+ * SecretEnvFinding[]`,d.ts 原话:the VALUE is never carried — this record is meant to be loggable),
11
+ * 缺的只是本仓这半场。本模块就是那半场,**不是**一个新的执法面:它一个字节也不改变剥除行为。
12
+ *
13
+ * ## 形(照 `fail-open.ts` 的仓内先例)
14
+ * 1. **计数(逐次)** —— `secret_env_scrubbed_total{site,kind,confidence}`;
15
+ * 2. **结构化披露(每 site×key 一次)** —— 首次在某条腿上丢掉某个 key 时一条 `info`,之后同 key 只走计数。
16
+ * 不是「每 site 一次」:一个**新**的密钥形 key 后来才出现(部署加了一个凭证)时,那才是最该看见的一行。
17
+ * 值永不进任何一件(core 的 finding 本身就不携带值)。
18
+ *
19
+ * ## 为什么是进程级自由函数而不是注入
20
+ * 与 `recordFailOpen` 同因:两个调用点都在**没有 logger/metrics 句柄**的深处
21
+ * (`plugins/remote-env-host.ts` 的 `mergeEnv` 全文零观测通道;`project-memory.ts` 的 `git()` 是个自由函数)。
22
+ * 装配层用 {@link installSecretEnvScrubRecorder} 把句柄接上,装配之前命中的次数不会丢(计数照常累加,
23
+ * install 时一次性补进 metrics),披露行则在下一次命中时补发(未成功披露的 key 不进 `disclosed` 集)。
24
+ */
25
+ import { summarizeRedactions } from "@sema-agent/core";
26
+ /** 登记过的剥除现场。闭集:未登记的 site 传不进 {@link recordSecretEnvScrub}(编译期拒,同 FAIL_OPEN_TAGS 纪律)。 */
27
+ export const SECRET_ENV_SCRUB_SITES = {
28
+ "server.remote-env-host.inherit-scrub": "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令",
29
+ "server.project-memory.git-subprocess": "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行",
30
+ };
31
+ /** 计数器名。 */
32
+ const SCRUB_COUNTER = "secret_env_scrubbed_total";
33
+ /** 已披露集的上限。key 集在一个进程里实际上是常量(恒扫同一份 `process.env`),这个帽只是防病态输入
34
+ * 把集合撑爆;越帽之后只停止**新增披露**,计数照常(缺席的是行,不是账)。 */
35
+ const DISCLOSED_CAP = 512;
36
+ /** 桶键与披露键的分隔符。用 `=` 是有据的:POSIX 环境变量名里唯二不可能出现的字节就是 `=` 与 NUL
37
+ * (NUL 不写进源码 —— 裸控制字节会让 grep 对整个文件失明,本仓有机械门盯它)。用空格/冒号分隔则会让
38
+ * `A B`+`C` 与 `A`+`B C` 撞成同一个键 —— 标签串台 = 遥测说谎。 */
39
+ const SEP = "=";
40
+ let sink = {};
41
+ /** `site=kind=confidence` → 累计次数(逐次,进程级)。 */
42
+ let counts = new Map();
43
+ /** 已经推进 metrics 的部分 —— 差额即欠账,install 时一次性补。 */
44
+ let pushed = new Map();
45
+ /** `site=key` —— 已经出过披露行的现场×键。 */
46
+ let disclosed = new Set();
47
+ function bucketKey(site, f) {
48
+ return `${site}${SEP}${f.kind}${SEP}${f.confidence}`;
49
+ }
50
+ /** 把 counts 与 pushed 的差额推进 metrics。metrics 缺席时什么也不做(差额留着,下次补)。 */
51
+ function drainToMetrics() {
52
+ const m = sink.metrics;
53
+ if (m === undefined)
54
+ return;
55
+ for (const [k, n] of counts) {
56
+ const already = pushed.get(k) ?? 0;
57
+ if (n <= already)
58
+ continue;
59
+ const [site, kind, confidence] = k.split(SEP);
60
+ try {
61
+ m.inc(SCRUB_COUNTER, { site: site, kind: kind, confidence: confidence }, n - already);
62
+ pushed.set(k, n); // 真进了汇才销账(投递失败 ⇒ 差额留着重放)
63
+ }
64
+ catch {
65
+ /* 下一次命中/下一次装配再补 */
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * 记一次剥除。**总不抛** —— 观测本身绝不能变成故障(同 `recordFailOpen` / reapers 的判据)。
71
+ * `findings` 为空(这一次没丢任何 key)时什么也不做:那不是事件,是常态。
72
+ */
73
+ export function recordSecretEnvScrub(site, findings) {
74
+ if (findings.length === 0)
75
+ return;
76
+ try {
77
+ for (const f of findings) {
78
+ const k = bucketKey(site, f);
79
+ counts.set(k, (counts.get(k) ?? 0) + 1);
80
+ }
81
+ drainToMetrics();
82
+ const logger = sink.logger;
83
+ if (logger === undefined)
84
+ return; // 披露行留到有 logger 的下一次命中(未进 disclosed ⇒ 会补发)
85
+ // 🔴 帽是**对这一次的批也生效**的(codex 复审 round2 medium 真修):先按剩余容量截断,再用截断后的
86
+ // 那一批去发行与登记。两条都靠它:
87
+ // ① 帽满 ⇒ 停止披露,而不是「喊了但记不下」—— 后者会把「每 site×key 一次」在 key 最多的时候
88
+ // 反转成「每次调用一行」(mergeEnv 是逐命令调用的,那是刷屏);
89
+ // ② 单批越界 ⇒ 也得截断 —— 只在批**之前**判帽,一次 513 条的病态 env 会一次性把 513 条全发出去
90
+ // (一行超大日志)并全部留驻,帽等于没有。计数不受截断影响:账永远是全的,缺席的只是行。
91
+ const room = DISCLOSED_CAP - disclosed.size;
92
+ if (room <= 0)
93
+ return;
94
+ const fresh = findings.filter((f) => !disclosed.has(`${site}${SEP}${f.key}`)).slice(0, room);
95
+ if (fresh.length === 0)
96
+ return;
97
+ logger.info("secret_env_scrubbed", {
98
+ site,
99
+ note: SECRET_ENV_SCRUB_SITES[site],
100
+ // 只报**本次新见**的 key(值永不携带 —— core 的 finding 按契约只带键名/规则/置信度/来源)。
101
+ keys: fresh.map((f) => f.key),
102
+ // 一行人读摘要走 core 的 `summarizeRedactions`(SecretEnvFinding 满足 SummarizableFinding),
103
+ // 不在本仓另写一份分组渲染 —— 那正是本票要消灭的那种孪生。
104
+ summary: summarizeRedactions(fresh),
105
+ });
106
+ for (const f of fresh)
107
+ disclosed.add(`${site}${SEP}${f.key}`);
108
+ }
109
+ catch {
110
+ /* 观测绝不抛 */
111
+ }
112
+ }
113
+ /** 装配层把 logger/metrics 接上(main.ts,与 `installFailOpenRecorder` 同一处)。装配前累计的次数在此补进 metrics。 */
114
+ export function installSecretEnvScrubRecorder(deps) {
115
+ sink = { ...deps };
116
+ deps.metrics?.counter(SCRUB_COUNTER, "secret-pattern env entries dropped before spawning a child process, by site/rule/confidence (core scrubSecretEnv findings)");
117
+ drainToMetrics();
118
+ }
119
+ /** 已留驻的「现场×键」条数(测试/诊断读面)。恒 ≤ {@link DISCLOSED_CAP}。 */
120
+ export function secretEnvScrubDisclosedSize() {
121
+ return disclosed.size;
122
+ }
123
+ /** 本进程各桶的累计次数(测试/诊断读面)。 */
124
+ export function secretEnvScrubCounts() {
125
+ return new Map(counts);
126
+ }
127
+ /** 测试用重置(进程级状态在同一个 vitest worker 里跨用例存活)。 */
128
+ export function resetSecretEnvScrubRecorderForTest(deps = {}) {
129
+ counts = new Map();
130
+ pushed = new Map();
131
+ disclosed = new Set();
132
+ sink = { ...deps };
133
+ deps.metrics?.counter(SCRUB_COUNTER, "secret-pattern env entries dropped before spawning a child process, by site/rule/confidence (core scrubSecretEnv findings)");
134
+ }
135
+ //# sourceMappingURL=secret-env-scrub.js.map
@@ -1,81 +1,18 @@
1
- import { type PersistedAllowRule, type PermissionRuleStore, type PermissionRuleStoreProvider, type PutResult, type QuarantinedRuleAdd, type RuleAdd, type RuleDot, type RuleScope, type RuleSyncFrontier, type RuleTombstone, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
1
+ import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { PgQueryFn } from "./pg-query.js";
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
- /** core `permission-rule-store.ts` 的 `PERMISSION_RULE_WRITER` 字面值(逐字)。`writerOf(store)` 读的
6
- * 就是这把键——它是 core 与 backend 之间**事实上的**协议名,只是没被导出。 */
7
- export declare const PERMISSION_RULE_WRITER_KEY = "__semaPermissionRuleWriter";
8
- /** core `RedemptionAuthorization` 的镜像。 */
9
- export interface RedemptionAuthorizationMirror {
10
- recordId: string;
11
- principal?: string;
12
- owner?: RuleOwner;
13
- }
14
- /** core `RuleAddDelta` 的镜像。 */
15
- export interface RuleAddDeltaMirror {
16
- kind: "redemption-add";
17
- rule: string;
18
- scope: RuleScope;
19
- tool: PersistedAllowRule["tool"];
20
- match: PersistedAllowRule["match"];
21
- command: string;
22
- add: RuleAdd;
23
- redemption: RedemptionAuthorizationMirror;
24
- }
25
- /** core `RuleDeleteDelta` 的镜像。 */
26
- export interface RuleDeleteDeltaMirror {
27
- kind: "tighten-delete";
28
- tombstone: RuleTombstone;
29
- }
30
- /** core `RuleSyncJoinDelta` 的镜像(**本 backend 不实现**,只为判别式完整——见顶注 ②)。 */
31
- export interface RuleSyncJoinDeltaMirror {
32
- kind: "sync-join";
33
- inbound: {
34
- rules: PersistedAllowRule[];
35
- tombstones: RuleTombstone[];
36
- };
37
- gcFrontier?: RuleSyncFrontier;
38
- observedVector?: RuleSyncFrontier;
39
- quarantine?: Array<{
40
- rule: string;
41
- scope: RuleScope;
42
- dots: RuleDot[];
43
- reason: QuarantinedRuleAdd["reason"];
44
- }>;
45
- }
46
- export type RuleWriteDeltaMirror = RuleAddDeltaMirror | RuleDeleteDeltaMirror | RuleSyncJoinDeltaMirror;
47
- /** core `RawRuleSyncState` 的镜像。 */
48
- export interface RawRuleSyncStateMirror {
49
- actor: string;
50
- counter: number;
51
- rev: number;
52
- rules: PersistedAllowRule[];
53
- tombstones: RuleTombstone[];
54
- observedVector?: RuleSyncFrontier;
55
- quarantined?: QuarantinedRuleAdd[];
56
- }
57
- /** core `PermissionRuleWriter` 的镜像。 */
58
- export interface PermissionRuleWriterMirror {
59
- nextDot(): Promise<RuleDot>;
60
- apply(delta: RuleWriteDeltaMirror, opts: {
61
- expectedRev: number;
62
- }): Promise<PutResult | {
63
- conflict: true;
64
- rev: number;
65
- }>;
66
- readRaw(): Promise<RawRuleSyncStateMirror>;
67
- }
68
- /** core `WritablePermissionRuleStore` 的镜像(属性名 = {@link PERMISSION_RULE_WRITER_KEY})。 */
69
- export interface WritablePermissionRuleStoreMirror extends PermissionRuleStore {
70
- readonly __semaPermissionRuleWriter: PermissionRuleWriterMirror;
71
- }
72
5
  /**
73
- * core 私有 `writerOf` 的**本地对偶**:一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
6
+ * 一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
7
+ *
8
+ * 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
9
+ * 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
10
+ * 就该当场判 `undefined`,而不是在调用 `readRaw()` 时炸一个 `not a function`。
74
11
  *
75
- * 结构检查而不是断言 —— 它同时是本仓消费点(测试/诊断)读写面的**唯一**入口:没有这个函数,每个调用点
76
- * 都会各写一次 `as unknown as {…}`,那既是三处宽松断言,也是三份会各自漂的形状假设。
12
+ * 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
13
+ * 也是三份会各自漂的形状假设。
77
14
  */
78
- export declare function writerOfSqlRuleStore(store: PermissionRuleStore): PermissionRuleWriterMirror | undefined;
15
+ export declare function writerOfSqlRuleStore(store: PermissionRuleStore): PermissionRuleWriter | undefined;
79
16
  export declare const PERMISSION_RULE_TABLE = "permission_rule";
80
17
  export declare const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval";
81
18
  export declare const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket";
@@ -23,23 +23,28 @@
23
23
  * 天然原子,不需要事务、不需要 `FOR UPDATE`、不进本仓在案的悲观锁死锁族。
24
24
  *
25
25
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
26
- * 🔴 已登记的**上游契约缺口**(汇报件,不是本车的自由发挥)
26
+ * 🔴 写面 = **core 的本尊**(A-033.2 / #256 销账,交接件③已兑现)
27
27
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
28
- * core 的**写面**(`PERMISSION_RULE_WRITER` 常量、`PermissionRuleWriter`/`WritablePermissionRuleStore`/
29
- * `RuleWriteDelta`/`RawRuleSyncState` 类型、`foldDelta`/`applySyncJoin`/`writerOf`/
30
- * `assertRedemptionNotQuarantined`/`assertDeleteDeltaCarriesNoAdd` 函数)**全部不在 `@sema-agent/core`
31
- * 的公开导出面上**(亲读 `dist/index.d.ts`;`exports` map 只开 `.`/`./bench`/`./fixtures`,深路径
32
- * import 被封)。core 的原话是「a host cannot hold one, so no API-level path to the store bypasses the
33
- * consent protocol」——那条纪律针对的是「宿主自己往店里写」,而**一个宿主提供的 backend** 恰恰必须持有
34
- * 写面才能被 `redeemRuleTicket` 使用(core 的 `writerOf(store)` 读的就是这个属性)。
28
+ * 本文件曾整段登记一个上游契约缺口:core 的写面(`PERMISSION_RULE_WRITER` 常量、`PermissionRuleWriter`/
29
+ * `WritablePermissionRuleStore`/`RuleWriteDelta`/`RawRuleSyncState` 类型、`foldDelta`/`writerOf`/
30
+ * `assertRedemptionNotQuarantined`/`assertDeleteDeltaCarriesNoAdd` 函数)当时**不在包的公开导出面上**,
31
+ * 于是本文件按结构满足它:属性名用字面常量、类型在下方一份份**本地镜像**(`RuleWriteDeltaMirror` 等),
32
+ * 并把「求 core 导出写面」列为交接件③。
35
33
  *
36
- * 本文件按**结构**满足写面:属性名用 core 的字面常量 `"__semaPermissionRuleWriter"`,类型在下方
37
- * 本地镜像(`RuleWriteDeltaMirror` 等)。这**是**一次形状复制,违反「schema 单一属主」的字面——
38
- * 处置不是隐瞒而是登记:①镜像类型逐字对着 core d.ts 写,并在 `test/permission-rule-store-*.test.ts`
39
- * 里用**结构可赋值性**钉住(core 改形 编译红);②`sync-join` 那一臂**不实现**——它的落地管线
40
- * (`applySyncJoin`)是 core 私有的一整条流水线,复制它才是真的会漂;本 backend 对该 delta **响亮拒绝**
41
- * (fail-closed,不是静默 no-op),同步客户端本仓尚未接线,所以这条臂今天零调用点。
42
- * core 导出写面(或提供一个 `createSqlBackedRuleStore` 适配器)已列入交接件。
34
+ * core 2026-08-10 裁定**导出后端契约**(d.ts 原话:「Exported as part of the BACKEND CONTRACT … so an
35
+ * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
36
+ * types」),5.33.0 起全部根导出 镜像与手写孪生一并摘除,本文件直接消费 core 的类型与三个共享判据。
37
+ * 这不是洁癖:`foldDelta` d.ts 自陈「Pure — the backends share it so the file and in-memory forms
38
+ * cannot drift in what a delta MEANS」,而本 backend 此前**没有共享它**——一份 delta 在 file 后端与
39
+ * SQL 后端上「意味着什么」可以各自漂,漂的落点是放行面。换装同时把两条判据换成 core 的严格版:
40
+ * · `assertDeleteDeltaCarriesNoAdd`:core **6 个** add-only 键(add/redemption/rule/command/match/tool)
41
+ * 并要求 `removedDots` 是**非空数组**;本地孪生只查过一个 `rules` 键(那还是 sync-join 的字段名,
42
+ * 打偏了)且对空 `removedDots` 放行 —— 一条删不掉任何 add 的墓碑会让本该已删的规则继续放行。
43
+ * · `assertRedemptionNotQuarantined`:语义等价(同 dot 即拒),换本尊后报文也与 file 后端一致。
44
+ *
45
+ * 仍然**不实现**的只剩 `sync-join` 那一臂:它的落地管线(`applySyncJoin`)至今是 core 私有的一整条
46
+ * 流水线,复制它才是真的会漂;本 backend 对该 delta **响亮拒绝**(fail-closed,不是静默 no-op),
47
+ * 同步客户端本仓尚未接线,所以这条臂今天零调用点。
43
48
  *
44
49
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
45
50
  * 边界纪律:列回读一律 zod `safeParse`(宪法 [2704] 禁裸 as-cast)。JSON 列是驱动交回的 `unknown`,
@@ -49,22 +54,23 @@
49
54
  */
50
55
  import { createHash, randomBytes } from "node:crypto";
51
56
  import { z } from "zod";
52
- import { applyTombstones, parseAllowRuleText, sameScope, screenRuleSyncState, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
57
+ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
53
58
  import { dialectProtocolJsonEncoder } from "./sql-driver.js";
54
59
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
55
- // core 私有写面的**本地镜像**(顶注 §「上游契约缺口」逐条理由)
60
+ // 写面(core 的后端契约,2026-08-10 裁定导出;本仓 A-033.2 已摘除全部本地镜像)
56
61
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
57
- /** core `permission-rule-store.ts` 的 `PERMISSION_RULE_WRITER` 字面值(逐字)。`writerOf(store)` 读的
58
- * 就是这把键——它是 core 与 backend 之间**事实上的**协议名,只是没被导出。 */
59
- export const PERMISSION_RULE_WRITER_KEY = "__semaPermissionRuleWriter";
60
62
  /**
61
- * core 私有 `writerOf` 的**本地对偶**:一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
63
+ * 一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
64
+ *
65
+ * 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
66
+ * 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
67
+ * 就该当场判 `undefined`,而不是在调用 `readRaw()` 时炸一个 `not a function`。
62
68
  *
63
- * 结构检查而不是断言 —— 它同时是本仓消费点(测试/诊断)读写面的**唯一**入口:没有这个函数,每个调用点
64
- * 都会各写一次 `as unknown as {…}`,那既是三处宽松断言,也是三份会各自漂的形状假设。
69
+ * 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
70
+ * 也是三份会各自漂的形状假设。
65
71
  */
66
72
  export function writerOfSqlRuleStore(store) {
67
- const w = Reflect.get(store, PERMISSION_RULE_WRITER_KEY);
73
+ const w = Reflect.get(store, PERMISSION_RULE_WRITER);
68
74
  if (w === null || typeof w !== "object")
69
75
  return undefined;
70
76
  const nextDot = Reflect.get(w, "nextDot");
@@ -315,42 +321,6 @@ function canonicalJson(v) {
315
321
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
316
322
  return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${canonicalJson(val)}`).join(",")}}`;
317
323
  }
318
- /**
319
- * core `foldDelta` 的**语义镜像**:把一次 redemption-add 折进原始规则集。
320
- *
321
- * 逐字对着 core 的 JSDoc 与 in-memory 参照物写:同 (rule, scope) 归一组,组内 adds 按 **dot 身份**并集
322
- * (同 dot 重放 ⇒ 恒等,这正是 core「一次兑付恒铸一个 dot,重放多少次都只有一个 add」的落点)。
323
- */
324
- function foldAdd(rules, delta) {
325
- const next = rules.map((r) => ({ ...r, adds: [...r.adds] }));
326
- const hit = next.find((r) => r.rule === delta.rule && sameScope(r.scope, delta.scope));
327
- if (hit === undefined) {
328
- next.push({ rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, scope: delta.scope, adds: [delta.add] });
329
- return next;
330
- }
331
- if (!hit.adds.some((a) => a.dot.actor === delta.add.dot.actor && a.dot.counter === delta.add.dot.counter))
332
- hit.adds.push(delta.add);
333
- return next;
334
- }
335
- /** core `assertRedemptionNotQuarantined` 的语义镜像:**任何** (rule, scope) 下与本 delta 同 dot 的
336
- * 隔离行都让这次写响亮失败——dot 只铸一次,被隔离钉住的 dot 再出现就是「复活」形。 */
337
- function assertNotQuarantined(quarantined, delta) {
338
- const hit = quarantined.find((q) => q.add.dot.actor === delta.add.dot.actor && q.add.dot.counter === delta.add.dot.counter);
339
- if (hit !== undefined) {
340
- throw new Error(`refusing to fold a redemption whose dot (${hit.add.dot.actor}#${hit.add.dot.counter}) is quarantined (${hit.reason}) — ` +
341
- `re-entry requires a NEW consent (a new ask, a new record, a new dot)`);
342
- }
343
- }
344
- /** core `assertDeleteDeltaCarriesNoAdd` 的语义镜像(联合已让它不可拼写,但 JS 调用方不受联合约束 ⇒ 执行它)。 */
345
- function assertDeleteCarriesNoAdd(delta) {
346
- // `Reflect.get` 而不是 `as unknown as {…}`:这一行的**全部目的**就是看一个联合类型里不存在的键在不在
347
- // (JS 调用方不受联合约束),用断言去表达「我知道它可能有个类型上没有的键」既绕过类型面又触类型卫生门。
348
- const smuggled = Reflect.get(delta, "rules");
349
- if (smuggled !== undefined)
350
- throw new Error("a tighten-delete delta must carry a tombstone and nothing else");
351
- if (delta.tombstone === undefined || delta.tombstone.removedDots === undefined)
352
- throw new Error("a tighten-delete delta must carry a tombstone");
353
- }
354
324
  /** {@link SqlPermissionRuleStore} 的 dot 铸造 CAS 重试上限。有界:一个无界循环只会把「争不过」变成
355
325
  * 挂死;用尽 ⇒ 响亮抛错,由 core 的兑付腿(它自己也有有界重试)如实回 refused。 */
356
326
  const NEXT_DOT_MAX_ATTEMPTS = 8;
@@ -475,7 +445,7 @@ class SqlPermissionRuleStore {
475
445
  async quarantined() {
476
446
  return (await this.read()).quarantined;
477
447
  }
478
- get [PERMISSION_RULE_WRITER_KEY]() {
448
+ get [PERMISSION_RULE_WRITER]() {
479
449
  return this.writer;
480
450
  }
481
451
  writer = {
@@ -535,12 +505,14 @@ class SqlPermissionRuleStore {
535
505
  return { conflict: true, rev: cur.rev };
536
506
  let nextRules = cur.rules;
537
507
  let nextTombstones = cur.tombstones;
508
+ // A-033.2(#256):两条判据 + 折叠本体全部走 core 的共享件(`foldDelta` 的 d.ts:「the backends share
509
+ // it so the file and in-memory forms cannot drift in what a delta MEANS」)。此前这三处是本地孪生。
538
510
  if (delta.kind === "redemption-add") {
539
- assertNotQuarantined(cur.quarantined, delta);
540
- nextRules = foldAdd(cur.rules, delta);
511
+ assertRedemptionNotQuarantined(cur.quarantined, delta);
512
+ nextRules = foldDelta(cur.rules, delta);
541
513
  }
542
514
  else {
543
- assertDeleteCarriesNoAdd(delta);
515
+ assertDeleteDeltaCarriesNoAdd(delta);
544
516
  nextTombstones = [...cur.tombstones, delta.tombstone];
545
517
  }
546
518
  // 🔴 codex 交叉复审 round5 [high] 一(验真后修)——**只写读得回来的东西**。
@@ -48,6 +48,7 @@ import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
48
48
  import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys } from "./host-platform.js";
49
49
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
50
50
  import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
51
+ import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
51
52
  const PROVIDER = "host";
52
53
  /** Default per-command wall-clock when the caller passes none (NOT a hang detector — host commands rarely hang).
53
54
  * ⚠️ 同判据三腿之一(另两条:`remote-env-local-docker.ts` 同值 30min、`remote-env-ssh.ts` **5min**)——
@@ -1526,8 +1527,15 @@ export class RemoteHostExecutionEnv {
1526
1527
  if (process.env[k] !== undefined)
1527
1528
  base[k] = process.env[k];
1528
1529
  }
1529
- else
1530
- base = scrubSecretEnv(process.env); // "scrub" — core's first-party D1 secret-env filter
1530
+ else {
1531
+ // "scrub" — core's first-party D1 secret-env filter. A-033.3(#256):带 `findings` 收集器调用,
1532
+ // 把「这一次丢了哪些密钥形 key、按哪条规则、置信度多少」交给既有观测面留痕。零留痕的保护证明不了
1533
+ // 自己生效过,也发现不了自己不再生效(词表漂/新命名法穿缝/这条腿被改成 inheritEnv:"all")。
1534
+ // 收集器**不改变**剥除行为一个字节(core: 省略它 ⇒ byte-for-byte 与之前相同)。
1535
+ const findings = [];
1536
+ base = scrubSecretEnv(process.env, findings);
1537
+ recordSecretEnvScrub("server.remote-env-host.inherit-scrub", findings);
1538
+ }
1531
1539
  if (this.cfg.env)
1532
1540
  Object.assign(base, this.cfg.env); // caller-supplied (control-plane secrets) verbatim
1533
1541
  if (perCommand)
@@ -17,6 +17,7 @@ import { execFile } from "node:child_process";
17
17
  import { createHmac, randomBytes } from "node:crypto";
18
18
  import { isAbsolute, join, sep } from "node:path";
19
19
  import { scrubSecretEnv } from "@sema-agent/core";
20
+ import { recordSecretEnvScrub } from "./observability/secret-env-scrub.js";
20
21
  import { redactSecrets } from "./trace/redact.js";
21
22
  /** The instruction-file name chain (core 1.302 S-1): AGENTS.md wins, CLAUDE.md is the fallback, then the
22
23
  * `.claude/` nested form. The FIRST hit is the single declared instruction source. */
@@ -122,11 +123,16 @@ async function hasSymlinkedParent(root, rel) {
122
123
  * and GIT_CONFIG_GLOBAL/SYSTEM=/dev/null drop the global/system config so a planted one can't inject either. */
123
124
  function git(root, args, maxBytes) {
124
125
  const safeArgs = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args];
126
+ // A-033.3(#256):带 `findings` 收集器 —— 见下方 env 行的注释,这条腿丢掉的是「仓内的钩子/凭证助手
127
+ // 本来会看到的」那批 key,零留痕的话保护生效与否在遥测里读不出来。收集器不改变剥除行为。
128
+ const scrubFindings = [];
129
+ const scrubbedEnv = scrubSecretEnv(process.env, scrubFindings);
130
+ recordSecretEnvScrub("server.project-memory.git-subprocess", scrubFindings);
125
131
  return new Promise((res) => {
126
132
  execFile("git", safeArgs,
127
133
  // scrubSecretEnv: a git hook / credential helper must not see the orchestrator's *_KEY/*_TOKEN/*_SECRET env.
128
134
  // GIT_CONFIG_GLOBAL/SYSTEM=/dev/null: ignore ~/.gitconfig + /etc/gitconfig so a planted exec-config can't steer us.
129
- { cwd: root, timeout: 5000, maxBuffer: maxBytes, env: { ...scrubSecretEnv(process.env), GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }, (err, stdout, stderr) => {
135
+ { cwd: root, timeout: 5000, maxBuffer: maxBytes, env: { ...scrubbedEnv, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }, (err, stdout, stderr) => {
130
136
  if (!err)
131
137
  return res({ out: stdout.trim() });
132
138
  // S23: the git narrative used to vanish with zero fingerprint — classify why (→ gitDropReason).
package/dist/run-local.js CHANGED
@@ -78,6 +78,7 @@ import { createPermissionDeniedMeter } from "./observability/tool-trace.js";
78
78
  import { createKeyResolver } from "./key-resolver.js";
79
79
  import { createLogger } from "./observability/logger.js";
80
80
  import { createMetrics } from "./observability/metrics.js";
81
+ import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
81
82
  /** Parse argv (everything AFTER `node run-local.js`). Throws a clear Error on a missing objective. */
82
83
  export function parseArgs(argv) {
83
84
  let objective;
@@ -308,6 +309,12 @@ export async function runLocal(argv, deps = {}) {
308
309
  const config = loadConfig();
309
310
  const logger = deps.logger ?? createLogger(config.logLevel);
310
311
  const metrics = createMetrics();
312
+ // A-033.3(#256,codex 复审 medium 真修):run-local **也**要装密钥剥除的留痕面。这不是 main.ts 的
313
+ // 复制粘贴 —— 它恰恰是最该有的那条腿:run-local 是 TOC 本地形,模型可驱动的 shell 跑在**用户自己的
314
+ // 机器**上、继承的是用户**真实的** env(脑钥/仓库令牌/DB 凭据),而这两条剥除腿(hostExecutionEnvFactory
315
+ // 的 mergeEnv 与 makeLoadProjectMemory 的 git 子进程)在本入口全都接线了。不装 ⇒ 收集器有了、sink 是空的,
316
+ // 这条腿保留 A-033.3 要消灭的那个「零披露」原状(而且进程一退,连计数都没了)。
317
+ installSecretEnvScrubRecorder({ logger, metrics });
311
318
  // F10 (codex audit): run-local used to drop the pre-logger config diagnostics on the floor (S20 soft-knob
312
319
  // warnings + [792]④ boot notices) — main.ts drained them, this entrypoint didn't. Shared helper, no drift.
313
320
  logConfigDiagnostics(logger);
package/dist/runs.js CHANGED
@@ -678,12 +678,42 @@ approval) {
678
678
  }
679
679
  : {}),
680
680
  };
681
+ // 🔴 **#255(core 5.34.0 #253 / 判据帖 [3967] G1):status 第五席** —— 与上面四席**不同**,它只在
682
+ // 「只回结果」的两条腿上展开,**绝不**挂到 `runTaskStream` 那一位。理由是 core 的 `statusEmit` 是
683
+ // **双写**(`queue.push` 照旧 + 座位在场即喂**同一个** frozen 帧对象):普通流形的那条 queue 就是本
684
+ // 函数自己 `for await` 的流 —— 下面 loop 里 `sink.onEvent(ev)` 已落账、`metrics.inc` 已计数 ⇒ 两处
685
+ // 都写 = **同一帧两行账本 + 计数翻倍**。组合腿(verify/cascade)没有这条流(引擎把内部腿的 queue
686
+ // 自己排干丢弃),座位是它**唯一**的载体,这正是 5.34.0 开这一席的理由。
687
+ // (反向钉:`test/issue190-status-wire-disclosure.test.ts` §4⑤ —— 夹具照 core 的真姿势 dual-write,
688
+ // 普通流形上账本必须恰五行不是十行。)
689
+ //
690
+ // ⚠️ **帧是 frozen 的**:core 在双写前 `Object.freeze(frame)`,所以这条链上任何一环都不许写帧字段
691
+ // (改一下再往下传会当场 TypeError)。`brainStatusEventData` 是纯读投影,不改源帧。
692
+ // ⚠️ **同步回调不能 await**:失败留痕不断链(孪生姿势 = `onForwardEvent` 的 fire-and-forget 写)。
693
+ // ⚠️ 下面那个 `.catch` **买的不是进程安全**(别照抄别处的「逃逸 rejection 杀进程」说法):
694
+ // `sink.appendStatus` 返回的是**链步**promise,而 `LedgerSink.append` 内部已经给每一步挂了处理器
695
+ // (`chain = step.then(ok, err)`,「失败不断链」)⇒ 调用方 `void` 掉也不会有逃逸的 rejection
696
+ // (实测:摘掉这个 `.catch`,issue190 §4⑥/⑥b 仍绿)。它买的是**留痕** —— 披露行被静默丢掉时至少
697
+ // 有一行诊断可查;判别力由 §4⑥ 的「诊断行在场」断言看守。
698
+ const statusSeat = {
699
+ onStatusEvent: (e) => {
700
+ // S4 P0-a 计数腿:与普通流形 loop 的 `case "status"`、sync 腿、resume 腿同键同标签。
701
+ metrics?.inc("brain_retry_total", { phase: String(e.phase) });
702
+ // 账本腿:共享 LedgerSink 的 **`appendStatus` 同步入链门**(与 `onEvent` 的 `case "status"` 共用
703
+ // 同一个白名单 builder ⇒ 行的 type/data 与其余三腿逐字同形;判据 = issue190 §2/§3/§4 共用一份
704
+ // EXPECTED_ROW_DATA)。🔴 **别改回 `sink.onEvent(e)`**:那条路先 `await flush()` 才入链,同一拍
705
+ // 后到的 `onForwardEvent`(同步入链)会抢到更小的序号 ⇒ 账本时间线倒挂(codex 对抗复审 R2
706
+ // [medium],红先复现后修;反向钉 = issue190 §4⑤b)。
707
+ void sink.appendStatus(e).catch((err) => completionDiagLog?.("status_event_append_failed", { route: "bg-run-composite", taskId, err: err instanceof Error ? err.message : String(err) }));
708
+ },
709
+ };
681
710
  if (verify !== undefined) {
682
711
  // Developer-mode verification gate (1.44): multi-round (impl → adversarial verify → fix loop), not
683
712
  // a stream — await the result and persist it terminal. The durable event log gets just the `done`
684
713
  // (the verifier subtask runs on its own session). `result.verification` carries the verdict.
685
714
  // #253(core 5.33.0 #246):第 4 位 = 供给座席,core 逐字转发给实现腿 + 验证器腿 + 每一轮修复腿。
686
- const result = await runWithVerification(runner, { ...spec, taskId, signal: cancelCtrl.signal }, verify, fwdInternals);
715
+ // #255(core 5.34.0):`statusSeat` 只在这两条「只回结果」腿上展开(理由见其顶注)
716
+ const result = await runWithVerification(runner, { ...spec, taskId, signal: cancelCtrl.signal }, verify, { ...fwdInternals, ...statusSeat });
687
717
  await flush();
688
718
  evictIfConflict(runner, spec.sessionId, result);
689
719
  // A nested suspend under the verify gate is mapped by core to status:"failed" carrying the capability
@@ -700,7 +730,7 @@ approval) {
700
730
  // Cascade (1.45): cheap→strong ladder; multi-attempt cold re-runs, not a stream. Persist terminal.
701
731
  // #253(core 5.33.0 #246):第 4 位 = 供给座席,core 逐字转发给**每一级 rung**(顺序跑,一只 sink
702
732
  // 按梯序拼接收到各级的帧 —— core 的 ATTRIBUTION 注写明 ToolActivity 自身不带 rung 身份)。
703
- const result = await runCascade(runner, { ...spec, taskId, signal: cancelCtrl.signal }, cascade, fwdInternals);
733
+ const result = await runCascade(runner, { ...spec, taskId, signal: cancelCtrl.signal }, cascade, { ...fwdInternals, ...statusSeat });
704
734
  await flush();
705
735
  evictIfConflict(runner, spec.sessionId, result);
706
736
  await flushPromptManifest(); // [998]②: same no-stream posture as the verify leg above
@@ -52,7 +52,7 @@ export declare function stripDelegationTools<T extends {
52
52
  }>(tools: readonly T[]): T[];
53
53
  /** The operator autonomy mode (registry `runtime.autonomy`). */
54
54
  export type Autonomy = "read-only" | "ask" | "plan" | "auto";
55
- /** 导出给 的枚举式行为钉用(见那里的顶注:从 core 行为反推再比对)。 */
55
+ /** 导出给 `test/coarse-shell-tools-mirror.test.ts` 的同源钉用(见那里的顶注)。 */
56
56
  export declare const COARSE_SHELL_TOOLS_MIRROR: readonly string[];
57
57
  /** Validate `commandPolicy` rules — the shape (array of {command, decision}), the `command` (a bare argv[0] name,
58
58
  * see {@link VALID_COMMAND_NAME}), AND the `decision` enum. Returns the list of human-readable errors (empty =
@@ -23,7 +23,7 @@
23
23
  * path-prefixed commands all bypass the argv[0] match (they route to `ask`, fail-closed, but are not blocked by
24
24
  * name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
25
25
  */
26
- import { combinePolicies, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
26
+ import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
27
27
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
28
28
  /** True when `name` names a DELEGATION tool — the subagent tool(CC-187 canonical `"Agent"`,core
29
29
  * `DEFAULT_SUBAGENT_TOOL_NAME`)。5.0.0 RB-476:折叠面退役,RAW 比对 core 单源常量——旧名("Task")
@@ -48,23 +48,20 @@ export function stripDelegationTools(tools) {
48
48
  const kept = tools.filter((t) => !isDelegationToolName(t.name));
49
49
  return kept.length === tools.length ? tools : kept;
50
50
  }
51
- /** The shell tool names a command-name gate parses. Mirrors core's (un-exported) `COARSE_SHELL_TOOLS` default so
52
- * the ask-list policy below gates the SAME tools `createCoarseCommandNamePolicy` does. A unit test pins behavior
53
- * (a `Bash` ask-command asks) so a core rename surfaces as a red test, not silent drift.
51
+ /** The shell tool names a command-name gate parses **core {@link COARSE_SHELL_TOOLS} 本尊**,不再是镜像。
52
+ * 本仓的 ask-list 策略因此恒门住与 `createCoarseCommandNamePolicy` **同一批**工具。
54
53
  * Q6 (core 1.161): tool names align to CC — `bash`→`Bash`; the old `bash_readonly` MERGED into `Bash` (read-only is
55
54
  * now a toolEffect, not a separate tool).
56
55
  *
57
- * 🔴 2026-07-26:core 1.414 把默认从 `["Bash"]` 改成 `["Bash","Monitor"]`(Monitor 走同一条执行 seam,此前是
58
- * 两条 always-on 门的旁门;core 的端到端实证:同一条 `rm -rf $(cat targets)`,Bash 被拒、**Monitor 零门执行**)
59
- * 我这份镜像因此漂了,后果是 **blocklist 模式下我的 ask-list 不覆盖 Monitor core deny 覆盖**。
60
- * ⚠️ 更要紧的是**旧的钉抓不到这次加名** —— 它只钉了「一条 Bash ask-command 会 ask」,加一个新名字它照样绿
61
- * (core [1707]③ 明确点了这条)。现在的钉改成**枚举式且从 core 的行为反推**:
62
- * `test/coarse-shell-tools-mirror.test.ts` core 导出的 `createCoarseCommandNamePolicy()`(默认参)去逐个探
63
- * 候选工具名,把「core 真的门了哪些」测出来,再断言本常量**等于**那个集合。
64
- * 这样做的理由:core 的 `COARSE_SHELL_TOOLS` **未导出**,而扫它的 dist 源码只能挡写法不挡行为
65
- * (core RB-149 的教训:一个变异靠换写法就穿过了源码扫描)。 */
66
- const COARSE_SHELL_TOOLS = ["Bash", "Monitor"];
67
- /** 导出给 的枚举式行为钉用(见那里的顶注:从 core 行为反推再比对)。 */
56
+ * 🔴 A-033.1(#256)换装记:本常量此前是**手抄镜像** `["Bash","Monitor"] as const`,注释自称「core 未导出」。
57
+ * 那句话在 core RB-153(server [1708] 请托)之后就过期了 —— core 现在**公开导出**这份名单(index.d.ts:134),
58
+ * 它自己的原话是「The list itself is the fact; publishing it removes the guess」。镜像的真实代价有前科:
59
+ * core 1.414 把默认从 `["Bash"]` 扩到 `["Bash","Monitor"]`(Monitor 走同一条执行 seam),镜像漏跟一轮
60
+ * **blocklist 模式下本仓的 ask-list 不覆盖 Monitor 而 core deny 覆盖**(core 端到端实证:同一条
61
+ * `rm -rf $(cat targets)`,Bash 被拒、Monitor 零门执行)。换真 import 之后这一整类漏跟在结构上消失。
62
+ * `test/coarse-shell-tools-mirror.test.ts` 的钉相应从「行为反推 + 值比对」改成**同源断言**(见那里顶注)。 */
63
+ const COARSE_SHELL_TOOLS = CORE_COARSE_SHELL_TOOLS;
64
+ /** 导出给 `test/coarse-shell-tools-mirror.test.ts` 的同源钉用(见那里的顶注)。 */
68
65
  export const COARSE_SHELL_TOOLS_MIRROR = COARSE_SHELL_TOOLS;
69
66
  /**
70
67
  * A per-command ask-list ToolPolicy: a parsed shell command whose `argv[0]` is in `askCommands` tightens to
@@ -138,7 +138,9 @@ export interface AuthContext {
138
138
  sessionId: string;
139
139
  /** Long-term memory scope, derived from the principal (not the body). Undefined disables memory. */
140
140
  memoryScope?: string;
141
- /** 142-S4:the request's SHAPE-VALIDATED projectId (body.projectId 过 PROJECT_ID_REGEX 门后回传)。
141
+ /** 142-S4:the request's SHAPE-VALIDATED **且已归一(小写)** 的 projectId(body.projectId 过 core
142
+ * `PROJECT_ID_REGEX` 门后折小写回传;A-033.1/#256 宽读严写 —— 词法带 /i 收下大写形,但派生 scope 键
143
+ * 与 config.projects 查表都只认这一个归一形,否则同一个项目会按大小写分成两只记忆盘)。
142
144
  * 身份半场(scope 的 tenant 段)永远来自 verified principal — projectId 只选「哪个项目盘/哪条登记」
143
145
  * (config.projects 查表键 + proj: 键的 projectId 段),不是 capability。main.ts 拿它查 defaultScopes。 */
144
146
  resolvedProjectId?: string;
package/dist/security.js CHANGED
@@ -1,5 +1,4 @@
1
- import { uuidv7 } from "@sema-agent/core";
2
- import { PROJECT_ID_REGEX } from "@sema-agent/registry-core";
1
+ import { uuidv7, PROJECT_ID_REGEX } from "@sema-agent/core";
3
2
  // design/158 A4: the env→key-set decoders (parseApprovalHmacKeys / parsePrincipalJwks + their row types) moved to
4
3
  // the leaf ./auth-keys.js so config.ts's env-load no longer value-imports this whole auth module (lens2 §F). The
5
4
  // verifiers that consume the parsed key-sets now live in ./approval-hmac.js / ./principal-jwt.js (design/158 A13);
@@ -110,16 +109,25 @@ export function createAuthorizer(config, sessionStore) {
110
109
  throw new HttpError(401, `missing principal header '${config.principalHeader}'`);
111
110
  }
112
111
  // 142-S4 projectId 线程化(design/142 §2 行为面):body.projectId 是「哪个项目」的选择器,不是身份 —
113
- // scope 的 tenant 段永远来自 verified principal(上面),所以它过 SHAPE 门即可(PROJECT_ID_REGEX,
114
- // registry-core 0.10.0 center 登记簿同一词法;generic lowercase UUID)。不合形状 = 422 typed
115
- // fail-loud(注入串/大小写/任意路径段绝不进 scope 键或 config.projects 查表)。RESUME 腿不过此门
112
+ // scope 的 tenant 段永远来自 verified principal(上面),所以它过 SHAPE 门即可。不合形状 = 422 typed
113
+ // fail-loud(注入串/任意路径段绝不进 scope 键或 config.projects 查表)。RESUME 腿不过此门
116
114
  // (checkpoint 重建 auth 不跑 authorizer)— 持久化 body 在原始提交时已验过,形状不变。
115
+ //
116
+ // 🔴 A-033.1(#256)单源换装 + **宽读严写**两半:
117
+ // ① 形状词法改从 core 取(`PROJECT_ID_REGEX`,core 自陈「projectId 格式单源」[637]①)。此前取自
118
+ // registry-core 的**自持镜像**,而那份镜像与 core 已实证漂移:core 带 `/i`、registry-core 不带 ⇒
119
+ // 一个 projectId 能过 core 的 scope 面(`parseProjectMarker`/`resolveProjectId` 都认大小写)却被
120
+ // 本仓的门 422。registry-core 自己的 `types.d.ts` 就挂着「core 公开导出后即改从 core 取」的 TODO。
121
+ // ② 收下之后**立即折小写**再往下走 —— core 的铸造面同款纪律(`formatProjectMarker` 亲读:验形用
122
+ // 带 `/i` 的同一条正则,写盘 `projectId.toLowerCase()`)。这一折是承重的:`formatProjScope` 不做
123
+ // 任何归一化,不折的话 `proj:<t>/ABC…` 与 `proj:<t>/abc…` 是**两只不同的记忆盘**,同一个项目按
124
+ // 大小写分家(而 registry-core 的 `ProjectsConfig` 只收小写键 ⇒ 大写形还会在 config.projects 恒 miss)。
117
125
  let projectId;
118
126
  if (body.projectId !== undefined) {
119
127
  if (typeof body.projectId !== "string" || !PROJECT_ID_REGEX.test(body.projectId)) {
120
- throw new HttpError(422, "projectId must be a lowercase UUID (the center project registry's id shape)", { code: "invalid_project_id" });
128
+ throw new HttpError(422, "projectId must be a UUID (the center project registry's id shape)", { code: "invalid_project_id" });
121
129
  }
122
- projectId = body.projectId;
130
+ projectId = body.projectId.toLowerCase();
123
131
  }
124
132
  // design/114 Phase3 (reuse-path warm-resume): the caller requires the session to ALREADY exist. The authorizer
125
133
  // must NOT claim-create a missing id — that would materialize an empty session_meta row and DEFEAT core's
@@ -35,7 +35,7 @@
35
35
  * - (`hooks` NO LONGER deferred either — 轴B #2 注释保鲜:applied 形已接线(本文件 hooks 分片 +
36
36
  * main.ts 单用户闸),仅 malformed 才报 deferred;旧「需要 remote hook-runner」是接线前拍照。)
37
37
  */
38
- import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, } from "@sema-agent/core";
38
+ import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, RUN_WORKFLOW_TOOL_NAME, } from "@sema-agent/core";
39
39
  import { findCcRuleFormNames, findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
40
40
  import { parseHooksConfig } from "./hooks/hook-runner.js";
41
41
  /**
@@ -339,12 +339,17 @@ function createAskListPolicy(askNames) {
339
339
  * only, a workflow confirmation is not an edit ([1248]② — cli to verify against the 212 live shell; flip there
340
340
  * if the real shell differs). bypassPermissions/undefined-mode attach nothing (pre-existing behavior); plan is
341
341
  * blocked upstream by handsReadOnly (effect="write"). Deployment disable stays `RuntimeCaps.allowWorkflows` —
342
- * this leg is the per-request HITL gate, not the capability switch. */
343
- const WORKFLOW_ASK_NAMES = ["Workflow"];
342
+ * this leg is the per-request HITL gate, not the capability switch.
343
+ *
344
+ * 🔴 A-033.1(#256):这三处曾**手抄字面量** `"Workflow"`。core 改名该工具时,手抄的名字与 core 实挂的名字
345
+ * 分家 ⇒ ask 腿的 `req.toolName !== "Workflow"` 恒真、deny 腿恒假,**整条门不触发**,而且不报错、不留痕
346
+ * —— 安全轴上的静默 fail-open。现在四处引用(本文件两条腿 + `nameSets` 审计面 + `http/server.ts` 的
347
+ * 可满足性预检)全部派生自 core 导出的 {@link RUN_WORKFLOW_TOOL_NAME},core 一改名这四处同步跟着改。 */
348
+ const WORKFLOW_ASK_NAMES = [RUN_WORKFLOW_TOOL_NAME];
344
349
  function createWorkflowAskPolicy(isExempt) {
345
350
  return {
346
351
  async check(req) {
347
- if (req.toolName !== "Workflow")
352
+ if (req.toolName !== RUN_WORKFLOW_TOOL_NAME)
348
353
  return { action: "allow" };
349
354
  // codex F2 (durable lane): the "don't ask again this session" grant (decide remember="session" →
350
355
  // approvalExemptionStore) is probed at the POLICY layer here — the durable lane parks every policy `ask`
@@ -375,7 +380,7 @@ function createWorkflowAskPolicy(isExempt) {
375
380
  function createWorkflowPlanDenyPolicy() {
376
381
  return {
377
382
  check(req) {
378
- return req.toolName === "Workflow"
383
+ return req.toolName === RUN_WORKFLOW_TOOL_NAME
379
384
  ? { action: "deny", message: `Plan mode is active — "${req.toolName}" is a write/mutating tool and is read-only-blocked.`, decisionReason: "rule" }
380
385
  : { action: "allow" };
381
386
  },
@@ -1,3 +1,4 @@
1
+ import { brainStatusEventData } from "./project.js";
1
2
  import { NotifiedKeys } from "../orchestration/workflow-completion-inbox.js";
2
3
  import type { LedgerEventType } from "./ledger-events.js";
3
4
  /**
@@ -33,6 +34,23 @@ export interface LedgerSink {
33
34
  onEvent(ev: {
34
35
  type: string;
35
36
  }): Promise<void>;
37
+ /**
38
+ * #255(core 5.34.0 的 `RunInternals.onStatusEvent` 第五席):**「只回结果」腿**的 `status` 行写口。
39
+ *
40
+ * 与 {@link onEvent} 的 `case "status"` 共用**同一个**白名单 builder(`brainStatusEventData`),差别只有
41
+ * 一处:本门**同步入链** —— 在回调进入的那一刻就占住序号槽,而不是先 `await flush()` 再入链。
42
+ *
43
+ * 🔴 **为什么必须同步入链**(codex 对抗复审 R2 [medium],红先复现后修):`onForwardEvent` 是同步入链的,
44
+ * 而 core 的第五席与 forward sink 都是**同步回调**、可能同一拍相邻到达。走 `onEvent` 的话那一次
45
+ * `await flush()` 会让**后到**的 forward 帧抢到**更小**的序号 ⇒ durable 时间线倒挂(消费端读到「子代
46
+ * 进度」排在触发它的那次重连之前)。钉在 `test/issue190-status-wire-disclosure.test.ts` §4⑤b。
47
+ *
48
+ * 🔴 **为什么可以不 flush**:本门只服务「只回结果」的两条腿(verify/cascade、resume-verify),它们的
49
+ * sink **从不收** `text_delta`/`reasoning_delta` —— `onForwardEvent` 有意不逐块持久化子代文本,而
50
+ * `onEvent` 在这些腿上根本没有调用点 ⇒ 没有可被倒挂的已缓冲文本。真有文本缓冲的腿(普通流形 / sync
51
+ * detach / resume stream)走 `onEvent` 或自己那份 `case`,那里的 **flush-first 纪律一个字不动**。
52
+ */
53
+ appendStatus(ev: Parameters<typeof brainStatusEventData>[0]): Promise<void>;
36
54
  /** Forward-sink (subagent) events → ledger, bg-leg posture: fire-and-forget with SYNCHRONOUS seq allocation
37
55
  * (sink-vs-loop appends get unique seqs), same whitelist+redact builders (forwarded events are UNTRUSTED
38
56
  * RAW — §E1). Child text/reasoning deltas are deliberately NOT persisted per-chunk (a child's turn text has
@@ -191,6 +191,10 @@ export function createLedgerSink(opts) {
191
191
  void append("tool_end", toolEndEventData(e)).catch(() => undefined);
192
192
  }
193
193
  };
194
+ // #255:第五席的写口 —— 一行,但**位置**是它的全部:`append` 在这里被**同步**调用,序号槽在回调进入
195
+ // 的那一刻就占住(理由与判据全文见接口上的 doc 注)。别为了「与 onEvent 同形」把它改写成
196
+ // `onEvent({type:"status",…})`:那一次 `await flush()` 正是被修掉的病。
197
+ const appendStatus = (ev) => append("status", brainStatusEventData(ev));
194
198
  const appendParked = (kind, result) =>
195
199
  // Never the capability checkpointToken — only the non-secret gate (search [18] Q1b).
196
200
  append(kind, { gate: result?.checkpointGate ?? null });
@@ -203,6 +207,6 @@ export function createLedgerSink(opts) {
203
207
  }
204
208
  await appendDone(result);
205
209
  };
206
- return { append, flush, onEvent, onForwardEvent, appendParked, appendDone, onDone };
210
+ return { append, flush, onEvent, appendStatus, onForwardEvent, appendParked, appendDone, onDone };
207
211
  }
208
212
  //# sourceMappingURL=ledger-sink.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.21.0-rc.1",
3
+ "version": "7.22.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^5.33.0",
57
+ "@sema-agent/core": "^5.34.0",
58
58
  "@sema-agent/registry-core": "^0.16.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",