@sema-agent/server 3.19.0 → 3.21.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.
@@ -304,12 +304,14 @@ export async function openStores(ctx) {
304
304
  }
305
305
  }
306
306
  const sessionStore = createSessionStore(config, backend, metrics); // S25: stale-affinity evict fingerprint
307
- // S6 startup guard: multi-tenant isolation needs an owner-aware (TiDB) store. Refuse to start with
308
- // REQUIRE_PRINCIPAL on an in-memory store that can't enforce session ownership.
309
- if (config.requirePrincipal && !sessionStore.ownerOf) {
310
- throw new Error("REQUIRE_PRINCIPAL=true needs an owner-aware session store (SESSION_BACKEND=tidb); " +
311
- "the in-memory store cannot enforce session ownership.");
312
- }
307
+ // 🪦 S6 启动门(旧):判据曾是 `!sessionStore.ownerOf`,core 2.11.0 TtlSessionStore 补了 owner 面后**恒假**。
308
+ // 2026-08-01 复审一度把判据改成「归属能否跨重启存活」以复活它 —— **那是错的,已撤**(cli [C46]
309
+ // 多租真机围栏当场证伪:`REQUIRE_PRINCIPAL=true` + 内存店必须**起得来**且真隔离)
310
+ // 错在威胁模型:内存店重启后**会话数据本身也没了**,"先到者认领 session id" 认领到的是空壳,
311
+ // alice 的历史随进程消失 —— 归属丢失 数据泄露。真正危险的是**数据留存而归属丢失**的 file 形,
312
+ // 那一形由下面那道 local 门拦住(它的论证里 "session/run CONTENT is durable there" 正是这个区别)。
313
+ // ⇒ 这道门防的威胁(store 没有归属能力)已被 core 2.11.0 消除,补偿该撤,不该换个判据续命。
314
+ // 判据跟目的走、不跟补偿走(cli [C46] 的原话,我抄下)。反向钉见 test/security.test.ts。
313
315
  // The local in-memory backend HAS an ownerOf (LocalSessionStore), so the guard above passes — but its owner map is
314
316
  // process-local + lost on restart, so it cannot DURABLY enforce multi-tenant ownership (post-restart a session id is
315
317
  // re-claimable by whoever attaches first). Refuse REQUIRE_PRINCIPAL on it: a real multi-tenant
@@ -1,4 +1,4 @@
1
- import { type BackgroundAgentStore, type CheckpointStore, type PromptProvider, type Runner, type SkillSpec, type SubagentSpawnContext, type ToolSpec, type WebSearchConfig } from "@sema-agent/core";
1
+ import { type WebFetchConfig, type BackgroundAgentStore, type CheckpointStore, type PromptProvider, type Runner, type SkillSpec, type SubagentSpawnContext, type ToolSpec, type WebSearchConfig } from "@sema-agent/core";
2
2
  import type { Metrics } from "../observability/metrics.js";
3
3
  import type { Logger } from "../observability/logger.js";
4
4
  import { GiteaClient } from "./repo-tools.js";
@@ -42,7 +42,7 @@ export interface ScenarioDeps {
42
42
  * main.ts 构造一次注入)。在场 ⇒ 装配实参携 `webFetch.summarize`——带 prompt 的 WebFetch 走 cheap
43
43
  * 模型摘要而非整页灌上下文([1870] L1 的 ~660× 放大就是这缝);缺席 ⇒ `webFetch` 键不铸(修前
44
44
  * 字节形,core 落「summarization unavailable」诚实兜底)。 */
45
- webFetchSummarize?: (content: string, prompt: string, signal?: AbortSignal) => Promise<string>;
45
+ webFetchSummarize?: NonNullable<NonNullable<WebFetchConfig["summarize"]>>;
46
46
  metrics?: Metrics;
47
47
  logger?: Logger;
48
48
  /** OA backend coordinates for the `oa` scenario (read /api/v1, write /api/assets). */
package/dist/config.d.ts CHANGED
@@ -11,6 +11,20 @@ export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat
11
11
  * config overlay (config-center/facade.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
12
12
  * stops managing `autonomy` (a stale center override must not stick — see applyRuntimeHot). */
13
13
  export declare function parseAutonomy(raw: string | undefined): Autonomy | undefined;
14
+ /** Numeric env with validation: a non-numeric value FAILS at startup instead of silently becoming `NaN`
15
+ * (which e.g. slips past the `runStaleSec` liveness guard and silently disables the reaper). */
16
+ export declare function numEnv(name: string, fallback: string): number;
17
+ /**
18
+ * 数值 env 的核心判据,**接受 raw 值**(而非自己去读 process.env),这样注入式配置也能复用同一条判据。
19
+ *
20
+ * 提出来的理由(2026-08-01 gap-sweep):`leader/wire.ts` 的四个资源旋钮各自手写了
21
+ * `Number(x) || default`,因为 numEnv 只会读 process.env、够不到它们注入进来的 env 对象。
22
+ * 结果是同一个失败模式在另一处复发,而且**回落方向更坏**:那边落到的是慷慨的默认值,
23
+ * 症状从「设了没生效」变成「想收紧却被松绑」。判据只有一份,谁都能调,才不会各写各的。
24
+ *
25
+ * `undefined`/空串 ⇒ 交给调用方的 fallback 语义处理(本函数只判「给了值但不是数」)。
26
+ */
27
+ export declare function parseNumOrFail(name: string, raw: string | undefined): number;
14
28
  export declare function drainConfigWarnings(): Array<{
15
29
  env: string;
16
30
  raw: string;
package/dist/config.js CHANGED
@@ -73,8 +73,22 @@ function env2(primary, alias, fallback) {
73
73
  }
74
74
  /** Numeric env with validation: a non-numeric value FAILS at startup instead of silently becoming `NaN`
75
75
  * (which e.g. slips past the `runStaleSec` liveness guard and silently disables the reaper). */
76
- function numEnv(name, fallback) {
77
- const raw = env(name, fallback);
76
+ export function numEnv(name, fallback) {
77
+ return parseNumOrFail(name, env(name, fallback));
78
+ }
79
+ /**
80
+ * 数值 env 的核心判据,**接受 raw 值**(而非自己去读 process.env),这样注入式配置也能复用同一条判据。
81
+ *
82
+ * 提出来的理由(2026-08-01 gap-sweep):`leader/wire.ts` 的四个资源旋钮各自手写了
83
+ * `Number(x) || default`,因为 numEnv 只会读 process.env、够不到它们注入进来的 env 对象。
84
+ * 结果是同一个失败模式在另一处复发,而且**回落方向更坏**:那边落到的是慷慨的默认值,
85
+ * 症状从「设了没生效」变成「想收紧却被松绑」。判据只有一份,谁都能调,才不会各写各的。
86
+ *
87
+ * `undefined`/空串 ⇒ 交给调用方的 fallback 语义处理(本函数只判「给了值但不是数」)。
88
+ */
89
+ export function parseNumOrFail(name, raw) {
90
+ if (raw === undefined)
91
+ return NaN;
78
92
  const n = Number(raw);
79
93
  if (!Number.isFinite(n))
80
94
  throw new Error(`env ${name}="${raw}" must be a number`);
@@ -291,6 +291,8 @@ export declare class FleetEventBus {
291
291
  releaseBackgroundChildLane(scope: string | undefined, childUuid: string): void;
292
292
  /** run-leg 车道:该子代是否已有 BCE 真行(有 ⇒ 让位,不铸复合 id 行)。 */
293
293
  backgroundChildLaneRow(scope: string | undefined, childUuid: string): string | undefined;
294
+ /** [2268] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
295
+ taskRow(id: string): Readonly<FleetTaskRow> | undefined;
294
296
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
295
297
  publishWorkflow(delta: Partial<FleetWorkflowRow> & {
296
298
  id: string;
@@ -86,6 +86,10 @@ export class FleetEventBus {
86
86
  backgroundChildLaneRow(scope, childUuid) {
87
87
  return this.bceClaims.get(FleetEventBus.bceKey(scope, childUuid));
88
88
  }
89
+ /** [2268] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
90
+ taskRow(id) {
91
+ return this.tasks.get(id);
92
+ }
89
93
  // └────────────────────────────────────────────────────────────────────────────────────────────────┘
90
94
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
91
95
  publishWorkflow(delta) {
@@ -172,20 +176,43 @@ export function fleetRunPublisher(bus, run) {
172
176
  * registry ⇒ 永远没有 BCE 真行,让位就等于让这些 agent 从面板消失。那一形保留复合行,并在帧上带
173
177
  * `sourceLane:"run-leg"` 明示这是过渡形——消费端不必再自己发明集合级去重判据。
174
178
  */
175
- const yieldToBackgroundChildLane = (childTaskId, cid) => {
176
- if (bus.backgroundChildLaneRow(run.scope, childTaskId) === undefined)
179
+ const yieldToBackgroundChildLane = (childTaskId, cid, tickName) => {
180
+ const bceRowId = bus.backgroundChildLaneRow(run.scope, childTaskId);
181
+ if (bceRowId === undefined)
177
182
  return false;
178
183
  if (children.has(cid)) {
179
184
  // 我们早于 claim 铸过一行 —— 清场(BCE 侧的后缀扫是同一意图的反应式兜底,两者幂等叠加无害)
180
185
  bus.removeTask(cid);
181
186
  children.delete(cid);
182
- childStartedAt.delete(cid);
187
+ // ⚠️ childStartedAt 故意不删 —— 它是下面 elapsed 供给的计时基准([2268])
183
188
  for (const [k, v] of childByToolCall)
184
189
  if (v === cid)
185
190
  childByToolCall.delete(k);
186
191
  }
192
+ // 🔴 [2268] 迁移料([2269] 认领「迁移」臂):此前让位=删「有名的 run-leg 行」留「无名的 BCE 行」
193
+ // ((unnamed)/0s/详情空白三症状同源)。tick 手上的显示料补给存活行,**只补缺席位** —— BCE 是权威
194
+ // 车道,它已有的 name/真 elapsed 一律不碰。elapsed 供给是持续的(本闸每拍都过),不是一次性冻结;
195
+ // elapsedDonor 记住「这行的 elapsed 是我们供的」,避免把 BCE 自己的真 elapsed 误当缺席重置。
196
+ // transcriptId 不在 run-leg tick 手上,迁不了(诚实范围;那半归 BCE 行自身后续 tick)。
197
+ const row = bus.taskRow(bceRowId);
198
+ if (row) {
199
+ const donorName = row.name === undefined && tickName !== undefined ? redactSecrets(tickName) : undefined;
200
+ if (!childStartedAt.has(cid))
201
+ childStartedAt.set(cid, Date.now());
202
+ if (elapsedDonor.has(cid) || row.elapsedMs === undefined || row.elapsedMs === 0)
203
+ elapsedDonor.add(cid);
204
+ const elapsed = elapsedDonor.has(cid) ? Date.now() - childStartedAt.get(cid) : undefined;
205
+ if (donorName !== undefined || elapsed !== undefined) {
206
+ bus.publishTask({
207
+ id: bceRowId,
208
+ ...(donorName !== undefined ? { name: donorName, agentType: donorName } : {}),
209
+ ...(elapsed !== undefined ? { elapsedMs: elapsed } : {}),
210
+ });
211
+ }
212
+ }
187
213
  return true;
188
214
  };
215
+ const elapsedDonor = new Set(); // [2268] 我们在为哪些 cid 供 elapsed(见上)
189
216
  const sumTokens = (usage) => {
190
217
  const u = usage;
191
218
  if (!u)
@@ -219,8 +246,8 @@ export function fleetRunPublisher(bus, run) {
219
246
  else if (ev.type === "task_progress" && ev.taskId) {
220
247
  // core 1.147 subagent usage tick (per-turn, cumulative) → a CHILD fleet row nested under the run.
221
248
  const cid = childId(ev.taskId);
222
- if (yieldToBackgroundChildLane(ev.taskId, cid))
223
- return; // [2070]① BCE 真行在场 ⇒ 让位不铸
249
+ if (yieldToBackgroundChildLane(ev.taskId, cid, ev.name))
250
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸([2268] 随迁显示料)
224
251
  children.add(cid);
225
252
  // BC-2 (core 1.151): use the subagent's sanitized display `name` (taskName/agent-type) for the child row.
226
253
  // [2070]③:name 缺席时**不铸**(此前回落 taskId=子代 uuid,人眼乱码)——行 IDENTITY 一直是 `cid`
@@ -273,8 +300,8 @@ export function fleetRunPublisher(bus, run) {
273
300
  if (ev.type !== "task_progress" || !ev.taskId)
274
301
  return;
275
302
  const cid = childId(ev.taskId);
276
- if (yieldToBackgroundChildLane(ev.taskId, cid))
277
- return; // [2070]① BCE 真行在场 ⇒ 让位不铸
303
+ if (yieldToBackgroundChildLane(ev.taskId, cid, ev.name))
304
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸([2268] 随迁显示料)
278
305
  children.add(cid);
279
306
  const parentId = ev.parentTaskId && ev.parentTaskId !== run.rootTaskId ? childId(ev.parentTaskId) : run.runId;
280
307
  // [WF2-A] redact the forwarded subagent name for parity with the top-level run name (see onEvent above).
@@ -0,0 +1,68 @@
1
+ /**
2
+ * [2252]/[2255]①/[2257] 追加件:409 `conflict.session_active_run` 的**真出路材料**装配(单源)。
3
+ *
4
+ * 事故形:park(suspended/needs_review)占着 session claim,客户端每条新提交吃 409,而旧文案只教
5
+ * cancel(毁灭当前 run)。对 parked 会话,「继续」的唯一合法动作是去**对的** resume 入口决议——但四条
6
+ * resume 入口各认各的 gate.kind,客户端不知道等的是哪种门,只能试错。本模块把答案放进 409 响应体:
7
+ * activeTaskStatus(区分「插话/取消」与「决议/取消」两族出路)
8
+ * pendingGate { kind, decidePath }(parked 且真有 pending checkpoint 时)
9
+ *
10
+ * 🔴 token 永不上 wire(approvals-assistant 纪律:resume 寻址=sessionId,checkpoint token 是秘密能力)。
11
+ * 🔴 失败方向:材料是 best-effort 增强——store 面任何失败都不得挡 409 本体,退化为旧形状,绝不 throw。
12
+ * 🔴 gateKind→入口映射是**这张表在仓里的唯一函数形**;它的知识此前散在 server.ts 三处 guard 文案里
13
+ * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
+ * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
+ */
16
+ /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
+ export declare const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run \u2014 POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
18
+ export interface ActiveRunConflictBody {
19
+ error: string;
20
+ errorCode: "conflict.session_active_run";
21
+ activeTaskId: string | null;
22
+ activeTaskStatus?: string;
23
+ pendingGate?: {
24
+ kind: string;
25
+ decidePath: string;
26
+ };
27
+ }
28
+ /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
29
+ export declare function resumeEntryForGate(kind: string, ids: {
30
+ sessionId: string;
31
+ taskId: string;
32
+ }): string | null;
33
+ /** token 泛型:真身是 branded CheckpointToken(秘密能力,只在本函数内部流转,绝不进响应体)。 */
34
+ /**
35
+ * SSE 车道的 done 帧 result(拒绝形)。与 409 body **同一铸体处** —— 此前 tasks.ts 在 res.write 里
36
+ * 手搓内联对象挑键,形状没有名字、没有类型、只活在那一行字符串里(conditional-spread 死键的老坑形)。
37
+ * 这里铸,SDK 的 done 帧 result 类型与本形同车对齐(sdk-api-guarantee-duty),live-contract 套件对已发包真跑。
38
+ */
39
+ export interface ActiveRunConflictDoneResult {
40
+ status: "failed";
41
+ errorMessage: string;
42
+ activeTaskId: string | null;
43
+ activeTaskStatus?: string;
44
+ pendingGate?: {
45
+ kind: string;
46
+ decidePath: string;
47
+ };
48
+ }
49
+ export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
50
+ interface ConflictProbeDeps<TToken> {
51
+ runStore?: {
52
+ getRun?: (id: string) => Promise<{
53
+ status?: string;
54
+ } | null | undefined>;
55
+ } | undefined;
56
+ checkpointStore?: {
57
+ peekPendingScope?: (sessionId: string) => Promise<string | null | undefined>;
58
+ findPendingTokenBySession?: (sessionId: string, scope?: string) => Promise<TToken | null | undefined>;
59
+ get?: (token: TToken) => Promise<{
60
+ gate?: {
61
+ kind?: string;
62
+ };
63
+ } | null | undefined>;
64
+ } | undefined;
65
+ }
66
+ export declare function buildActiveRunConflict<TToken>(deps: ConflictProbeDeps<TToken>, sessionId: string, activeTaskId: string | null | undefined): Promise<ActiveRunConflictBody>;
67
+ export {};
68
+ //# sourceMappingURL=active-run-conflict.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * [2252]/[2255]①/[2257] 追加件:409 `conflict.session_active_run` 的**真出路材料**装配(单源)。
3
+ *
4
+ * 事故形:park(suspended/needs_review)占着 session claim,客户端每条新提交吃 409,而旧文案只教
5
+ * cancel(毁灭当前 run)。对 parked 会话,「继续」的唯一合法动作是去**对的** resume 入口决议——但四条
6
+ * resume 入口各认各的 gate.kind,客户端不知道等的是哪种门,只能试错。本模块把答案放进 409 响应体:
7
+ * activeTaskStatus(区分「插话/取消」与「决议/取消」两族出路)
8
+ * pendingGate { kind, decidePath }(parked 且真有 pending checkpoint 时)
9
+ *
10
+ * 🔴 token 永不上 wire(approvals-assistant 纪律:resume 寻址=sessionId,checkpoint token 是秘密能力)。
11
+ * 🔴 失败方向:材料是 best-effort 增强——store 面任何失败都不得挡 409 本体,退化为旧形状,绝不 throw。
12
+ * 🔴 gateKind→入口映射是**这张表在仓里的唯一函数形**;它的知识此前散在 server.ts 三处 guard 文案里
13
+ * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
+ * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
+ */
16
+ /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
+ export const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
18
+ /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
19
+ export function resumeEntryForGate(kind, ids) {
20
+ switch (kind) {
21
+ case "tool_approval":
22
+ case "human":
23
+ case "irreversible_ask":
24
+ case "policy_ask":
25
+ return `/v1/approvals/${ids.sessionId}/decide`;
26
+ case "plan_review":
27
+ return `/v1/assistant/tasks/${ids.taskId}/plan_review`;
28
+ case "resource_limit":
29
+ return `/v1/assistant/tasks/${ids.taskId}/resume`;
30
+ case "task_done":
31
+ return `/v1/sessions/${ids.sessionId}/wake`;
32
+ default:
33
+ return null; // 未知门型:宁缺毋假 —— 客户端仍有 activeTaskStatus + cancel 这条保底真路
34
+ }
35
+ }
36
+ export function toDoneFrameResult(body) {
37
+ return {
38
+ status: "failed",
39
+ errorMessage: body.error,
40
+ activeTaskId: body.activeTaskId,
41
+ ...(body.activeTaskStatus !== undefined ? { activeTaskStatus: body.activeTaskStatus } : {}),
42
+ ...(body.pendingGate !== undefined ? { pendingGate: body.pendingGate } : {}),
43
+ };
44
+ }
45
+ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
46
+ const base = {
47
+ error: ACTIVE_RUN_CONFLICT_BASE_TEXT,
48
+ errorCode: "conflict.session_active_run",
49
+ activeTaskId: activeTaskId ?? null,
50
+ };
51
+ if (!activeTaskId)
52
+ return base;
53
+ try {
54
+ const row = await deps.runStore?.getRun?.(activeTaskId);
55
+ const status = row?.status;
56
+ if (status !== "running" && status !== "suspended" && status !== "needs_review")
57
+ return base;
58
+ if (status === "running") {
59
+ return {
60
+ ...base,
61
+ error: "session already has an active run — POST /v1/runs/{activeTaskId}/steer injects a message into the running turn (queued, applied at the next turn boundary), or POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)",
62
+ activeTaskStatus: status,
63
+ };
64
+ }
65
+ // parked:找 pending checkpoint 的门型,指到它的那一个 resume 入口
66
+ const cs = deps.checkpointStore;
67
+ let pendingGate;
68
+ if (cs?.findPendingTokenBySession) {
69
+ const scope = (await cs.peekPendingScope?.(sessionId)) ?? undefined;
70
+ const token = scope === null ? null : await cs.findPendingTokenBySession(sessionId, scope);
71
+ const kind = token ? (await cs.get?.(token))?.gate?.kind : undefined;
72
+ const decidePath = kind ? resumeEntryForGate(kind, { sessionId, taskId: activeTaskId }) : null;
73
+ if (kind && decidePath)
74
+ pendingGate = { kind, decidePath };
75
+ }
76
+ return {
77
+ ...base,
78
+ error: pendingGate
79
+ ? `session already has an active run — it is parked waiting for a decision (${status}); resolve it via POST ${pendingGate.decidePath}, or POST /v1/runs/{activeTaskId}/cancel abandons it`
80
+ : `session already has an active run — it is parked (${status}); POST /v1/runs/{activeTaskId}/cancel abandons it (its pending decision could not be located on this instance)`,
81
+ activeTaskStatus: status,
82
+ ...(pendingGate ? { pendingGate } : {}),
83
+ };
84
+ }
85
+ catch {
86
+ return base; // 材料装配的任何失败都退化为旧形状 —— 增强绝不成为新故障点
87
+ }
88
+ }
89
+ //# sourceMappingURL=active-run-conflict.js.map
@@ -10,6 +10,7 @@ import { scopedIdempotencyKey } from "../idempotency.js";
10
10
  import { streamSseLog } from "../sse-log.js";
11
11
  import { normalizeRunEventType } from "../../trace/project.js";
12
12
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
13
+ import { buildActiveRunConflict } from "../active-run-conflict.js";
13
14
  import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
14
15
  // server.ts 侧的 routeLabel / isBillableSubmitPath 仍要用下面这些正则,故本模块导出(方向恒为 server.ts → routes/*)。
15
16
  export const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
@@ -161,7 +162,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
161
162
  if (clientTaskId && created.activeTaskId === clientTaskId) {
162
163
  return { status: 202, body: { taskId: clientTaskId, sessionId, status: "running" } };
163
164
  }
164
- return { status: 409, body: { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId } };
165
+ // [2255]① 真出路材料:activeTaskStatus + parked pendingGate{kind,decidePath}(best-effort,失败退旧形)
166
+ return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore }, sessionId, created.activeTaskId) };
165
167
  }
166
168
  // Durable F4: persist the resume rebuild inputs (sessionId-keyed) so an operator can resume from any
167
169
  // replica even after this worker is gone — core's checkpoint blob can't carry service scenario context.
@@ -10,6 +10,7 @@ import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEve
10
10
  import { cascadeConfig, runMeta } from "../run-meta.js";
11
11
  import { scopedIdempotencyKey } from "../idempotency.js";
12
12
  import { sendJson, sendError, sseHeaders } from "../send.js";
13
+ import { buildActiveRunConflict, toDoneFrameResult } from "../active-run-conflict.js";
13
14
  import { headerStr, gatedPrincipal } from "../principal-gate.js";
14
15
  export async function handleTasks(req, res, url, ctx) {
15
16
  const miss = { fell: false };
@@ -216,10 +217,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
216
217
  if (created.ok)
217
218
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget; in-titler dedupe;model 跟 turn([1992]②)
218
219
  if (!created.ok) {
219
- const conflict = { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId };
220
+ const conflict = await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore }, prepared.spec.sessionId, created.activeTaskId); // [2255]① 真出路材料
220
221
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
221
222
  // the returned resp is for a deduplicated concurrent caller (and is never idem-cached).
222
- res.write(`data: ${JSON.stringify({ type: "done", result: { status: "failed", errorMessage: conflict.error, activeTaskId: conflict.activeTaskId } })}\n\n`);
223
+ // done additive 携带同一份材料 —— 形状铸在 toDoneFrameResult(有名字有类型),不在这行内联挑键。
224
+ res.write(`data: ${JSON.stringify({ type: "done", result: toDoneFrameResult(conflict) })}\n\n`);
223
225
  return { status: 409, body: conflict };
224
226
  }
225
227
  durableTaskId = tid;
@@ -971,7 +973,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
971
973
  const tid = uuidv7();
972
974
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
973
975
  if (!created.ok)
974
- return { status: 409, body: { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId } };
976
+ return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore }, prepared.spec.sessionId, created.activeTaskId) }; // [2255]① 真出路材料
975
977
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model 跟 turn([1992]②)
976
978
  durableTaskId = tid;
977
979
  }
@@ -5,7 +5,7 @@ import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, Checkpoin
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
6
  import { matchCatalogModel } from "../model-select.js";
7
7
  import {} from "../config-center/facade.js";
8
- import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, verifyDirectDoorProof } from "../security.js";
8
+ import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, verifyDirectDoorProof, isDestructiveSessionWrite } from "../security.js";
9
9
  import {} from "../memory-sync.js";
10
10
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../task-settings.js";
11
11
  import { parseHooksConfig } from "../hooks/hook-runner.js";
@@ -533,32 +533,16 @@ export function createHttpServer(rawDeps) {
533
533
  sendError(res, 503, "state.model_roster_pending", "model_roster_pending", { message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly" });
534
534
  return;
535
535
  }
536
- // E6 (review): the operator session-policy PUT is a mutating operator-gated write — apply the SAME fail-closed
537
- // no-service-token guard as the POST submit paths (an un-authed worker must not accept policy writes from anything
538
- // in-cluster; without it a forged principal header alone could tighten/DoS a session's tools).
539
- if (req.method === "PUT" &&
540
- !anyServiceAuth &&
541
- !deps.config.allowUnauthedWrites &&
542
- /^\/v1\/sessions\/[^/]+\/policy$/.test(url)) {
543
- sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting session-policy writes");
544
- return;
545
- }
546
- // 🔴 E6 的**兄弟路径**(2026-08-01 缝合审):上面那道门只挂在 policy 的 PUT 腿上,而 session-sync 的
547
- // import 提交腿(`routes/session-sync.ts` 的 ② policy 段)对**同一个 sessionPolicyStore** 调 `putRules`。
548
- // 该腿的授权门是 `sessionOwnerScopeForWrite`,在「无 service token + 未开 ALLOW_UNAUTHED_WRITES +
549
- // 未开 REQUIRE_PRINCIPAL」这一形下是 fleet-wide ⇒ 集群内**任意**调用方可对**任意** session 走两阶段
550
- // import,顺带重写它的 policy 规则。`operatorOk` 走 explicitOperatorOk(无 operator 名单时为 false)
551
- // ⇒ 只能 tighten 不能放宽 —— 但上面那道门自己写明的威胁就是「forged principal 可以 **tighten/DoS**
552
- // 一个会话的工具」,严丝合缝落在同一句话上。
536
+ // E6 fail-closed:对**既有会话**的破坏性写,在「无 service token + 未开 ALLOW_UNAUTHED_WRITES」
537
+ // 这一形下一律拒。此形下 `sessionOwnerScopeForWrite` fleet-wide 集群内任意调用方可对任意
538
+ // session 动手;policy 腿担心的是「forged principal tighten/DoS 一个会话的工具」。
553
539
  //
554
- // 拦在 **Phase A**( staging 之前),不是 Phase B:否则会先落一半状态再拒。
555
- // 辖域取整条 import 而非只跳过 policy 段 —— 同一个理由:这一形下不信任调用方写 policy,就没有道理
556
- // 信任它整段替换会话的 entries(import 的破坏面比 policy 更大)
557
- if (req.method === "POST" &&
558
- !anyServiceAuth &&
559
- !deps.config.allowUnauthedWrites &&
560
- /^\/v1\/sessions\/[^/]+\/sync\/import$/.test(url)) {
561
- sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting a session-sync import");
540
+ // 🔴 2026-08-01 复审:这里原本是**逐条路由白名单**(PUT …/policy POST …/sync/import 两块),
541
+ // `DELETE /v1/sessions/:id`(删整条会话)**不在其中** —— 同一威胁形、爆炸半径更大却没门。
542
+ // 改成族判定(`isDestructiveSessionWrite`),新增破坏性会话路由自动落进门内。
543
+ // import 腿仍拦在 **Phase A**( staging 之前),因为族判据匹配的就是那个 URL,不是 Phase B。
544
+ if (isDestructiveSessionWrite(req.method ?? "", url) && !anyServiceAuth && !deps.config.allowUnauthedWrites) {
545
+ sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting destructive session writes (policy / sync-import / fork / delete)");
562
546
  return;
563
547
  }
564
548
  // design/158 A9:能力/场景/模型目录域(routes/capabilities.ts)。位置=全局 service-credential 门之后、
@@ -11,6 +11,7 @@
11
11
  * network; the MVP seeds the base from the Coordinator (no worker→remote net). MVP Planner = caller-provided
12
12
  * `subtasks` (an LLM DAG-planner is a follow-up).
13
13
  */
14
+ import { parseNumOrFail } from "../config.js";
14
15
  import { execFileSync } from "node:child_process";
15
16
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
17
  import { join } from "node:path";
@@ -104,14 +105,19 @@ export async function stageWorkerEnv(env, opts) {
104
105
  * (URL) silently killed long autonomous builds or expired the diff upload mid-run. core's turn cap is now a
105
106
  * high net (10000), so the leader gives each worker an explicit triple bound; a sub-task's own limits override.
106
107
  */
108
+ // 🔴 2026-08-01 gap-sweep:四个旋钮原为 `Number(x) || default` —— 非法值(`"20usd"`/`"1800000ms"`)
109
+ // 得 NaN 后**静默落到慷慨的默认值**,方向是**松绑**:运维想把 worker 预算从 $100 收到 $20,实际拿到
110
+ // $100(5 倍);想把寿命从 24h 收到 30min,实际拿到 24h,而 leaderTimeoutMs 还级联 presignTtlSec
111
+ // ⇒ diff 上传预签名 URL 的暴露窗口一起放大。改走与 numEnv 同源的 parseNumOrFail:非法值当场报错。
112
+ // 刻意**零行为变更**:`|| default` 保留,所以未设与显式 0 的既有语义一个没动。
107
113
  export function leaderResourceConfig(env = process.env) {
108
- const leaderTimeoutMs = Math.max(600_000, Math.floor(Number(env.LEADER_TIMEOUT_MS ?? "0")) || 86_400_000); // default 24h (was 10min)
109
- const workerMaxTurns = Math.floor(Number(env.LEADER_WORKER_MAX_TURNS ?? "0")) || 10_000;
110
- const workerBudgetUsd = Number(env.LEADER_WORKER_BUDGET_USD ?? "0") || 100;
114
+ const leaderTimeoutMs = Math.max(600_000, Math.floor(parseNumOrFail("LEADER_TIMEOUT_MS", env.LEADER_TIMEOUT_MS) || 0) || 86_400_000); // default 24h (was 10min)
115
+ const workerMaxTurns = Math.floor(parseNumOrFail("LEADER_WORKER_MAX_TURNS", env.LEADER_WORKER_MAX_TURNS) || 0) || 10_000;
116
+ const workerBudgetUsd = (parseNumOrFail("LEADER_WORKER_BUDGET_USD", env.LEADER_WORKER_BUDGET_USD) || 0) || 100;
111
117
  const resourceSuspendOn = String(env.LEADER_RESOURCE_SUSPEND ?? "").toLowerCase() === "true";
112
118
  // Per-slice window: a fraction of the total so a worker suspends+resumes several times across its budget
113
119
  // (default = total/4, min $1). Smaller window ⇒ more, shorter slices ⇒ progress saved more often.
114
- const sliceMaxCostUsd = Number(env.LEADER_WORKER_SLICE_BUDGET_USD ?? "0") || Math.max(1, workerBudgetUsd / 4);
120
+ const sliceMaxCostUsd = (parseNumOrFail("LEADER_WORKER_SLICE_BUDGET_USD", env.LEADER_WORKER_SLICE_BUDGET_USD) || 0) || Math.max(1, workerBudgetUsd / 4);
115
121
  return {
116
122
  leaderTimeoutMs,
117
123
  workerMaxTurns,
package/dist/main.js CHANGED
@@ -7,7 +7,7 @@ import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
8
8
  import { subagentSendUserFileExtraTools } from "./capabilities/send-user-file-tool.js";
9
9
  import { brainSummary } from "./brain.js";
10
- import { loadConfig, logConfigDiagnostics, resolveBindHost, splitLocalRoots, parseCapEnv } from "./config.js";
10
+ import { loadConfig, logConfigDiagnostics, resolveBindHost, splitLocalRoots, parseCapEnv, numEnv } from "./config.js";
11
11
  import { isPricingConfigured } from "./budget.js"; // 缝合审 M3:capabilities.pricingConfigured 的单一真源判据
12
12
  import { drainNumEnvWarnings } from "./plugins/remote-shell.js";
13
13
  import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.js";
@@ -362,7 +362,12 @@ async function main() {
362
362
  // Per-task wall-clock override (seconds). 0/absent = keep the built-in 600 (1500 council/debate/team) —
363
363
  // the override only ever RAISES (Math.max at the use site), so a misconfigured low value cannot shrink
364
364
  // the council budget.
365
- const taskTimeoutSec = Math.max(0, Math.floor(Number(process.env.TASK_TIMEOUT_SEC ?? "0")) || 0);
365
+ // 🔴 2026-08-01 env fail-loud 族:原为 `Math.max(0, Math.floor(Number(env ?? "0")) || 0)` ——
366
+ // `TASK_TIMEOUT_SEC="3600s"`(带单位后缀,运维最常见的写法)得 NaN,再被 `|| 0` 折成 0,
367
+ // 而 0 在这里的语义是**无超时**。运维以为设了一小时上限,实际把超时关掉了,方向恰好相反,
368
+ // 且启动期零提示。改走 numEnv:非法值当场 fail-loud 并指名键与实际值。
369
+ // (numEnv 的注释早就预言了这个失败模式,只是它没 export、跨文件够不到,于是这两处手写了。)
370
+ const taskTimeoutSec = Math.max(0, Math.floor(numEnv("TASK_TIMEOUT_SEC", "0")));
366
371
  // [854]④ per-request 配速的运营方上限旋钮(可选;缺省不设=不封顶):body.limits 每键各自被对应旋钮
367
372
  // Math.min 封顶(normalizeLimits)。多租部署想约束 caller 自报配速时才设;单用户 turnkey 通常留空。
368
373
  // ⚠️ 语义=只钳「显式请求值」,不是全队默认限额:body 缺席(或重放体里的非法值被 defensive DROP)⇒ 该键
@@ -552,6 +557,9 @@ async function main() {
552
557
  // ② **fail-soft**:core resolveTaskModel 在无可解析角色时 throw,这是全仓唯一 boot 期裸调用;
553
558
  // center 发一张既无 summarize 又无 default 的 roles 表就会拒启。解析失败 ⇒ 本次不摘要
554
559
  // (core 的 summarize 抛错是 fail-open:回退原文+note),绝不崩 boot。
560
+ // core 2.13.0:返回形放宽为 additive union(`string | { text, truncated? }`)—— 这里**原样转发**
561
+ // core summarizer 的返回值,不在本仓收窄成 string(收窄会把 core 的截断披露 `truncated` 吃掉,
562
+ // 让「内容被截断」这个事实在 fence 外消失)。
555
563
  const webFetchSummarize = async (content, prompt, signal) => {
556
564
  const model = (() => {
557
565
  try {
@@ -293,8 +293,17 @@ export class FileRunStore {
293
293
  * [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
294
294
  async setTerminal(taskId, status, result, error) {
295
295
  const r = this.runs.get(taskId);
296
- if (!r)
296
+ if (!r) {
297
+ // [2255]③ claim 孤儿修:行不在(跨进程窗 —— claim 落盘、行未落/已失;R9 只兜同进程写失败)时,
298
+ // 终局动词仍必须能放锁:cancel 是文案承诺的用户自救路,它对孤儿失效=会话钉死到 reaper/boot。
299
+ // 反查 active 索引找持有该 taskId 的 session(O(claims),claims 数=活跃会话数,小)。
300
+ for (const [sessionId, tid] of this.active)
301
+ if (tid === taskId) {
302
+ this.releaseClaim(sessionId);
303
+ break;
304
+ }
297
305
  return;
306
+ }
298
307
  if (r.status === "running" || r.status === "suspended" || r.status === "needs_review") {
299
308
  r.status = status;
300
309
  r.result = result;
@@ -535,8 +544,10 @@ export class FileRunStore {
535
544
  */
536
545
  async reapSuspended(olderThanMs) {
537
546
  const probe = this.checkpointProbe;
538
- if (!probe)
547
+ if (!probe) {
548
+ this.sweepClaims();
539
549
  return 0;
550
+ } // [2255]③:时间腿无 probe=诚实 NO-OP,但 claim 不变式维护不跟 probe 走
540
551
  const cutoff = Date.now() - olderThanMs;
541
552
  let reaped = 0;
542
553
  for (const r of this.runs.values()) {
@@ -566,8 +577,10 @@ export class FileRunStore {
566
577
  */
567
578
  async failSuspendedWithExpiredCheckpoint() {
568
579
  const probe = this.checkpointProbe;
569
- if (!probe)
580
+ if (!probe) {
581
+ this.sweepClaims();
570
582
  return 0;
583
+ } // [2255]③ 同上
571
584
  let reaped = 0;
572
585
  for (const r of this.runs.values()) {
573
586
  if (r.status !== "suspended" && r.status !== "needs_review")
@@ -127,8 +127,15 @@ export class MemoryRunStore {
127
127
  * [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
128
128
  async setTerminal(taskId, status, result, error) {
129
129
  const r = this.runs.get(taskId);
130
- if (!r)
130
+ if (!r) {
131
+ // [2255]③ file 孪生同判据(parity oracle):行缺席时终局动词仍放锁(反查 active)。
132
+ for (const [sessionId, tid] of this.active)
133
+ if (tid === taskId) {
134
+ this.active.delete(sessionId);
135
+ break;
136
+ }
131
137
  return;
138
+ }
132
139
  if (r.status === "running" || r.status === "suspended" || r.status === "needs_review") {
133
140
  r.status = status;
134
141
  r.result = result;
@@ -328,8 +335,10 @@ export class MemoryRunStore {
328
335
  */
329
336
  async reapSuspended(olderThanMs) {
330
337
  const probe = this.checkpointProbe;
331
- if (!probe)
338
+ if (!probe) {
339
+ this.releaseTerminalClaims();
332
340
  return 0;
341
+ } // [2255]③ claim 不变式维护不跟 probe 走
333
342
  const cutoff = Date.now() - olderThanMs;
334
343
  let reaped = 0;
335
344
  for (const r of this.runs.values()) {
@@ -358,8 +367,10 @@ export class MemoryRunStore {
358
367
  */
359
368
  async failSuspendedWithExpiredCheckpoint() {
360
369
  const probe = this.checkpointProbe;
361
- if (!probe)
370
+ if (!probe) {
371
+ this.releaseTerminalClaims();
362
372
  return 0;
373
+ } // [2255]③ claim 不变式维护不跟 probe 走
363
374
  let reaped = 0;
364
375
  for (const r of this.runs.values()) {
365
376
  if (r.status !== "suspended" && r.status !== "needs_review")
@@ -520,6 +520,12 @@ export class SqlRunStore {
520
520
  // claim — the session stays locked until an operator resumes it or reapSuspended expires it. (Was
521
521
  // `<> 'running'`, which would have prematurely unlocked a parked suspended session.)
522
522
  await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.status NOT IN ('running','suspended','needs_review')", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.status NOT IN ('running','suspended','needs_review')"));
523
+ // [2255]③ 孤儿 claim 清扫(纵深防御):claim 无对应 task_run 行时,三处 INNER JOIN 释放腿永远碰不到
524
+ // 它。正常路径不产孤儿(createRun 单事务、setTerminal 无条件 DELETE、deleteBySession 同事务对称)——
525
+ // 这形只来自带外(手工 SQL/半截迁移),但一旦出现该 session 只剩 cancel 一条自救路。挂在 reapStale
526
+ // (每 tick 无条件跑)一处即可。同文双方言(关联 NOT EXISTS 指向**另一张**表,两引擎都合法);
527
+ // 无竞态:createRun 的 claim+row 同事务提交,已提交的 claim 必有行。
528
+ await conn.query(this.q("DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)", "DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)"));
523
529
  });
524
530
  return reaped;
525
531
  }
@@ -90,6 +90,20 @@ export declare function setWebSearchBadPayloadObserver(fn: ((provider: string) =
90
90
  * stays captured in this closure — it is never surfaced to the model or the tool args.
91
91
  */
92
92
  export declare function createWebSearchBackend(cfg: WebSearchBackendConfig): WebSearchBackend;
93
+ /**
94
+ * `WEB_SEARCH_SEARXNG_PARAMS` / `settings.webSearch.searxngParams` 的解析 —— SearXNG 实例侧参数
95
+ * (`engines=` / `language=` 等)。
96
+ *
97
+ * 🔴 [2240] cli 逐串直证:3.18.0 我加了 `searxngParams` 字段和消费点,**却没开任何配置入口** ——
98
+ * 两条门都没有位置填它,于是我在 [2239] 说的「这条是给你们的」是句空话。单测直接构造
99
+ * `WebSearchBackendConfig` 传进去,**天然绕过了配置入口这一层**,于是「能力在」与「配得进」之间
100
+ * 断开,而两边的绿都是真的。判据:**按消费端实际能填的那个入口验,不是按自己构造的对象验。**
101
+ *
102
+ * 形状:`k=v` 用 `;` 分隔(`engines=bing,duckduckgo;language=zh-CN`)——值里本来就常含逗号,
103
+ * 所以分隔符取 `;` 而不是 `,`。整串解析不出任何一对 ⇒ **返回 undefined,键整个不铸**
104
+ * (不产出空对象:空对象会让下游以为「配了但是空的」,与本仓「缺席就要缺席得干净」同源)。
105
+ */
106
+ export declare function parseSearxngParams(raw: unknown): Record<string, string> | undefined;
93
107
  /**
94
108
  * #81④ 探活门控 —— **默认 OFF**,显式开才探。
95
109
  *
@@ -152,11 +152,17 @@ async function tavilySearch(fetchImpl, cfg, query, max, signal, opts) {
152
152
  * ⚠️ core adapter **不管**这三样,必须留在外层(换真源最容易丢的就是它们,已上红先钉):
153
153
  * ① `maxResults` 截断 ② 统一形状 `{title,url,snippet}` ③ 坏 payload 观测器。
154
154
  */
155
- async function searxngSearch(fetchImpl, cfg, query, max, signal, opts) {
155
+ async function searxngSearch(fetchImpl, cfg, query, max, signal, timeoutMs, opts) {
156
156
  if (!cfg.endpoint)
157
157
  throw new Error("WEB_SEARCH_ENDPOINT (the SearXNG instance URL) is required for the searxng provider");
158
+ // 🔴 timeoutMs 必须显式传:core 的 adapter 内部是 `options.timeoutMs ?? 10_000`,不传就用它自己的
159
+ // 10 秒。外层 createWebSearchBackend 那道 withTimeout 只在**更短**时先到 —— 部署方把
160
+ // WEB_SEARCH_TIMEOUT_MS 设成大于 10s 的值(慢实例/自建 SearXNG 的常见需要)会被**静默封顶在 10s**,
161
+ // 而 3.18.0 的 CHANGELOG 还把 core「自带 timeout」当纯收益卖(见 3.20.0 的更正段)。
162
+ // 两层同值不冲突:内层是 adapter 自己的 AbortSignal.any,外层是本文件的 withTimeout。
158
163
  const search = createSearxngSearchBackend(cfg.endpoint, {
159
164
  fetchImpl,
165
+ timeoutMs,
160
166
  ...(cfg.searxngParams ? { extraParams: cfg.searxngParams } : {}),
161
167
  });
162
168
  let rows;
@@ -196,7 +202,7 @@ export function createWebSearchBackend(cfg) {
196
202
  switch (cfg.provider) {
197
203
  case "brave": return await braveSearch(fetchImpl, cfg, q, maxResults, sig);
198
204
  case "tavily": return await tavilySearch(fetchImpl, cfg, q, maxResults, sig, opts);
199
- case "searxng": return await searxngSearch(fetchImpl, cfg, q, maxResults, sig, opts);
205
+ case "searxng": return await searxngSearch(fetchImpl, cfg, q, maxResults, sig, timeoutMs, opts);
200
206
  }
201
207
  }
202
208
  finally {
@@ -205,6 +211,41 @@ export function createWebSearchBackend(cfg) {
205
211
  };
206
212
  return { search, maxResults };
207
213
  }
214
+ /**
215
+ * `WEB_SEARCH_SEARXNG_PARAMS` / `settings.webSearch.searxngParams` 的解析 —— SearXNG 实例侧参数
216
+ * (`engines=` / `language=` 等)。
217
+ *
218
+ * 🔴 [2240] cli 逐串直证:3.18.0 我加了 `searxngParams` 字段和消费点,**却没开任何配置入口** ——
219
+ * 两条门都没有位置填它,于是我在 [2239] 说的「这条是给你们的」是句空话。单测直接构造
220
+ * `WebSearchBackendConfig` 传进去,**天然绕过了配置入口这一层**,于是「能力在」与「配得进」之间
221
+ * 断开,而两边的绿都是真的。判据:**按消费端实际能填的那个入口验,不是按自己构造的对象验。**
222
+ *
223
+ * 形状:`k=v` 用 `;` 分隔(`engines=bing,duckduckgo;language=zh-CN`)——值里本来就常含逗号,
224
+ * 所以分隔符取 `;` 而不是 `,`。整串解析不出任何一对 ⇒ **返回 undefined,键整个不铸**
225
+ * (不产出空对象:空对象会让下游以为「配了但是空的」,与本仓「缺席就要缺席得干净」同源)。
226
+ */
227
+ export function parseSearxngParams(raw) {
228
+ if (typeof raw === "object" && raw !== null) {
229
+ // settings 门传的是已解析的表;只收「字符串→字符串」,任一值非字符串 ⇒ 整表不铸(不半吞)。
230
+ const entries = Object.entries(raw);
231
+ if (entries.length === 0 || entries.some(([, v]) => typeof v !== "string"))
232
+ return undefined;
233
+ return Object.fromEntries(entries);
234
+ }
235
+ if (typeof raw !== "string")
236
+ return undefined;
237
+ const out = {};
238
+ for (const pair of raw.split(";")) {
239
+ const i = pair.indexOf("=");
240
+ if (i <= 0)
241
+ continue;
242
+ const k = pair.slice(0, i).trim();
243
+ const v = pair.slice(i + 1).trim();
244
+ if (k && v)
245
+ out[k] = v;
246
+ }
247
+ return Object.keys(out).length > 0 ? out : undefined;
248
+ }
208
249
  /**
209
250
  * #81④ 探活门控 —— **默认 OFF**,显式开才探。
210
251
  *
@@ -224,12 +265,14 @@ export function webSearchConfigFromEnv(env = process.env) {
224
265
  const provider = env.WEB_SEARCH_PROVIDER?.trim().toLowerCase();
225
266
  if (provider !== "brave" && provider !== "tavily" && provider !== "searxng")
226
267
  return undefined;
268
+ const searxngParams = parseSearxngParams(env.WEB_SEARCH_SEARXNG_PARAMS); // [2240] 部署 env 门
227
269
  const maxRaw = Number(env.WEB_SEARCH_MAX_RESULTS ?? "");
228
270
  const timeoutRaw = Number(env.WEB_SEARCH_TIMEOUT_MS ?? "");
229
271
  return {
230
272
  provider,
231
273
  ...(env.WEB_SEARCH_API_KEY ? { apiKey: env.WEB_SEARCH_API_KEY } : {}),
232
274
  ...(env.WEB_SEARCH_ENDPOINT ? { endpoint: env.WEB_SEARCH_ENDPOINT } : {}),
275
+ ...(searxngParams ? { searxngParams } : {}), // [2240] 解析不出任何一对 ⇒ 键整个不铸(不产出空对象)
233
276
  ...(Number.isFinite(maxRaw) && maxRaw > 0 ? { maxResults: Math.floor(maxRaw) } : {}),
234
277
  ...(Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? { timeoutMs: Math.floor(timeoutRaw) } : {}),
235
278
  };
@@ -253,6 +296,12 @@ export function webSearchConfigFromSettings(raw) {
253
296
  provider,
254
297
  ...(typeof s.apiKey === "string" && s.apiKey ? { apiKey: s.apiKey } : {}),
255
298
  ...(typeof s.endpoint === "string" && s.endpoint ? { endpoint: s.endpoint } : {}),
299
+ // [2240]:与 endpoint **同门同辖域** —— 安全边界不在字段粒度,而在调用方把整条门 gate 到
300
+ // 单用户 host lane(见本函数头注的 SSRF 段)。单独把它挡在门外是个没有理由的例外。
301
+ ...(() => {
302
+ const p = parseSearxngParams(s.searxngParams);
303
+ return p ? { searxngParams: p } : {};
304
+ })(),
256
305
  ...(max ? { maxResults: max } : {}),
257
306
  };
258
307
  }
package/dist/run-local.js CHANGED
@@ -31,7 +31,7 @@ import { createBrain } from "./brain.js";
31
31
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
32
32
  import { taskWallClockSec } from "./task-workflow.js";
33
33
  import { validatePromptsDomain, applyCatalogToSource, CORE_ENGINE_VERSION } from "./capabilities/center-prompts.js";
34
- import { loadConfig, logConfigDiagnostics } from "./config.js";
34
+ import { loadConfig, logConfigDiagnostics, numEnv } from "./config.js";
35
35
  import { createConfigProvider } from "./config-provider.js";
36
36
  import { applyEffective, resolveMcpServers, mcpForScenario } from "./config-center/facade.js";
37
37
  import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
@@ -440,7 +440,12 @@ export async function runLocal(argv, deps = {}) {
440
440
  // wins with its raise-only floor (team 1500s — a multi-agent coordinator legitimately exceeds 600s).
441
441
  // [854]④ 不落此腿:per-request body.limits 是 HTTP body 车道(resolveTaskLimits,main.ts resolveSpec);
442
442
  // run-local 无 body,配速仍走 env(要 CLI flag 时另开件,别在这里半做)。
443
- const taskTimeoutSec = Math.max(0, Math.floor(Number(process.env.TASK_TIMEOUT_SEC ?? "0")) || 0);
443
+ // 🔴 2026-08-01 env fail-loud 族:原为 `Math.max(0, Math.floor(Number(env ?? "0")) || 0)` ——
444
+ // `TASK_TIMEOUT_SEC="3600s"`(带单位后缀,运维最常见的写法)得 NaN,再被 `|| 0` 折成 0,
445
+ // 而 0 在这里的语义是**无超时**。运维以为设了一小时上限,实际把超时关掉了,方向恰好相反,
446
+ // 且启动期零提示。改走 numEnv:非法值当场 fail-loud 并指名键与实际值。
447
+ // (numEnv 的注释早就预言了这个失败模式,只是它没 export、跨文件够不到,于是这两处手写了。)
448
+ const taskTimeoutSec = Math.max(0, Math.floor(numEnv("TASK_TIMEOUT_SEC", "0")));
444
449
  const timeoutSec = taskWallClockSec(taskTimeoutSec, false, scenarioName === "team");
445
450
  const mcp = mcpForScenario(config.mcpServers, scenarioName);
446
451
  const spec = {
@@ -23,6 +23,23 @@ export interface SessionListItem {
23
23
  /** A SessionStore that may additionally expose tenant-ownership ops (the TiDB store does) AND the
24
24
  * `SessionRepo` enumeration/lifecycle seam (§0.5: list/fork/delete homed on the session abstraction's own
25
25
  * tables, not the `task_run` runs ledger). All optional — an in-memory dev store implements none. */
26
+ /**
27
+ * E6 fail-closed 守卫的辖域判据:**这个请求是不是一次「破坏性会话写」**。
28
+ *
29
+ * 🔴 2026-08-01 复审逮到:守卫原本是**逐条路由白名单**(`PUT …/policy`、`POST …/sync/import`),
30
+ * 而 `DELETE /v1/sessions/:id`(删整条会话)不在其中 —— 同一威胁形、爆炸半径**更大**,却没门。
31
+ *
32
+ * 威胁形逐字同源(守卫自己的注释):「无 service token + 未开 `ALLOW_UNAUTHED_WRITES`」这一形下
33
+ * `sessionOwnerScopeForWrite` 判 fleet-wide ⇒ 集群内**任意**调用方可对**任意** session 动手。
34
+ * policy 腿担心的是「forged principal 能 tighten/DoS 一个会话的工具」;DELETE 直接把会话删掉。
35
+ *
36
+ * ⇒ 枚举改**族判定**:新增一条破坏性会话路由时自动落进门内,不必记得回来加白名单。
37
+ * 这是本仓「同一条论证只在枚举到的路径上执行」这一族的第 N 次,修法统一为把判据提成函数。
38
+ *
39
+ * 辖域**只含**对**既有会话**的破坏性写:改规则 / 整段替换 / 删除 / 分叉(读源会话全历史)。
40
+ * 不含读面,也不含 `POST /v1/tasks` 那条提交腿 —— 它有自己的同族门(别重复拦,双拦会让错误归因变糊)。
41
+ */
42
+ export declare function isDestructiveSessionWrite(method: string, url: string): boolean;
26
43
  export type OwnerAwareSessionStore = SessionStore & {
27
44
  ownerOf?: (sessionId: string) => Promise<string | null | undefined>;
28
45
  register?: (sessionId: string, owner: string | null) => Promise<void>;
package/dist/security.js CHANGED
@@ -11,6 +11,34 @@ import { memoryScopeFor } from "./memory-scope.js"; // design/158 N17: see re-ex
11
11
  // `verifiedPrincipal` still calls verifyPrincipalJwt for the direct-door branch (value import below); every
12
12
  // symbol from both groups is re-exported near the bottom of this file so existing importers keep working.
13
13
  import { verifyPrincipalJwt } from "./principal-jwt.js";
14
+ /** A SessionStore that may additionally expose tenant-ownership ops (the TiDB store does) AND the
15
+ * `SessionRepo` enumeration/lifecycle seam (§0.5: list/fork/delete homed on the session abstraction's own
16
+ * tables, not the `task_run` runs ledger). All optional — an in-memory dev store implements none. */
17
+ /**
18
+ * E6 fail-closed 守卫的辖域判据:**这个请求是不是一次「破坏性会话写」**。
19
+ *
20
+ * 🔴 2026-08-01 复审逮到:守卫原本是**逐条路由白名单**(`PUT …/policy`、`POST …/sync/import`),
21
+ * 而 `DELETE /v1/sessions/:id`(删整条会话)不在其中 —— 同一威胁形、爆炸半径**更大**,却没门。
22
+ *
23
+ * 威胁形逐字同源(守卫自己的注释):「无 service token + 未开 `ALLOW_UNAUTHED_WRITES`」这一形下
24
+ * `sessionOwnerScopeForWrite` 判 fleet-wide ⇒ 集群内**任意**调用方可对**任意** session 动手。
25
+ * policy 腿担心的是「forged principal 能 tighten/DoS 一个会话的工具」;DELETE 直接把会话删掉。
26
+ *
27
+ * ⇒ 枚举改**族判定**:新增一条破坏性会话路由时自动落进门内,不必记得回来加白名单。
28
+ * 这是本仓「同一条论证只在枚举到的路径上执行」这一族的第 N 次,修法统一为把判据提成函数。
29
+ *
30
+ * 辖域**只含**对**既有会话**的破坏性写:改规则 / 整段替换 / 删除 / 分叉(读源会话全历史)。
31
+ * 不含读面,也不含 `POST /v1/tasks` 那条提交腿 —— 它有自己的同族门(别重复拦,双拦会让错误归因变糊)。
32
+ */
33
+ export function isDestructiveSessionWrite(method, url) {
34
+ if (method === "DELETE")
35
+ return /^\/v1\/sessions\/[^/]+$/.test(url);
36
+ if (method === "PUT")
37
+ return /^\/v1\/sessions\/[^/]+\/policy$/.test(url);
38
+ if (method === "POST")
39
+ return /^\/v1\/sessions\/[^/]+\/(sync\/import|fork)$/.test(url);
40
+ return false;
41
+ }
14
42
  /** The shape core's `uuidv7()` mints: canonical 8-4-4-4-12 lowercase hex with version nibble `7` and the RFC-4122
15
43
  * variant (`8`/`9`/`a`/`b`). Used to shape-validate a caller-supplied session id on the §0.5 fork/delete routes so
16
44
  * a crafted id (LIKE metacharacters, over-long, non-canonical) is rejected up front — a SECOND layer behind the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.19.0",
3
+ "version": "3.21.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": "^2.12.0",
57
+ "@sema-agent/core": "^2.13.0",
58
58
  "@sema-agent/registry-core": "^0.11.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
@@ -68,7 +68,7 @@
68
68
  "sharp": "^0.35.3"
69
69
  },
70
70
  "devDependencies": {
71
- "@sema-agent/sdk": "^2.2.0",
71
+ "@sema-agent/sdk": "^2.3.0",
72
72
  "@types/libsodium-wrappers": "^0.7.14",
73
73
  "@types/node": "22.10.2",
74
74
  "@types/pg": "^8.20.0",