@sema-agent/server 3.19.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/stores.js +8 -6
- package/dist/capabilities/scenarios.d.ts +2 -2
- package/dist/config.d.ts +14 -0
- package/dist/config.js +16 -2
- package/dist/http/server.js +10 -26
- package/dist/leader/wire.js +10 -4
- package/dist/main.js +10 -2
- package/dist/plugins/web-search.d.ts +14 -0
- package/dist/plugins/web-search.js +51 -2
- package/dist/run-local.js +7 -2
- package/dist/security.d.ts +17 -0
- package/dist/security.js +28 -0
- package/package.json +2 -2
package/dist/boot/stores.js
CHANGED
|
@@ -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
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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?:
|
|
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
|
-
|
|
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`);
|
package/dist/http/server.js
CHANGED
|
@@ -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
|
|
537
|
-
//
|
|
538
|
-
//
|
|
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
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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 门之后、
|
package/dist/leader/wire.js
CHANGED
|
@@ -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(
|
|
109
|
-
const workerMaxTurns = Math.floor(
|
|
110
|
-
const workerBudgetUsd =
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
-
|
|
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 = {
|
package/dist/security.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "3.20.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.
|
|
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",
|