@sema-agent/server 7.46.0 → 7.47.0-rc.2
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/USAGE.md +22 -3
- package/dist/approval.js +14 -7
- package/dist/boot/resolve-spec.js +6 -1
- package/dist/config-center/skills-mcp.js +15 -2
- package/dist/config-center/types.d.ts +19 -0
- package/dist/config-types.d.ts +33 -1
- package/dist/config.d.ts +15 -0
- package/dist/config.js +17 -2
- package/dist/http/route-ctx.d.ts +48 -5
- package/dist/http/route-ctx.js +11 -0
- package/dist/http/routes/approvals-assistant.d.ts +1 -1
- package/dist/http/routes/approvals-assistant.js +7 -2
- package/dist/http/routes/notify-wake.d.ts +1 -1
- package/dist/http/routes/notify-wake.js +3 -1
- package/dist/http/server.js +19 -6
- package/dist/http/wire-types.d.ts +12 -1
- package/dist/mcp-content-origin.d.ts +42 -0
- package/dist/mcp-content-origin.js +10 -0
- package/dist/resource-suspend.d.ts +9 -0
- package/dist/resource-suspend.js +7 -1
- package/dist/task-mcp.d.ts +47 -1
- package/dist/task-mcp.js +34 -4
- package/dist/task-settings.js +11 -10
- package/dist/trace/core-keyset-guard.d.ts +6 -2
- package/package.json +3 -3
package/USAGE.md
CHANGED
|
@@ -90,9 +90,16 @@ WEB_SEARCH_TIMEOUT_MS=10000 # 单次搜索墙钟
|
|
|
90
90
|
**可选 — 成本计量 + 预算闸(core 1.37)**
|
|
91
91
|
```bash
|
|
92
92
|
# 每任务成本/token 上限(运维 CEILING)。调用方可在 body 传更小的 maxCostUsd/maxTokens,但会被夹到这个上限以下。
|
|
93
|
-
MAX_TASK_COST_USD=0.50 MAX_TASK_TOKENS=200000 # 0/不设 = 不限。超限 → 任务 failed + errorCode
|
|
93
|
+
MAX_TASK_COST_USD=0.50 MAX_TASK_TOKENS=200000 # 0/不设 = 不限。超限 → 任务 failed + errorCode limits.max_{tokens,cost,turns,walltime}_exceeded
|
|
94
94
|
# 每 principal 跨任务累计成本配额(滚动窗口)。某人窗口内累计花费超顶 → 下一个任务被拒(429 + retry-after)。
|
|
95
95
|
MAX_PRINCIPAL_COST_USD=5.00 COST_QUOTA_WINDOW_SEC=86400 # 0/不设 = 不限;默认窗口 1 天
|
|
96
|
+
# ⚠️ 上面两组都是 **per-SLICE / per-任务** 的窗:一次 park→resume(审批、plan_review、抢占、预算切片)
|
|
97
|
+
# 会给引擎**重开一个满窗**。要给「整条 park/resume 链」封顶,用下面这组**跨片总额**(需 RESOURCE_SUSPEND=true)。
|
|
98
|
+
RESOURCE_SUSPEND_TOTAL_TOKENS=750000 # 整条链累计 token 总额;0/不设 = 不限
|
|
99
|
+
RESOURCE_SUSPEND_TOTAL_BUDGET_USD=12.50 # 整条链累计 $ 总额(可小数);0/不设 = 不限
|
|
100
|
+
RESOURCE_SUSPEND_MAX_SLICES=8 # 整条链最多几片(零进展 slice 循环的兜底);0/不设 = 不限
|
|
101
|
+
# 三根都是**部署级**旋钮(只读 env,调用方无从影响),坏值(负数/非数/计数轴写小数)**boot 期拒启**——
|
|
102
|
+
# 一个悄悄没生效的总额天花板与根本没配代价一样,而 operator 会以为自己封了顶。
|
|
96
103
|
# 降级到更便宜的模型(而非直接失败)。MODEL_DEGRADE_TO = 目录里的便宜模型名,两种触发可同时开:
|
|
97
104
|
MODEL_DEGRADE_TO=deepseek-v4-flash
|
|
98
105
|
MODEL_DEGRADE_AT_COST_FRACTION=0.7 # ① 近预算(1.40):累计成本到 0.7×maxCostUsd 切(需任务有 cost ceiling)
|
|
@@ -105,10 +112,22 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
|
|
|
105
112
|
- 反应式降级 brain 包在**最外层**(core council #8);fallback brain 用自己的凭据(decorator 清掉主模型的 per-call key 防泄漏给别的 provider)。**坑**:`MODEL_DEGRADE_TO` 最好别和被限流的是同一网关/账号,否则反应式切过去照样撞同一个 rate_limit。
|
|
106
113
|
- **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-flash)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
|
|
107
114
|
- 成本计量**自动开**:从 config 的 `model.cost`(per-1M 绝对 USD)注入 `pricing`,core 算出权威的整数 `costMicroUsd`(避免浮点累计误差)。`/metrics` 新增:`model_cost_micro_usd_total{model}`(覆盖所有 brain 调用=主任务+异步+council 子任务的总花费)、`brain_first_token_ms`(网关 hang 早警)、`brain_call_latency_ms`、`tool_calls_total{name,ok}`、`budget_exceeded_total{code}`、`cost_quota_rejected_total`、`degraded_total{reason}`(1.40 降级)。
|
|
108
|
-
- **近预算降级 vs 硬闸**:降级(`MODEL_DEGRADE_TO`,到 `atCostFraction` 切便宜模型)是**撑长**预算、任务仍完成(出口质量下降、发 `task.degraded` 事件可告警);硬闸(`maxCostUsd` 全额)仍在,切了便宜模型还超全额 → `
|
|
115
|
+
- **近预算降级 vs 硬闸**:降级(`MODEL_DEGRADE_TO`,到 `atCostFraction` 切便宜模型)是**撑长**预算、任务仍完成(出口质量下降、发 `task.degraded` 事件可告警);硬闸(`maxCostUsd` 全额)仍在,切了便宜模型还超全额 → `limits.max_cost_exceeded` 停。
|
|
109
116
|
- **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 管理端(sema-web,规划名 sema-admin)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使泄露也只暴露指标(只读)。
|
|
110
117
|
- **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, degraded, cascade, verifications, councilRuns, toolErrors}`。给**轻量 fleet 看板**用(管理端 sema-web 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
|
|
111
|
-
- 预算闸是 core 强制的:`maxCostUsd` pre-call 估算(
|
|
118
|
+
- 预算闸是 core 强制的:`maxCostUsd` pre-call 估算(没花钱就拒)+ 流中途取消 + turn 边界(`limits.max_cost_exceeded`);`maxTokens` 超 → `limits.max_tokens_exceeded`(core 5.8.0 起 `budget.*` 前缀退役)。**review 网关**建议设 `MAX_TASK_COST_USD` 防单任务烧掉共享云 key。
|
|
119
|
+
- 🔴 **per-slice 窗 vs 跨片总额(别把前者当后者用)**:`MAX_TASK_TOKENS` / `MAX_TASK_COST_USD` / 任务墙钟都是 core 的
|
|
120
|
+
**per-SLICE 窗**——引擎每次被调用都重铸它们,所以一次 `POST /v1/approvals/:id/decide`、`…/plan_review`、
|
|
121
|
+
`…/resume`、`/wake` 续跑拿到的是**全新的满窗**;停顿前那条腿烧掉的量只在**跨片账本**上被扣。那本账 core
|
|
122
|
+
一直在记(park 时铸、resume 时还原),但**没有总额就没有天花板**。所以一条被人反复批准续跑的任务,只靠
|
|
123
|
+
per-slice 窗是封不住顶的——要封顶就配 `RESOURCE_SUSPEND_TOTAL_TOKENS` / `_TOTAL_BUDGET_USD`(以及
|
|
124
|
+
`_MAX_SLICES` 兜住零进展循环)。前置:`RESOURCE_SUSPEND=true` + durable checkpoint 能力的 backend。
|
|
125
|
+
- **两条总额是「冻结」的**:`_TOTAL_TOKENS` / `_TOTAL_BUDGET_USD` 在**首片**被写进 checkpoint 的账本,
|
|
126
|
+
此后续跑重传无效(设计如此:一次 resume 不能刷新自己的额度)。⇒ 改这两根 env **不影响已经 park 的链**,
|
|
127
|
+
只对新任务生效。
|
|
128
|
+
- **`_MAX_SLICES` 不冻结**:引擎不把它存进账本,每片现读当期部署值去比账本上累计的片数。⇒ 改它会**立刻**
|
|
129
|
+
作用于已 park 的链(调大=放宽、设 0=解除兜底)。这是部署级旋钮跟随当期部署的常态,但别把它误读成
|
|
130
|
+
「和两条总额一样冻在链上」——两者的运维含义不同。
|
|
112
131
|
- **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=mysql` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 滚动窗(单副本兜底)。启动日志 `costQuota` 字段回显 `shared(mysql)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
|
|
113
132
|
- **`RATE_LIMIT_RPM` 请求限流同样自动跨副本**(`SESSION_BACKEND=mysql` 时,`rate_limit` 表,与配额共用 `WriteBehindCounter`;启动日志 `rateLimit` 回显)。注意:写后聚合 = **软限流**(边界上短暂略超 OK,适合公平/热调用方防护);要**硬合规上限**得另走 CAS/原子计数,不靠写后聚合——和断路器跨副本同款权衡。
|
|
114
133
|
|
package/dist/approval.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {} from "@sema-agent/core";
|
|
1
|
+
import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "@sema-agent/core";
|
|
2
2
|
export function hasOperatorGateIntent(config) {
|
|
3
3
|
return (config.approvalRequire.length > 0 ||
|
|
4
4
|
config.approvalDeny.length > 0 ||
|
|
@@ -13,20 +13,27 @@ export function assertGateIntentServiceable(config, checkpointStorePresent) {
|
|
|
13
13
|
}
|
|
14
14
|
throw new Error("DURABLE_APPROVAL=true but no checkpoint store is available — the configured DB backend has no checkpoint face (or no backend is configured). Wire a MySQL-protocol/PostgreSQL/file backend, or unset DURABLE_APPROVAL.");
|
|
15
15
|
}
|
|
16
|
+
function createNameSetMatcher(names) {
|
|
17
|
+
const exact = new Set(names);
|
|
18
|
+
const covering = names.filter(isNamespacedCoveringRuleName);
|
|
19
|
+
if (covering.length === 0)
|
|
20
|
+
return (toolName) => exact.has(toolName);
|
|
21
|
+
return (toolName) => exact.has(toolName) || covering.some((rule) => namespacedRuleNameCovers(rule, toolName));
|
|
22
|
+
}
|
|
16
23
|
export function createDurableAskPolicy(opts) {
|
|
17
|
-
const require =
|
|
18
|
-
const deny =
|
|
19
|
-
const neverAuto =
|
|
24
|
+
const require = createNameSetMatcher(opts.requireApproval);
|
|
25
|
+
const deny = createNameSetMatcher(opts.deny ?? []);
|
|
26
|
+
const neverAuto = createNameSetMatcher(opts.neverAuto ?? []);
|
|
20
27
|
const budget = Math.max(0, Math.floor(opts.autoBudget ?? 0));
|
|
21
28
|
let autoApproved = 0;
|
|
22
29
|
return {
|
|
23
30
|
check: (req) => {
|
|
24
31
|
const toolName = req.toolName;
|
|
25
|
-
if (deny
|
|
32
|
+
if (deny(toolName))
|
|
26
33
|
return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
|
|
27
|
-
if (neverAuto
|
|
34
|
+
if (neverAuto(toolName))
|
|
28
35
|
return { action: "ask", message: `human approval required to run "${req.toolName}"` };
|
|
29
|
-
if (require
|
|
36
|
+
if (require(toolName)) {
|
|
30
37
|
const remEarly = req.budget?.resourceRemainingMicroUsd;
|
|
31
38
|
if (remEarly !== undefined && remEarly <= 0) {
|
|
32
39
|
return { action: "ask", message: `durable resource budget exhausted — human approval required to run "${req.toolName}"` };
|
|
@@ -28,7 +28,7 @@ import { a2aForScenario, mcpForScenario } from "../config-center/facade.js";
|
|
|
28
28
|
import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolMaterializeStrategyFromBody, toolNameListFromBody } from "../spec-fields.js";
|
|
29
29
|
import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
|
|
30
30
|
import { assertRequestA2aUnlocked, resolveRequestA2a } from "../task-a2a.js";
|
|
31
|
-
import { assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
|
|
31
|
+
import { assertRequestMcpContentOrigin, assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
|
|
32
32
|
import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, shellGateForMode, withPermissionMode } from "../task-settings.js";
|
|
33
33
|
import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationFromBody } from "../task-workflow.js";
|
|
34
34
|
import { redactSecrets } from "../trace/redact.js";
|
|
@@ -42,6 +42,8 @@ export function createResolveSpec(ctx) {
|
|
|
42
42
|
logger.warn(onlySensitiveBaseline.event, onlySensitiveBaseline.fields);
|
|
43
43
|
const resolveSpec = async (body, _req, auth, opts) => {
|
|
44
44
|
assertRequestMcpUnlocked(body.mcpServers, lockedKeys);
|
|
45
|
+
if (opts?.leg === "fresh")
|
|
46
|
+
assertRequestMcpContentOrigin(body.mcpServers);
|
|
45
47
|
assertRequestA2aUnlocked(body.a2aPeers, lockedKeys);
|
|
46
48
|
const gated = await gateScenarioAndAppend(body, auth, opts);
|
|
47
49
|
const effMode = effectivePermissionMode(body, gated.parsedSettings.settings);
|
|
@@ -369,6 +371,9 @@ export function createResolveSpec(ctx) {
|
|
|
369
371
|
isVerify: body.verify === true,
|
|
370
372
|
isCascade: body.cascade === true,
|
|
371
373
|
scope: encodeCheckpointScope(auth?.principal),
|
|
374
|
+
totalTokens: config.resourceSuspendTotalTokens,
|
|
375
|
+
totalBudgetUsd: config.resourceSuspendTotalBudgetUsd,
|
|
376
|
+
maxSlices: config.resourceSuspendMaxSlices,
|
|
372
377
|
})),
|
|
373
378
|
...(liveQuestionFace === undefined ? { onQuestion: QUESTION_AWAITS_RESUME } : {}),
|
|
374
379
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { skillContentHash } from "@sema-agent/registry-core";
|
|
2
|
+
import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "../mcp-content-origin.js";
|
|
2
3
|
import { fetchSkillContent } from "./http-client.js";
|
|
3
4
|
import { SHA256_HEX_RE } from "../digest-form.js";
|
|
4
5
|
export async function applyCenterSkills(baseline, manifest, baseUrl, token, logger, fetchImpl = fetch, diskCacheDir) {
|
|
@@ -82,13 +83,25 @@ export function resolveMcpServers(mcp, logger) {
|
|
|
82
83
|
continue;
|
|
83
84
|
const allow = s.allowTools && s.allowTools.length > 0 ? { allowTools: s.allowTools } : {};
|
|
84
85
|
const elicit = s.elicitation === true ? { elicitation: true } : {};
|
|
86
|
+
let contentOrigin = {};
|
|
87
|
+
if (s.contentOrigin !== undefined) {
|
|
88
|
+
if (isContentOriginWord(s.contentOrigin))
|
|
89
|
+
contentOrigin = { contentOrigin: s.contentOrigin };
|
|
90
|
+
else
|
|
91
|
+
logger?.warn("sema_registry_mcp_content_class_invalid", {
|
|
92
|
+
server: s.name,
|
|
93
|
+
value: describeBadContentOrigin(s.contentOrigin),
|
|
94
|
+
allowed: CONTENT_ORIGIN_WORDS,
|
|
95
|
+
note: "declaration DROPPED — this server's tools keep the pre-378 default (external content, session gets marked). Fix the center entry; the server itself still mounts.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
85
98
|
if (s.transport.kind === "stdio") {
|
|
86
99
|
const env = resolveRefs(s.transport.envRefs, s.name, "env", logger);
|
|
87
100
|
if (env === null)
|
|
88
101
|
continue;
|
|
89
102
|
out.push({
|
|
90
103
|
scenarios: s.scenarios ?? [],
|
|
91
|
-
spec: { name: s.name, transport: { kind: "stdio", command: s.transport.command, args: s.transport.args, ...(Object.keys(env).length ? { env } : {}) }, ...allow, ...elicit },
|
|
104
|
+
spec: { name: s.name, transport: { kind: "stdio", command: s.transport.command, args: s.transport.args, ...(Object.keys(env).length ? { env } : {}) }, ...allow, ...elicit, ...contentOrigin },
|
|
92
105
|
});
|
|
93
106
|
}
|
|
94
107
|
else {
|
|
@@ -97,7 +110,7 @@ export function resolveMcpServers(mcp, logger) {
|
|
|
97
110
|
continue;
|
|
98
111
|
out.push({
|
|
99
112
|
scenarios: s.scenarios ?? [],
|
|
100
|
-
spec: { name: s.name, transport: { kind: "http", url: s.transport.url, ...(Object.keys(headers).length ? { headers } : {}), ...(s.transport.principalHeader ? { principalHeader: s.transport.principalHeader } : {}) }, ...allow, ...elicit },
|
|
113
|
+
spec: { name: s.name, transport: { kind: "http", url: s.transport.url, ...(Object.keys(headers).length ? { headers } : {}), ...(s.transport.principalHeader ? { principalHeader: s.transport.principalHeader } : {}) }, ...allow, ...elicit, ...contentOrigin },
|
|
101
114
|
});
|
|
102
115
|
}
|
|
103
116
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Pure type/interface declarations — no runtime logic. Split out of `facade.ts` (design/158 A13,
|
|
6
6
|
* internal-lossless: the facade re-exports every symbol below unchanged).
|
|
7
7
|
*/
|
|
8
|
+
import type { ToolContentOrigin } from "@sema-agent/core";
|
|
8
9
|
import type { CollabTemplateWire } from "../capabilities/collab-wire.js";
|
|
9
10
|
import type { Autonomy, CommandRule } from "../runtime-governance.js";
|
|
10
11
|
export interface CenterModel {
|
|
@@ -107,6 +108,24 @@ export interface CenterMcpServer {
|
|
|
107
108
|
* `RunnerDeps.onElicit` (MCP_ELICITATION_ENABLED). Default OFF (omitted) — a server cannot pull a human into the
|
|
108
109
|
* loop unless an operator explicitly opts it in. */
|
|
109
110
|
elicitation?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* design/378(core 5.60.0)—— 声明**这台 server 的整套工具**带回来的内容属于哪一类,映射到
|
|
113
|
+
* `McpServerSpec.contentOrigin`。`"local"` / `"execution"` ⇒ 这台 server 的调用不再给本会话记忆打
|
|
114
|
+
* 「已接触外来内容」标(`execution` 仍留在 `execIsExternalContent` 那只旋钮的射程内);`"external"` 是
|
|
115
|
+
* 显式 PIN,而**PIN 不是空操作** —— 它会取消 `trustedTools` 对这台 server 的按名豁免。
|
|
116
|
+
* 缺席 ⇒ pre-378 逐字节现行为(协议命名空间一律判 external,fail-closed)。
|
|
117
|
+
*
|
|
118
|
+
* 🔴 **这是信任声明,不是路由提示**:只对部署自己跑的进程/套接字/服务用它。类名是**边界相对**的,
|
|
119
|
+
* 与传输类型和地址无关(stdio 子进程可以是不可信包,loopback URL 可以是你自己的服务)——core 刻意
|
|
120
|
+
* 不按拓扑推断,本层同样不推断。它是**部署面**的键(core 顶注:与 `RunnerDeps` 接线同一权威平面),
|
|
121
|
+
* 所以它的家在 center 下发这条腿;请求腿那半场的门与结构性兑现见 `task-mcp.ts`
|
|
122
|
+
* 的 `assertRequestMcpContentOrigin` 顶注。
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ **本地 `config.d` 腿今天到不了这里**(如实登记,别读成「两条腿都通」):那条腿的原料形是
|
|
125
|
+
* registry-core 的 zod schema,未知键被 strip;等 registry-core 补上同名可选键,
|
|
126
|
+
* `config-provider.ts` 的映射再加一行即可(远端 `/effective.mcp` 腿是直读 JSON,今天就通)。
|
|
127
|
+
*/
|
|
128
|
+
contentOrigin?: ToolContentOrigin;
|
|
110
129
|
transport: {
|
|
111
130
|
kind: "stdio";
|
|
112
131
|
command: string;
|
package/dist/config-types.d.ts
CHANGED
|
@@ -856,6 +856,38 @@ export interface ServiceConfigFlat {
|
|
|
856
856
|
* ABANDONED (never-resumed) suspend. 0 ⇒ core default (30d, `DEFAULT_RESOURCE_SUSPEND_TTL`). A legitimate
|
|
857
857
|
* resume happens long before this; the deadline only reaps abandonment. `RESOURCE_SUSPEND_TTL_SEC`. */
|
|
858
858
|
resourceSuspendTtlSec: number;
|
|
859
|
+
/**
|
|
860
|
+
* [3833] 件 S-3 —— **跨片总额**:一条任务在整条 park→resume→再 park 链上允许烧掉的 token 总量。
|
|
861
|
+
* `RESOURCE_SUSPEND_TOTAL_TOKENS`,**0 / 缺省 = 不设**(维持现状)。
|
|
862
|
+
*
|
|
863
|
+
* 🔴 为什么这是一根独立的旋钮、而不是 `MAX_TASK_TOKENS` 的同义词:`limits.maxTokens` 是 core 的
|
|
864
|
+
* **per-SLICE 窗**(core `types.d.ts` 逐字:"PER-SLICE windows, not whole-task totals"),而一次 resume
|
|
865
|
+
* 会**重开满窗**。跨片总额是唯一能跨越 park 边界约束总量的轴 —— core 把它冻在 checkpoint 的
|
|
866
|
+
* resource ledger 上,`remainingTokens(ledger)` 在 total 缺席时回 `undefined` = **无天花板**。
|
|
867
|
+
* [3833](plan_review approve 后一条 resume 腿烧 75 万 token 无产出)的三个叠加因子之一就是本值缺席。
|
|
868
|
+
*
|
|
869
|
+
* 🔒 **冻结语义**:core 把它冻在 checkpoint 的 resource ledger 上("Set ONLY on the FIRST slice";
|
|
870
|
+
* `debitLedger` 逐字 `prior ?? total`)⇒ 续跑腿重供**无效**,「一次 resume 刷新不了自己的额度」这条
|
|
871
|
+
* 安全语义由 core 保证。本仓每片都供值是无害的(首片生效,其后被 prior 挡掉)。
|
|
872
|
+
*/
|
|
873
|
+
resourceSuspendTotalTokens: number;
|
|
874
|
+
/** [3833] 件 S-3 —— 跨片总额的 **$ 轴**(`totalTokens` 的姊妹,语义与冻结形同上)。
|
|
875
|
+
* `RESOURCE_SUSPEND_TOTAL_BUDGET_USD`,0 / 缺省 = 不设。可为小数(美元)。 */
|
|
876
|
+
resourceSuspendTotalBudgetUsd: number;
|
|
877
|
+
/**
|
|
878
|
+
* [3833] 件 S-3 —— 跨片 **片数**上限:整条 resume 链最多几片,超出 core 以 `"suspend.loop"` 收场。
|
|
879
|
+
* `RESOURCE_SUSPEND_MAX_SLICES`,0 / 缺省 = 不设。
|
|
880
|
+
*
|
|
881
|
+
* 它是**零进展 slice 循环**的兜底(两条总额才是主约束),与重启环的 `maxSuspends` 是两个独立计数。
|
|
882
|
+
* core 的 JSDoc 逐字点名了本仓这个角色:"a host driving resumes unattended **should set this**"。
|
|
883
|
+
*
|
|
884
|
+
* 🔴 **它与两条 totals 的冻结语义不同**(codex 对抗复审 R1-[medium],验真后改口):core **不把它写进
|
|
885
|
+
* 账本**(`debitLedger` 的返回形里没有这一格),每片现读 `spec.resourceSuspend.maxSlices` 去比账本上
|
|
886
|
+
* 累计的 `sliceCount`。⇒ 本仓在**每一片**(含续跑)供值是**载荷不是冗余**:只在首片供值等于让续跑腿
|
|
887
|
+
* 供 `undefined` = 上限当场消失。代价如实登记:滚动部署改这根 env,已 park 的链按**新**值判 —— 部署
|
|
888
|
+
* 级旋钮本就该跟着当期部署走,但别把它误读成「冻在链上」。钉:test/resource-suspend.test.ts 的上游契约格。
|
|
889
|
+
*/
|
|
890
|
+
resourceSuspendMaxSlices: number;
|
|
859
891
|
/** design/80 D-E (upgrade-budget): max NORMAL gated tool calls AUTO-APPROVED per run-leg before the rest
|
|
860
892
|
* escalate to a human — a circuit-breaker against a runaway/inflated approval loop (an AI stuck re-asking),
|
|
861
893
|
* NOT a cap on safety asks. 0 = OFF (every gated tool asks a human — unchanged, back-compat default). Clamped
|
|
@@ -1451,7 +1483,7 @@ export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "ses
|
|
|
1451
1483
|
export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "atModelAllowlist" | "cascadeLadder" | "degrade">;
|
|
1452
1484
|
/** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
|
|
1453
1485
|
* 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
|
|
1454
|
-
export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
|
|
1486
|
+
export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "resourceSuspendTotalTokens" | "resourceSuspendTotalBudgetUsd" | "resourceSuspendMaxSlices" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
|
|
1455
1487
|
/** 组:memory(记忆面 + TOC 同步腿)。 */
|
|
1456
1488
|
export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryConsolidationDriver" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
|
|
1457
1489
|
/** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
|
package/dist/config.d.ts
CHANGED
|
@@ -185,6 +185,21 @@ export declare function applyAutoCompactWindow(m: Model, explicit?: number): "ex
|
|
|
185
185
|
*
|
|
186
186
|
* 已接受的权衡(与退役表墓碑同一条):部署自有工具真叫 `figma__x` 的极端形会被误拒——boot 期无 roster 可
|
|
187
187
|
* 豁免,且失败响亮、指引明确,好过静默失守。
|
|
188
|
+
*
|
|
189
|
+
* 🔴 **三条腿的消费者现已全部认 covering**(#450 孪生修,[5246];此前本段记的「分家」已作废):
|
|
190
|
+
* core 的每一只 name-keyed policy(`createAllowDenyPolicy` / `createPermissionRulePolicy` /
|
|
191
|
+
* `createApprovalPolicy`)都解 covering 拼法,而 operator 腿的消费者 `createDurableAskPolicy`
|
|
192
|
+
* (`src/approval.ts`)自 core 5.60.1 的谓词根导出到货后,deny/neverAuto/require 三集合也改成
|
|
193
|
+
* 「精确 ∪ covering」(同一只 `namespacedRuleNameCovers`)。于是本函数放行的
|
|
194
|
+
* `<ns>__<server>__*` / `<ns>__<server>__<glob>` 在**每一条**腿上都是**真判**,不再是
|
|
195
|
+
* 「放行进 no-op」—— 而那正是 #450 病形(`APPROVAL_DENY=mcp__figma__*` 判 allow)的根。
|
|
196
|
+
*
|
|
197
|
+
* ⚠️ 仍然拒的那一格,理由**换了**(别再照旧引「approval.ts 还不认」):`<ns>__<peer>` **两段形**
|
|
198
|
+
* (`mcp__figma`)core 全家解成 covering,本函数判它「缺段」拒。挡它的不再是消费腿的能力,而是
|
|
199
|
+
* **一名两义**:同一个 `mcp__figma` 既可能是运维想写的「整台服务器」,也可能是他把三段名写漏了一段
|
|
200
|
+
* (`mcp__figma__get_file` 少打一截),两者在 boot 期无 roster 可判——所以三条腿一律响亮拒,并在
|
|
201
|
+
* 指引里直接给出**无歧义**的等价写法 `mcp__figma__*`(见下方 guidance)。放宽两段形是一次
|
|
202
|
+
* behavior-facing 的放宽(三条腿同时变宽,且方向是「更少拒绝」),要走独立批的三问,不是本函数的补丁。
|
|
188
203
|
*/
|
|
189
204
|
export interface UnmatchableToolName {
|
|
190
205
|
name: string;
|
package/dist/config.js
CHANGED
|
@@ -127,6 +127,18 @@ export function parseNumOrFailNonNegative(name, raw) {
|
|
|
127
127
|
throw new Error(`env ${name}=${n} must not be negative`);
|
|
128
128
|
return n;
|
|
129
129
|
}
|
|
130
|
+
function nonNegativeNumEnv(name, role, fallback = "0") {
|
|
131
|
+
const n = numEnv(name, fallback);
|
|
132
|
+
if (n < 0)
|
|
133
|
+
throw new Error(`env ${name}=${n} must not be negative(${role};0 或不设 = 不启用本上限)`);
|
|
134
|
+
return n;
|
|
135
|
+
}
|
|
136
|
+
function nonNegativeIntEnv(name, role, fallback = "0") {
|
|
137
|
+
const n = nonNegativeNumEnv(name, role, fallback);
|
|
138
|
+
if (!Number.isInteger(n))
|
|
139
|
+
throw new Error(`env ${name}=${n} must be an integer(${role};计数轴不接受小数——单位写漏了?)`);
|
|
140
|
+
return n;
|
|
141
|
+
}
|
|
130
142
|
function numEnvBounded(name, fallback, min, max) {
|
|
131
143
|
const n = numEnv(name, fallback);
|
|
132
144
|
if (n < min || n > max)
|
|
@@ -761,7 +773,7 @@ export function findUnmatchableToolNames(names) {
|
|
|
761
773
|
continue;
|
|
762
774
|
}
|
|
763
775
|
if (ns.parse(name) === undefined) {
|
|
764
|
-
out.push({ name, guidance: `incomplete ${ns.id} name — the live form is ${ns.prefix}<server>__<tool
|
|
776
|
+
out.push({ name, guidance: `incomplete ${ns.id} name — the live form is ${ns.prefix}<server>__<tool>, or ${ns.prefix}<server>__* to cover every tool on that server` });
|
|
765
777
|
}
|
|
766
778
|
}
|
|
767
779
|
return out;
|
|
@@ -818,6 +830,9 @@ function parseApprovalDomain(ctx) {
|
|
|
818
830
|
durableApproval,
|
|
819
831
|
resourceSuspend: boolEnv("RESOURCE_SUSPEND", false),
|
|
820
832
|
resourceSuspendTtlSec: clampEnvWithWarn("RESOURCE_SUSPEND_TTL_SEC", "0", 0),
|
|
833
|
+
resourceSuspendTotalTokens: nonNegativeIntEnv("RESOURCE_SUSPEND_TOTAL_TOKENS", "跨片 token 总额"),
|
|
834
|
+
resourceSuspendTotalBudgetUsd: nonNegativeNumEnv("RESOURCE_SUSPEND_TOTAL_BUDGET_USD", "跨片 $ 总额"),
|
|
835
|
+
resourceSuspendMaxSlices: nonNegativeIntEnv("RESOURCE_SUSPEND_MAX_SLICES", "跨片 slice 数上限"),
|
|
821
836
|
approvalAutoBudget: clampEnvWithWarn("APPROVAL_AUTO_BUDGET", "0", 0, 10000),
|
|
822
837
|
approvalNeverAuto: rejectUnmatchableToolNames("APPROVAL_NEVER_AUTO", csv("APPROVAL_NEVER_AUTO")),
|
|
823
838
|
approvalHmacKeys: dgHmacKeys,
|
|
@@ -1612,7 +1627,7 @@ const MODEL_PLANE_GROUP_KEYS = [
|
|
|
1612
1627
|
const APPROVAL_GROUP_KEYS = [
|
|
1613
1628
|
"approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
|
|
1614
1629
|
"approvalHmacKeys", "durableApproval", "directApprovalDoor", "directDoorActive", "resourceSuspend",
|
|
1615
|
-
"resourceSuspendTtlSec", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "unattendedApprovalPolicy", "mcpElicitation",
|
|
1630
|
+
"resourceSuspendTtlSec", "resourceSuspendTotalTokens", "resourceSuspendTotalBudgetUsd", "resourceSuspendMaxSlices", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "unattendedApprovalPolicy", "mcpElicitation",
|
|
1616
1631
|
"sensitiveWritePatterns", "manualModeShellGate",
|
|
1617
1632
|
];
|
|
1618
1633
|
const MEMORY_GROUP_KEYS = [
|
package/dist/http/route-ctx.d.ts
CHANGED
|
@@ -133,8 +133,11 @@ export interface DriveResumeArgs {
|
|
|
133
133
|
* await,没有「CAS 已过、模型还没跑」这个缝可停 —— 在那条臂上提前受理就等于把绑定判别也异步化了
|
|
134
134
|
* (那是决策证明的一部分,不能丢)。故它维持同步长调用形,如实登记在 CHANGELOG/契约文档。
|
|
135
135
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
136
|
+
* **今天传 `true` 的调用方**([3833] 件 S-1 之后 = 全部有人在等响应的 HTTP 腿):legacy 任务级
|
|
137
|
+
* `/v1/approvals/:sessionId/decide`、`/v1/assistant/tasks/:id/resume`、`…/plan_review`、
|
|
138
|
+
* `/v1/sessions/:id/wake`、以及 live 回决腿的 parked 赎回席(`redeemParkedAsk`)。
|
|
139
|
+
* **唯一不设此位的**是内部 D-D SLA deny-sweep:它没有在等响应的人,提前受理对它零收益,而且那条
|
|
140
|
+
* 循环靠 `await` 串行给每 tick 的重活限流(见其调用处的登记注)。 */
|
|
138
141
|
acceptEarly?: boolean;
|
|
139
142
|
}
|
|
140
143
|
/** 提交/续跑「腿」= 跨域共享的**有状态**长流程(不像 {@link RouteHelpers} 那样只依赖 deps/config:它们要写
|
|
@@ -174,12 +177,17 @@ export interface RouteLegs {
|
|
|
174
177
|
body: object;
|
|
175
178
|
}>;
|
|
176
179
|
/** assistant 抢占腿的复位(gate-kind 守卫 + 驱动)。 */
|
|
177
|
-
resumePreempted(sessionId: string, req: IncomingMessage | undefined
|
|
180
|
+
resumePreempted(sessionId: string, req: IncomingMessage | undefined,
|
|
181
|
+
/** [3833] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/resume` 腿传 `true`。 */
|
|
182
|
+
acceptEarly?: boolean): Promise<{
|
|
178
183
|
status: number;
|
|
179
184
|
body: object;
|
|
180
185
|
}>;
|
|
181
186
|
/** notify-wake 域:唤醒一条 parked checkpoint。 */
|
|
182
|
-
resumeWake(sessionId: string, message: string | undefined, caller: string | undefined, req: IncomingMessage | undefined
|
|
187
|
+
resumeWake(sessionId: string, message: string | undefined, caller: string | undefined, req: IncomingMessage | undefined,
|
|
188
|
+
/** [3833] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/wake` 腿传 `true`
|
|
189
|
+
* —— 本腿走 `mintFreshRun`,受理体里的 `taskId` 因此是**新铸的**那个(壳的 poll 锚)。 */
|
|
190
|
+
acceptEarly?: boolean): Promise<{
|
|
183
191
|
status: number;
|
|
184
192
|
body: object;
|
|
185
193
|
}>;
|
|
@@ -189,11 +197,46 @@ export interface RouteLegs {
|
|
|
189
197
|
log?: {
|
|
190
198
|
taskId?: string;
|
|
191
199
|
principalPresent?: boolean;
|
|
192
|
-
}
|
|
200
|
+
},
|
|
201
|
+
/** [3833] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/plan_review` 腿传 `true`。 */
|
|
202
|
+
acceptEarly?: boolean): Promise<{
|
|
193
203
|
status: number;
|
|
194
204
|
body: object;
|
|
195
205
|
}>;
|
|
196
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* [3833] 件 S-2 —— resume 族**受理点的断连留痕**(裁定:维持 detach,补观察面)。
|
|
209
|
+
*
|
|
210
|
+
* ## 裁定(fable,与 S-1 同批;这里是它的唯一属主)
|
|
211
|
+
*
|
|
212
|
+
* S-1 落地后 resume 族的四条 HTTP 腿都在**受理点**回执:
|
|
213
|
+
* · 受理**前**断连 ⇒ 这条腿还没赢 CAS、还没驱模型 ⇒ **零副作用**,没有什么要留痕的;
|
|
214
|
+
* · 受理**后**本就**没有长连接可断** —— HTTP 已经走了,结果全在 durable 账本
|
|
215
|
+
* (`GET /v1/runs/:id` 与 `/events` 全程可查),所以这一族的语义**就是 detach**,而且是审过之后
|
|
216
|
+
* 留下的 detach,不是「从来没装过 abort」的 detach。
|
|
217
|
+
*
|
|
218
|
+
* 因此**刻意不做**两件事:①不给 resume 族加 `x-detach-on-disconnect`(那是 `POST /v1/tasks/stream`
|
|
219
|
+
* 的旋钮 —— 它有一条**真在流的长连接**,断连即 abort 才是它的默认;resume 族受理后无连接可断,一个
|
|
220
|
+
* 永远为真的 opt-in 只会让消费方以为存在第二种行为);②不因断连 abort(受理即宣告这条 run 归账本
|
|
221
|
+
* 管;断连 abort 等于让一次**网络抖动**杀掉一条已受理的续跑)。
|
|
222
|
+
*
|
|
223
|
+
* 做的只有这一件:**受理那一刻若原连接已经没了,打一条 info**。不改任何行为,纯观察面 ——
|
|
224
|
+
* 修前运维在这种形上能拿到的只有一条 `status:"499"` 的 request 行,连是哪条 run 都不知道。
|
|
225
|
+
*
|
|
226
|
+
* 判据 = `res.destroyed || res.closed`(且 `!res.writableEnded` —— 正常收尾的 close 不是断连),
|
|
227
|
+
* 与 `src/http/routes/tasks.ts` 的断连回看**逐字同一条**(那里的顶注写了为什么 `req` 的 'close'
|
|
228
|
+
* 靠不住)。best-effort:探测失真只会少打/多打一条日志,不影响这条 run 的任何结局。
|
|
229
|
+
*
|
|
230
|
+
* @param res 本次请求的响应对象(受理点尚未写过它)。
|
|
231
|
+
* @param out `driveResumeIntoRunLog` 的回执 —— 只有**受理形**(200 + `status:"resuming"`)才留痕:
|
|
232
|
+
* 同步的 4xx/409/404 是「这条请求被拒了」,与断连无关,记进来会把真信号淹掉。
|
|
233
|
+
*/
|
|
234
|
+
export declare function noteResumeClientGone(res: ServerResponse, out: {
|
|
235
|
+
status: number;
|
|
236
|
+
body: object;
|
|
237
|
+
}, logger: {
|
|
238
|
+
info?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
239
|
+
} | undefined): void;
|
|
197
240
|
export interface RouteCtxBase {
|
|
198
241
|
deps: FlatServiceDeps;
|
|
199
242
|
registry: RunRegistry;
|
package/dist/http/route-ctx.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
export function noteResumeClientGone(res, out, logger) {
|
|
2
|
+
if (out.status !== 200 || out.body.status !== "resuming")
|
|
3
|
+
return;
|
|
4
|
+
if (res.writableEnded || !(res.destroyed || res.closed))
|
|
5
|
+
return;
|
|
6
|
+
const taskId = out.body.taskId;
|
|
7
|
+
logger?.info?.("resume_client_gone_at_accept", {
|
|
8
|
+
...(typeof taskId === "string" ? { taskId } : {}),
|
|
9
|
+
...(out.body.sessionId !== undefined ? { sessionId: out.body.sessionId } : {}),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
1
12
|
export const RUN_NOT_FOUND_MESSAGE = "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
|
|
2
13
|
export function runNotFoundMessage(id) {
|
|
3
14
|
const aStar = /^(?:a|wa)[0-9a-f]{4,}$/i.test(id);
|
|
@@ -15,7 +15,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
15
15
|
import type { QuestionAnswer } from "@sema-agent/core";
|
|
16
16
|
import type { CheckpointStoreFull } from "../../plugins/store-backend.js";
|
|
17
17
|
import { type GovernancePosture } from "../active-run-conflict.js";
|
|
18
|
-
import type
|
|
18
|
+
import { type RouteCtx } from "../route-ctx.js";
|
|
19
19
|
export declare const ASSISTANT_PREEMPT_RE: RegExp;
|
|
20
20
|
export declare const ASSISTANT_RESUME_RE: RegExp;
|
|
21
21
|
export declare const ASSISTANT_PLAN_REVIEW_RE: RegExp;
|
|
@@ -9,6 +9,7 @@ import { bindSseLifecycle } from "../sse-lifecycle.js";
|
|
|
9
9
|
import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
|
|
10
10
|
import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
|
|
11
11
|
import { governanceOriginOf } from "../active-run-conflict.js";
|
|
12
|
+
import { noteResumeClientGone } from "../route-ctx.js";
|
|
12
13
|
export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
|
|
13
14
|
export const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
|
|
14
15
|
export const ASSISTANT_PLAN_REVIEW_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/plan_review$/;
|
|
@@ -123,6 +124,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
123
124
|
...(s.severity !== undefined ? { severity: s.severity } : {}),
|
|
124
125
|
...(s.spentMicroUsd !== undefined ? { spentMicroUsd: s.spentMicroUsd } : {}),
|
|
125
126
|
...(s.deadline !== undefined ? { deadline: s.deadline } : {}),
|
|
127
|
+
...(s.hasBidiControls === true ? { hasBidiControls: true } : {}),
|
|
126
128
|
objective: ctx?.body?.objective ?? null,
|
|
127
129
|
input,
|
|
128
130
|
};
|
|
@@ -246,7 +248,8 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
246
248
|
sendError(res, 404, "not_found.run", "task not found");
|
|
247
249
|
return;
|
|
248
250
|
}
|
|
249
|
-
const out = await resumePreempted(run.sessionId, req);
|
|
251
|
+
const out = await resumePreempted(run.sessionId, req, true);
|
|
252
|
+
noteResumeClientGone(res, out, deps.logger);
|
|
250
253
|
sendJson(res, out.status, out.body);
|
|
251
254
|
return;
|
|
252
255
|
}
|
|
@@ -312,7 +315,8 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
312
315
|
sendError(res, 404, "not_found.run", "task not found");
|
|
313
316
|
return;
|
|
314
317
|
}
|
|
315
|
-
const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined });
|
|
318
|
+
const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true);
|
|
319
|
+
noteResumeClientGone(res, out, deps.logger);
|
|
316
320
|
sendJson(res, out.status, out.body);
|
|
317
321
|
return;
|
|
318
322
|
}
|
|
@@ -438,6 +442,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
438
442
|
}
|
|
439
443
|
: undefined;
|
|
440
444
|
const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, "human", req, answer, binding, grantOnCommit, true);
|
|
445
|
+
noteResumeClientGone(res, out, deps.logger);
|
|
441
446
|
if (remember && deps.approvalExemptionStore) {
|
|
442
447
|
sendJson(res, out.status, { ...out.body, rememberApplied });
|
|
443
448
|
return;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 都在腿里),而不是在本域自建第二条驱动路径。本域零可变状态。
|
|
10
10
|
*/
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
-
import type
|
|
12
|
+
import { type RouteCtx } from "../route-ctx.js";
|
|
13
13
|
/**
|
|
14
14
|
* `POST /v1/sessions/:id/wake` 的路由形 —— **单源导出**,因为它有两个消费者:本模块的分发,
|
|
15
15
|
* 和 `server.ts` 的 `isBillableSubmitPath`(wake 会烧模型,必须吃 drain / model-roster-pending /
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { taskNotificationInboxEntry } from "../../orchestration/workflow-completion-inbox.js";
|
|
2
2
|
import { sendJson, sendError } from "../send.js";
|
|
3
3
|
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
4
|
+
import { noteResumeClientGone } from "../route-ctx.js";
|
|
4
5
|
export const SESSION_WAKE_RE = /^\/v1\/sessions\/[^/]+\/wake$/;
|
|
5
6
|
export async function handleNotifyWake(req, res, url, ctx) {
|
|
6
7
|
const miss = { fell: false };
|
|
@@ -127,7 +128,8 @@ async function handleNotifyWakeBody(req, res, url, ctx, miss) {
|
|
|
127
128
|
sendError(res, 400, "request.field_invalid", "message must be a non-empty string when present");
|
|
128
129
|
return;
|
|
129
130
|
}
|
|
130
|
-
const out = await resumeWake(wakeSession, wakeBody.message, principal, req);
|
|
131
|
+
const out = await resumeWake(wakeSession, wakeBody.message, principal, req, true);
|
|
132
|
+
noteResumeClientGone(res, out, deps.logger);
|
|
131
133
|
sendJson(res, out.status, out.body);
|
|
132
134
|
return;
|
|
133
135
|
}
|
package/dist/http/server.js
CHANGED
|
@@ -1583,10 +1583,19 @@ export function createHttpServer(rawDeps) {
|
|
|
1583
1583
|
e.code === "resume.parent_constraint_mismatch" ||
|
|
1584
1584
|
e.code === "resume.constraint_rejected" ||
|
|
1585
1585
|
e.code === "resume.constraint_unprojectable" ||
|
|
1586
|
+
e.code === "resume.usage_window_exhausted" ||
|
|
1587
|
+
e.code === "resume.principal_mismatch" ||
|
|
1586
1588
|
e.code.startsWith("wake.") ||
|
|
1587
1589
|
(e.code === "checkpoint.unsupported_version" && checkpointRowRedeemableElsewhere(e.detail?.reason));
|
|
1588
1590
|
if (claimedRow && taskId && deps.runStore) {
|
|
1589
|
-
if (retriable) {
|
|
1591
|
+
if (retriable && freshRunClaimed) {
|
|
1592
|
+
const c = { taskId, sessionId, status: "failed", errorCode: e.code, errorMessage: e.message, stats: { turns: 0, tokens: 0 } };
|
|
1593
|
+
await deps.runStore.setTerminal(taskId, "failed", c, c.errorMessage ?? null).catch((err) => {
|
|
1594
|
+
deps.logger?.warn?.("wake_synthetic_run_release_failed", { taskId, sessionId, errorCode: e.code, error: err instanceof Error ? err.message : String(err), note: "the synthetic run row for this refused fresh-run resume could not be settled — its task_active claim is still held and the next attempt on this session will 409 conflict.session_active_run until reapStale collects it (runStaleSec)" });
|
|
1595
|
+
});
|
|
1596
|
+
settleFleet("failed");
|
|
1597
|
+
}
|
|
1598
|
+
else if (retriable) {
|
|
1590
1599
|
await (outcome.gate === "plan_review" ? deps.runStore.setNeedsReview(taskId) : deps.runStore.setSuspended(taskId)).catch(() => undefined);
|
|
1591
1600
|
settleFleet(outcome.gate === "plan_review" ? "needs_review" : "suspended");
|
|
1592
1601
|
}
|
|
@@ -1604,6 +1613,9 @@ export function createHttpServer(rawDeps) {
|
|
|
1604
1613
|
? (outcome.gate === "policy_ask" ? "approval_binding_mismatch" : "resume_outcome_invalid")
|
|
1605
1614
|
: e.code,
|
|
1606
1615
|
...(e.detail?.field ? { field: e.detail.field } : {}),
|
|
1616
|
+
...(e.code === "resume.usage_window_exhausted" && typeof e.detail?.retryAfterMs === "number" && Number.isFinite(e.detail.retryAfterMs) && e.detail.retryAfterMs > 0
|
|
1617
|
+
? { retryAfterSec: Math.max(1, Math.ceil(e.detail.retryAfterMs / 1000)) }
|
|
1618
|
+
: {}),
|
|
1607
1619
|
...(retriable ? { retriable: true } : {}),
|
|
1608
1620
|
},
|
|
1609
1621
|
};
|
|
@@ -1662,7 +1674,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1662
1674
|
},
|
|
1663
1675
|
};
|
|
1664
1676
|
}
|
|
1665
|
-
async function resumePreempted(sessionId, req) {
|
|
1677
|
+
async function resumePreempted(sessionId, req, acceptEarly) {
|
|
1666
1678
|
const cs = deps.checkpointStore;
|
|
1667
1679
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
1668
1680
|
if (!token)
|
|
@@ -1687,9 +1699,9 @@ export function createHttpServer(rawDeps) {
|
|
|
1687
1699
|
const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
1688
1700
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
1689
1701
|
const verifyRounds = verifyRoundsFromBody(ctx.body);
|
|
1690
|
-
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
|
|
1702
|
+
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds, ...(acceptEarly === true ? { acceptEarly: true } : {}) });
|
|
1691
1703
|
}
|
|
1692
|
-
async function resumeWake(sessionId, message, caller, req) {
|
|
1704
|
+
async function resumeWake(sessionId, message, caller, req, acceptEarly) {
|
|
1693
1705
|
const cs = deps.checkpointStore;
|
|
1694
1706
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
1695
1707
|
if (!token)
|
|
@@ -1747,9 +1759,10 @@ export function createHttpServer(rawDeps) {
|
|
|
1747
1759
|
outcome: { gate: "wake", ...(message !== undefined ? { message: { text: message, trusted } } : {}) },
|
|
1748
1760
|
verifyRounds: undefined,
|
|
1749
1761
|
mintFreshRun: { source: "wake" },
|
|
1762
|
+
...(acceptEarly === true ? { acceptEarly: true } : {}),
|
|
1750
1763
|
});
|
|
1751
1764
|
}
|
|
1752
|
-
async function resumePlanReview(sessionId, decision, editedPlan, reason, req, log) {
|
|
1765
|
+
async function resumePlanReview(sessionId, decision, editedPlan, reason, req, log, acceptEarly) {
|
|
1753
1766
|
const cs = deps.checkpointStore;
|
|
1754
1767
|
const log404 = (r) => deps.logger?.info?.("decide_404", {
|
|
1755
1768
|
reason: r,
|
|
@@ -1795,7 +1808,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1795
1808
|
...(editedPlan !== undefined ? { editedPlan } : {}),
|
|
1796
1809
|
...(reason ? { reason } : {}),
|
|
1797
1810
|
};
|
|
1798
|
-
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds });
|
|
1811
|
+
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(acceptEarly === true ? { acceptEarly: true } : {}) });
|
|
1799
1812
|
}
|
|
1800
1813
|
function recordTaskResult(result) {
|
|
1801
1814
|
deps.metrics?.inc("tasks_total", { status: result.status });
|
|
@@ -334,7 +334,18 @@ export interface TaskRequestBody {
|
|
|
334
334
|
* `McpServerSpec`. 🔒 honored on any SINGLE-USER deployment (task-mcp.ts `mcpInjectionHonored` = `requirePrincipal!==true`)
|
|
335
335
|
* — the requester is the super-admin of their OWN worker (CC-parity), on ANY execution lane (the stdio MCP runs on the
|
|
336
336
|
* worker, not the exec env); IGNORED on multi-tenant deployments (can't make the shared worker run an arbitrary
|
|
337
|
-
* command). Validated + gated downstream; the center/config baseline wins on a name clash.
|
|
337
|
+
* command). Validated + gated downstream; the center/config baseline wins on a name clash.
|
|
338
|
+
*
|
|
339
|
+
* design/378(core 5.60.0):条目上可选的 `contentOrigin`(`"local"|"execution"|"external"`)= **内容
|
|
340
|
+
* 出处声明**,只影响记忆写治理(会话污染标记),对审批门/策略/角色表零作用。缺席 = pre-378 逐字节
|
|
341
|
+
* 现行为(判 external)。🔴 **新提交(fresh)腿上,词表外的值 ⇒ 同步 400 `request.field_invalid`** ——
|
|
342
|
+
* 静默丢会把一次信任边界声明变成探针。⚠️ **耐久 resume 腿上不拒**:那条腿重放的是存量 body,4xx 会把
|
|
343
|
+
* 一条 parked 任务永久砖死(折成 `409 resume_blocked_by_policy`),所以坏值在那里**降级**为「键缺席」+
|
|
344
|
+
* 一条 `mcp_content_class_dropped` warn(本仓三处 `opts.leg` 先例的同款姿势)。两条腿上 server 本体都
|
|
345
|
+
* 保留 —— 丢的只是声明。core 把这个键定性为**部署面**的键,本仓对它的 reject-or-strip 义务由既有的单用户闸
|
|
346
|
+
* **结构性**兑现:多租户腿整只 `mcpServers` 被忽略 ⇒ 租户够不着;单用户腿 = core 明文豁免的超管面
|
|
347
|
+
* (完整论证见 `task-mcp.ts` 的 `assertRequestMcpContentOrigin` 顶注)。部署面的正门是 center 下发的
|
|
348
|
+
* `CenterMcpServer.contentOrigin`。 */
|
|
338
349
|
mcpServers?: McpServerSpec[];
|
|
339
350
|
/** [DESIGN-269 车1] Caller-supplied per-request A2A peers (remote agents this task may call), aligned to
|
|
340
351
|
* core `A2aServerSpec`. 🔒 honored on a SINGLE-USER deployment that has neither locked the `a2a` key nor
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/378(core 5.60.0)—— `McpServerSpec.contentOrigin` 的**闭词表单一属主**。
|
|
3
|
+
*
|
|
4
|
+
* core 给 MCP 条目开了一个**内容出处声明**座位:一台 server 的整套工具的结果算 `"local"`(部署自己
|
|
5
|
+
* 写的)/ `"execution"`(执行环境产物,`execIsExternalContent` 仍能收紧)/ `"external"`(第三方内容,
|
|
6
|
+
* 显式 PIN —— 它同时**取消** `trustedTools` 的按名豁免)。缺席 = pre-378 逐字节现行为(协议命名空间
|
|
7
|
+
* 一律判 external,fail-closed)。它只影响**记忆写治理**(会话污染标记),core 明写「no gate/policy/
|
|
8
|
+
* roster effect」——与审批门、角色表、工具白名单都无关。
|
|
9
|
+
*
|
|
10
|
+
* ## 为什么值得一个自己的模块
|
|
11
|
+
* 本仓有**两个**平面会拿到这个键(部署面 center 下发 / 请求面 body),两边都要判「词在不在表里」。
|
|
12
|
+
* 一条安全轴上的判据有两份实现,就是其中一份将来漂开的方式(`mcpInjectionHonored` 顶注里那句
|
|
13
|
+
* 「判据只有一只谓词,不许两处各判一半」的同款理由)。⇒ 表与谓词都只有这一份。
|
|
14
|
+
*
|
|
15
|
+
* ## 闭集纪律(CLAUDE.md:安全轴上未知词必须是**编译**错误)
|
|
16
|
+
* 下面两条 `AssertNever` 是**双向**钉:
|
|
17
|
+
* · core 给 `ToolContentOrigin` 加词而本表没跟 ⇒ `_ContentOriginCovers` 残余非 never ⇒ tsc 红;
|
|
18
|
+
* · 本表写错/写多一个词 ⇒ `_ContentOriginNoStray` 残余非 never ⇒ tsc 红。
|
|
19
|
+
* 单向钉挡不住第二种(手滑把 `"local"` 打成 `"locale"` 只会让一个合法声明被静默拒),所以两条都要。
|
|
20
|
+
*/
|
|
21
|
+
import type { ToolContentOrigin } from "@sema-agent/core";
|
|
22
|
+
/** 编译期断言:`T` 必须收敛到 `never`(有残余 ⇒ tsc 红)。 */
|
|
23
|
+
type AssertNever<T extends never> = T;
|
|
24
|
+
/** core `ToolContentOrigin` 的执法副本 —— **唯一**一份(见模块头注)。 */
|
|
25
|
+
export declare const CONTENT_ORIGIN_WORDS: readonly ["local", "execution", "external"];
|
|
26
|
+
type ContentOriginWord = (typeof CONTENT_ORIGIN_WORDS)[number];
|
|
27
|
+
type _ContentOriginCovers = AssertNever<Exclude<ToolContentOrigin, ContentOriginWord>>;
|
|
28
|
+
type _ContentOriginNoStray = AssertNever<Exclude<ContentOriginWord, ToolContentOrigin>>;
|
|
29
|
+
/** (占位使用,防 noUnusedLocals;类型别名零运行时。) */
|
|
30
|
+
export type ContentOriginWordTableGuards = [_ContentOriginCovers, _ContentOriginNoStray];
|
|
31
|
+
/**
|
|
32
|
+
* 词表内?—— 两个平面共用的**那一只**谓词。
|
|
33
|
+
*
|
|
34
|
+
* 🔴 严判:非串(数字 / 布尔 / null / 对象)与大小写不符(`"LOCAL"`)一律**不在词表内**。core 的
|
|
35
|
+
* `ToolContentOrigin` 是字面量联合,不做规范化;本层替它「宽容一下」等于自造第二份值域语义,而这条
|
|
36
|
+
* 轴上宽容的方向恰好是**放宽信任**(把一个写坏的声明当成 `"local"` 读)。
|
|
37
|
+
*/
|
|
38
|
+
export declare function isContentOriginWord(v: unknown): v is ToolContentOrigin;
|
|
39
|
+
/** 坏值的**可打印**摘要(进 400 文案 / warn 元数据)——中和 + 截长,绝不把任意长度的调用方输入原样回显。 */
|
|
40
|
+
export declare function describeBadContentOrigin(v: unknown): string;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=mcp-content-origin.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const CONTENT_ORIGIN_WORDS = ["local", "execution", "external"];
|
|
2
|
+
const WORDS = new Set(CONTENT_ORIGIN_WORDS);
|
|
3
|
+
export function isContentOriginWord(v) {
|
|
4
|
+
return typeof v === "string" && WORDS.has(v);
|
|
5
|
+
}
|
|
6
|
+
export function describeBadContentOrigin(v) {
|
|
7
|
+
const raw = typeof v === "string" ? JSON.stringify(v) : v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
|
|
8
|
+
return raw.length > 64 ? `${raw.slice(0, 64)}…` : raw;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=mcp-content-origin.js.map
|
|
@@ -27,8 +27,17 @@ export declare function resourceSuspendOptIn(opts: {
|
|
|
27
27
|
isCascade: boolean;
|
|
28
28
|
/** Multi-tenant isolation key — the SAME key durableApproval uses (the principal; "_" when auth is off). */
|
|
29
29
|
scope: string;
|
|
30
|
+
/** [3833] S-3:`RESOURCE_SUSPEND_TOTAL_TOKENS`(跨片 token 总额)。0/缺省 ⇒ 不铸键。 */
|
|
31
|
+
totalTokens?: number;
|
|
32
|
+
/** [3833] S-3:`RESOURCE_SUSPEND_TOTAL_BUDGET_USD`(跨片 $ 总额)。0/缺省 ⇒ 不铸键。 */
|
|
33
|
+
totalBudgetUsd?: number;
|
|
34
|
+
/** [3833] S-3:`RESOURCE_SUSPEND_MAX_SLICES`(跨片 slice 数上限)。0/缺省 ⇒ 不铸键。 */
|
|
35
|
+
maxSlices?: number;
|
|
30
36
|
}): {
|
|
31
37
|
scope: string;
|
|
32
38
|
ttlMs?: number;
|
|
39
|
+
totalTokens?: number;
|
|
40
|
+
totalBudgetUsd?: number;
|
|
41
|
+
maxSlices?: number;
|
|
33
42
|
} | undefined;
|
|
34
43
|
//# sourceMappingURL=resource-suspend.d.ts.map
|
package/dist/resource-suspend.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export function resourceSuspendOptIn(opts) {
|
|
2
2
|
if (!opts.enabled || opts.isVerify || opts.isCascade)
|
|
3
3
|
return undefined;
|
|
4
|
-
return {
|
|
4
|
+
return {
|
|
5
|
+
scope: opts.scope,
|
|
6
|
+
...(opts.ttlSec > 0 ? { ttlMs: opts.ttlSec * 1000 } : {}),
|
|
7
|
+
...(opts.totalTokens !== undefined && opts.totalTokens > 0 ? { totalTokens: opts.totalTokens } : {}),
|
|
8
|
+
...(opts.totalBudgetUsd !== undefined && opts.totalBudgetUsd > 0 ? { totalBudgetUsd: opts.totalBudgetUsd } : {}),
|
|
9
|
+
...(opts.maxSlices !== undefined && opts.maxSlices > 0 ? { maxSlices: opts.maxSlices } : {}),
|
|
10
|
+
};
|
|
5
11
|
}
|
|
6
12
|
//# sourceMappingURL=resource-suspend.js.map
|
package/dist/task-mcp.d.ts
CHANGED
|
@@ -39,11 +39,16 @@ export declare function mcpInjectionHonored(config: {
|
|
|
39
39
|
}): boolean;
|
|
40
40
|
/**
|
|
41
41
|
* Shape-check the raw `body.mcpServers`. `null` → a non-array (caller error; the route may 400). Otherwise the
|
|
42
|
-
* valid subset + the names of dropped (malformed/over-cap) entries
|
|
42
|
+
* valid subset + the names of dropped (malformed/over-cap) entries + design/378's `badContentOrigin`
|
|
43
|
+
* (**降级**掉的内容出处声明,与 `dropped` 是两回事:那条 server **留着**,丢的只是它的声明)。
|
|
43
44
|
*/
|
|
44
45
|
export declare function validateRequestMcp(raw: unknown): {
|
|
45
46
|
ok: McpServerSpec[];
|
|
46
47
|
dropped: string[];
|
|
48
|
+
badContentOrigin: Array<{
|
|
49
|
+
server: string;
|
|
50
|
+
value: string;
|
|
51
|
+
}>;
|
|
47
52
|
} | null;
|
|
48
53
|
/**
|
|
49
54
|
* design/170 件C §4.3 —— **同步**拒面:部署锁了 `mcp` 而请求仍带 `mcpServers` ⇒ 400 `config.locked_key`。
|
|
@@ -63,6 +68,47 @@ export declare function validateRequestMcp(raw: unknown): {
|
|
|
63
68
|
* 任务。同样是失败,同步 400 至少是**立刻可诊断**的那一种,而且拒得出理由(锁在哪、怎么改)。
|
|
64
69
|
*/
|
|
65
70
|
export declare function assertRequestMcpUnlocked(bodyMcp: unknown, lockedKeys: ReadonlySet<LockedKey>): void;
|
|
71
|
+
/**
|
|
72
|
+
* design/378(core 5.60.0 提货批件②)—— 请求体里 `mcpServers[].contentOrigin` 的**值域门**,腿首同步拒。
|
|
73
|
+
*
|
|
74
|
+
* ## 先把 core 的硬条款抄下来,因为本腿的全部争议都在它上面
|
|
75
|
+
* `McpServerSpec.contentOrigin` 顶注逐字:「TRUST SOURCE — a DEPLOYMENT-plane key … any assembly layer
|
|
76
|
+
* that accepts REQUEST-side MCP entries **must reject or strip this key** from them … A single-tenant
|
|
77
|
+
* superuser surface (a host reading its own `--mcp-config` file) **IS the deployment plane and needs no
|
|
78
|
+
* such gate**.」
|
|
79
|
+
*
|
|
80
|
+
* ## 本仓怎么兑现这条义务(**结构性**,不是靠纪律)
|
|
81
|
+
* 请求腿的条目只有在 {@link mcpInjectionHonored} 为真时才被读进 `TaskSpec.mcp`,而那只谓词的语义正是
|
|
82
|
+
* 「本部署是单用户 ⇒ 请求者就是这台 worker 的超管」—— core 点名豁免的那一格。多租户部署
|
|
83
|
+
* (`requirePrincipal===true`)整只 `mcpServers` 被忽略,租户**结构上**够不着这个键(钉见
|
|
84
|
+
* `test/task-mcp.test.ts` 的多租户格)。同一条腿今天已经透传 `toolAxes`(喂**审批门**,权限比本键**高**
|
|
85
|
+
* 一档 —— core 明写 contentOrigin「no gate/policy/roster effect」)与 stdio 的**任意命令**(worker 宿主
|
|
86
|
+
* 上的真 RCE);在那两样之后,「我声明我自己这台 server 的内容不算外来」不构成任何新的暴露面。
|
|
87
|
+
* 名字撞车时 baseline 恒赢(`mergeRequestMcp`)⇒ 调用方也改写不了一台**已配置** server 的声明。
|
|
88
|
+
*
|
|
89
|
+
* ## 为什么 fresh 腿是 400 而不是静默丢键
|
|
90
|
+
* 静默丢键会把一次**信任边界声明**变成探针 —— 调用方以为声明生效了,其实没有。本仓 `opts.leg` 的成文
|
|
91
|
+
* 契约(`ServiceDeps.resolveSpec` 顶注)逐字写着 fresh 腿「may fail-loud 400 on combos that would
|
|
92
|
+
* silently discard caller payload」,这正是那种 combo。
|
|
93
|
+
*
|
|
94
|
+
* ## 🔴 但只在 fresh 腿(与 {@link assertRequestMcpUnlocked} **刻意分歧**)
|
|
95
|
+
* 锁那条敢在 resume 腿同拒,是因为它有一个本门**没有**的前提:锁在场 ⇒ core 的 preflight 无论如何都会
|
|
96
|
+
* 拒这条腿,放进去只换来一个跑起来又终态失败的任务。而坏 `contentOrigin` 在本仓两个平面都被**降级**掉了
|
|
97
|
+
* (`normalizeServer` / `resolveMcpServers`),core 根本看不到它,也就永远不会拒。
|
|
98
|
+
* 与此同时 `checkpoint_ctx` 存的是**原始** `TaskRequestBody`,耐久 resume 逐字重放它,从本门逃出去的
|
|
99
|
+
* `HttpError` 会被 `src/http/server.ts` 折成 `409 resume_blocked_by_policy` ⇒ 一条**升级前** park、body 里
|
|
100
|
+
* 带着当时被静默忽略的坏值的任务,升级后**永久**恢复不了(codex 对抗复审 R1-[high],验真后采纳)。
|
|
101
|
+
* ⇒ 照本仓成文的三处 `opts.leg` 先例(`appendSystemPrompt` 案 / `@model` 名单 #233 终裁 (a) /
|
|
102
|
+
* `compactionModel` 案,都逐字写着「砖死 resume 比降级糟」):fresh fail-loud,resume 降级 + 留声
|
|
103
|
+
* (留声点 = {@link resolveRequestMcp} 的 `mcp_content_class_dropped`)。挂点判据在 `boot/resolve-spec.ts`。
|
|
104
|
+
*
|
|
105
|
+
* ## 位置与射程
|
|
106
|
+
* 排在腿首、`assertRequestMcpUnlocked` 旁边 —— **任何副作用之前**(附件绑定 / per-session cwd·env 写入;
|
|
107
|
+
* 理由逐字见 `boot/resolve-spec.ts` 那两行的注)。判据是**纯值域**、与**租户车道**无关:一个写坏的请求
|
|
108
|
+
* 在哪条车道上都是写坏的,而多租户腿本来就要忽略整只数组,告诉调用方「你这个键写错了」只多不少。
|
|
109
|
+
* 形不合(非数组 / 条目非对象)**不归本门**——那是 `validateRequestMcp` 的既有宽容形,本门不抢裁决。
|
|
110
|
+
*/
|
|
111
|
+
export declare function assertRequestMcpContentOrigin(bodyMcp: unknown): void;
|
|
66
112
|
/**
|
|
67
113
|
* Merge GATED per-request MCP servers OVER the deployment baseline (center/config). The baseline WINS on a name
|
|
68
114
|
* clash — a caller can ADD servers but can never SHADOW a configured one (the security baseline is authoritative,
|
package/dist/task-mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveComplianceDenies } from "@sema-agent/core";
|
|
2
2
|
import { HttpError } from "./security.js";
|
|
3
|
+
import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "./mcp-content-origin.js";
|
|
3
4
|
export const MAX_REQUEST_MCP_SERVERS = 32;
|
|
4
5
|
export function mcpInjectionHonored(config) {
|
|
5
6
|
if (config.lockedConfigKeys?.includes("mcp") === true)
|
|
@@ -11,7 +12,7 @@ export function mcpInjectionHonored(config) {
|
|
|
11
12
|
function isStringRecord(v) {
|
|
12
13
|
return !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
|
|
13
14
|
}
|
|
14
|
-
function normalizeServer(e) {
|
|
15
|
+
function normalizeServer(e, noteBadOrigin) {
|
|
15
16
|
if (!e || typeof e !== "object")
|
|
16
17
|
return null;
|
|
17
18
|
const o = e;
|
|
@@ -70,17 +71,24 @@ function normalizeServer(e) {
|
|
|
70
71
|
if (Object.keys(axes).length > 0)
|
|
71
72
|
spec.toolAxes = axes;
|
|
72
73
|
}
|
|
74
|
+
if (o.contentOrigin !== undefined) {
|
|
75
|
+
if (isContentOriginWord(o.contentOrigin))
|
|
76
|
+
spec.contentOrigin = o.contentOrigin;
|
|
77
|
+
else
|
|
78
|
+
noteBadOrigin(spec.name, o.contentOrigin);
|
|
79
|
+
}
|
|
73
80
|
return spec;
|
|
74
81
|
}
|
|
75
82
|
export function validateRequestMcp(raw) {
|
|
76
83
|
if (raw === undefined || raw === null)
|
|
77
|
-
return { ok: [], dropped: [] };
|
|
84
|
+
return { ok: [], dropped: [], badContentOrigin: [] };
|
|
78
85
|
if (!Array.isArray(raw))
|
|
79
86
|
return null;
|
|
80
87
|
const ok = [];
|
|
81
88
|
const dropped = [];
|
|
89
|
+
const badContentOrigin = [];
|
|
82
90
|
for (const e of raw.slice(0, MAX_REQUEST_MCP_SERVERS)) {
|
|
83
|
-
const v = normalizeServer(e);
|
|
91
|
+
const v = normalizeServer(e, (server, value) => badContentOrigin.push({ server, value: describeBadContentOrigin(value) }));
|
|
84
92
|
if (v)
|
|
85
93
|
ok.push(v);
|
|
86
94
|
else
|
|
@@ -88,7 +96,7 @@ export function validateRequestMcp(raw) {
|
|
|
88
96
|
}
|
|
89
97
|
if (raw.length > MAX_REQUEST_MCP_SERVERS)
|
|
90
98
|
dropped.push(`(+${raw.length - MAX_REQUEST_MCP_SERVERS} over cap of ${MAX_REQUEST_MCP_SERVERS})`);
|
|
91
|
-
return { ok, dropped };
|
|
99
|
+
return { ok, dropped, badContentOrigin };
|
|
92
100
|
}
|
|
93
101
|
export function assertRequestMcpUnlocked(bodyMcp, lockedKeys) {
|
|
94
102
|
if (!lockedKeys.has("mcp"))
|
|
@@ -99,6 +107,21 @@ export function assertRequestMcpUnlocked(bodyMcp, lockedKeys) {
|
|
|
99
107
|
code: "config.locked_key",
|
|
100
108
|
});
|
|
101
109
|
}
|
|
110
|
+
export function assertRequestMcpContentOrigin(bodyMcp) {
|
|
111
|
+
if (!Array.isArray(bodyMcp))
|
|
112
|
+
return;
|
|
113
|
+
for (const e of bodyMcp) {
|
|
114
|
+
if (!e || typeof e !== "object" || Array.isArray(e))
|
|
115
|
+
continue;
|
|
116
|
+
const v = e.contentOrigin;
|
|
117
|
+
if (v === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
if (isContentOriginWord(v))
|
|
120
|
+
continue;
|
|
121
|
+
throw new HttpError(400, `mcpServers[].contentOrigin must be one of ${CONTENT_ORIGIN_WORDS.map((w) => `"${w}"`).join(" | ")} — got ${describeBadContentOrigin(v)}` +
|
|
122
|
+
` (omit the key entirely for the default: this server's tools classify as external content)`, { code: "request.field_invalid" });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
102
125
|
export function mergeRequestMcp(baseline, gated) {
|
|
103
126
|
if (gated.length === 0)
|
|
104
127
|
return baseline;
|
|
@@ -121,6 +144,13 @@ export function resolveRequestMcp(baseline, bodyMcp, config, logger) {
|
|
|
121
144
|
}
|
|
122
145
|
if (v.dropped.length > 0)
|
|
123
146
|
logger?.warn("mcp_injection_dropped", { dropped: v.dropped });
|
|
147
|
+
if (v.badContentOrigin.length > 0) {
|
|
148
|
+
logger?.warn("mcp_content_class_dropped", {
|
|
149
|
+
entries: v.badContentOrigin,
|
|
150
|
+
allowed: CONTENT_ORIGIN_WORDS,
|
|
151
|
+
note: "declaration DROPPED — these servers keep the pre-378 default (external content, session gets marked); the servers themselves still mount",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
124
154
|
return mergeRequestMcp(baseline, v.ok);
|
|
125
155
|
}
|
|
126
156
|
//# sourceMappingURL=task-mcp.js.map
|
package/dist/task-settings.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, RUN_WORKFLOW_TOOL_NAME, } from "@sema-agent/core";
|
|
1
|
+
import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, createPermissionRulePolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, RUN_WORKFLOW_TOOL_NAME, } from "@sema-agent/core";
|
|
2
2
|
import { findCcRuleFormNames, findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
|
|
3
3
|
import { parseHooksConfig } from "./hooks/hook-runner.js";
|
|
4
4
|
export function coercePermissionMode(raw) {
|
|
@@ -151,16 +151,17 @@ export function acceptAppendSystemPrompt(v, warn, packDropsAppend) {
|
|
|
151
151
|
}
|
|
152
152
|
return v;
|
|
153
153
|
}
|
|
154
|
+
const ASK_RULE_SOURCE = "settings.permissions.ask";
|
|
154
155
|
function createAskListPolicy(askNames) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
156
|
+
try {
|
|
157
|
+
return createPermissionRulePolicy(askNames.map((name) => ({ rule: name, behavior: "ask", source: ASK_RULE_SOURCE })), {
|
|
158
|
+
defaultAction: "allow",
|
|
159
|
+
caps: { maxRules: MAX_SETTINGS_PERMISSION_RULES },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw new Error(`permissions.ask has rule(s) core cannot compile — ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
164
|
+
}
|
|
164
165
|
}
|
|
165
166
|
const WORKFLOW_ASK_NAMES = [RUN_WORKFLOW_TOOL_NAME];
|
|
166
167
|
function createWorkflowAskPolicy(isExempt) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 维护:core 新键到来时,transparently 透传的加进对应 PROJECTED 并去实现投影;有意不上 wire 的加进
|
|
16
16
|
* EXCLUDED 并写一行理由。谁改投影(project.ts / fleet-bus.ts / roster-store-sql.ts)谁同步本文件。
|
|
17
17
|
*/
|
|
18
|
-
import type { TaskNotificationPayload, BackgroundChildEvent, RosterEntry, AskRequest, TaskEvent, TraceEvent, MailboxMessage, MailboxStore } from "@sema-agent/core";
|
|
18
|
+
import type { TaskNotificationPayload, BackgroundChildEvent, RosterEntry, AskRequest, CheckpointSummary, TaskEvent, TraceEvent, MailboxMessage, MailboxStore } from "@sema-agent/core";
|
|
19
19
|
/** 编译期断言:T 必须收敛到 never(有残余键 = tsc 红)。 */
|
|
20
20
|
type AssertAllKeysHandled<T extends never> = T;
|
|
21
21
|
type NotificationProjected = "task_id" | "task_type" | "toolUseId" | "status" | "summary" | "result" | "output_file" | "usage" | "sessionId" | "seq" | "lines" | "stoppedBy" | "source" | "exitCode" | "partial" | "diagnostics" | "recentSteps" | "editedFiles" | "resumable" | "completionId" | "error" | "errorCode";
|
|
@@ -44,6 +44,9 @@ type _GuardPermissionTraceReverse = AssertAllKeysHandled<Exclude<PermissionTrace
|
|
|
44
44
|
/** `T` 非 `never` 时收敛到 `never`(空集 ⇒ 残留 `"EMPTY"` ⇒ tsc 红)。 */
|
|
45
45
|
type AssertNonEmpty<T> = [T] extends [never] ? "EMPTY" : never;
|
|
46
46
|
type _GuardPermissionTraceNonEmpty = AssertAllKeysHandled<AssertNonEmpty<PermissionTraceKind>>;
|
|
47
|
+
type SummaryProjected = "sessionId" | "scope" | "createdAt" | "gateKind" | "severity" | "spentMicroUsd" | "deadline" | "hasBidiControls";
|
|
48
|
+
type SummaryExcluded = "token" | "toolInput" | "toolName" | "toolCallId" | "preview" | "principal" | "sourceTaskId" | "contentKind" | "restoreMode" | "checkpointId";
|
|
49
|
+
type _GuardInboxSummary = AssertAllKeysHandled<Exclude<keyof CheckpointSummary, SummaryProjected | SummaryExcluded>>;
|
|
47
50
|
export type CoreKeysetGuards = [
|
|
48
51
|
_GuardNotification,
|
|
49
52
|
_GuardBgNotif,
|
|
@@ -54,7 +57,8 @@ export type CoreKeysetGuards = [
|
|
|
54
57
|
_GuardMailboxAppend,
|
|
55
58
|
_GuardPermissionTrace,
|
|
56
59
|
_GuardPermissionTraceReverse,
|
|
57
|
-
_GuardPermissionTraceNonEmpty
|
|
60
|
+
_GuardPermissionTraceNonEmpty,
|
|
61
|
+
_GuardInboxSummary
|
|
58
62
|
];
|
|
59
63
|
export {};
|
|
60
64
|
//# sourceMappingURL=core-keyset-guard.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "7.
|
|
4
|
-
"description": "Sema Server
|
|
3
|
+
"version": "7.47.0-rc.2",
|
|
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",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -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.
|
|
57
|
+
"@sema-agent/core": "^5.60.1",
|
|
58
58
|
"@sema-agent/registry-core": "^0.19.0",
|
|
59
59
|
"e2b": "^2.28.0",
|
|
60
60
|
"libsodium-wrappers": "^0.8.4",
|