@sema-agent/server 7.78.1 → 7.78.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/README.md CHANGED
@@ -114,6 +114,12 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
114
114
  # To pin a model: add "model":"<catalog id>" to the body; see GET /v1/capabilities for what is available
115
115
  ```
116
116
 
117
+ - **Where your files have to be**: a caller-supplied `cwd` is a path on the machine the **server process**
118
+ runs on, and it is honored only on the single-user `REMOTE_EXEC=host` lane — that lane is by definition
119
+ *this* box (no container boundary). So "server on box A, my repo on box B" does not work: nothing maps
120
+ B's paths into A. Run the server on the box that holds the files (or `run-local` there), or use a
121
+ container lane (`e2b` / `k8s` / `local-docker`) where the workspace is the sandbox's own and `cwd` is
122
+ ignored.
117
123
  - **Distribution coordinates**: npm = [`@sema-agent/server`](https://www.npmjs.com/package/@sema-agent/server)
118
124
  (public on npmjs) · images = `ghcr.io/sema-agent/sema-server` + `docker.io/claybobby/sema-server`
119
125
  (both public; `:latest` rolling, `:<sha>` pinned).
package/USAGE.md CHANGED
@@ -404,6 +404,10 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
404
404
  `SERVICE_AUTH_TOKEN`/TOKENS 名录)⇒ 自动绑 `127.0.0.1`,boot 日志点名原因。理由:该形常配
405
405
  `REMOTE_EXEC=host`(用户真机、非沙箱),绑全接口=同网段任何人可无鉴权提交任务并执行。
406
406
  - 配了凭证的部署(云形/k8s/compose)缺省**不变**(全接口),既有部署零影响。
407
+ - **跨机不映射 cwd**:请求里的 `cwd` 是**服务进程所在那台机器**上的路径,且只有单用户 `REMOTE_EXEC=host`
408
+ 车道会尊重它(那条车道的定义就是"运维自己这台机、无容器边界")。所以「服务跑在 A 机、代码在 B 机」不成立
409
+ —— 没有任何东西把 B 的路径映到 A。要么把服务(或 `run-local`)跑在放着文件的那台机器上,要么走容器车道
410
+ (`e2b`/`k8s`/`local-docker`),那里的工作区是沙箱自己的,`cwd` 被忽略。
407
411
 
408
412
  **workspace 浏览面 —— 已随整树快照纪元退役**
409
413
  - 旧四端点(list/tree/file/archive)恒 `501 capability.workspace_retired`,`capabilities.workspace`
@@ -21,7 +21,7 @@
21
21
  * 没有方法、没有捕获的行为。
22
22
  */
23
23
  import { z } from "zod";
24
- import { type AskEvidenceAbsence, type ReadRootGrantCandidate, type RuleOffer } from "@sema-agent/core";
24
+ import { type AskEvidenceAbsence, type AskRequest as CoreAskRequest, type ReadRootGrantCandidate, type RuleOffer } from "@sema-agent/core";
25
25
  import type { AskRow } from "./plugins/approval-ask-store-sql.js";
26
26
  /** 模型自由文本(`sourceAgentName` / `delegation.agentName`)的限长(设计稿 §6.2)。设计 §3.1 把
27
27
  * 「限长 + 脱敏」写成 **server 新增责任**(引擎无此层):spawning model 挑的名字是自由文本,
@@ -322,14 +322,24 @@ export type RuleEvidenceProjection = z.infer<typeof RuleEvidenceSchema>;
322
322
  * · `orgRevision` / 缺席词 verbatim(数值 / 闭集词,非内容)。
323
323
  */
324
324
  export declare function readRuleEvidence(req: unknown): RuleEvidenceProjection | undefined;
325
- /** [ref] 第五单(core [ref] 修②)—— `ruleOffers` 缺席因由的**闭三词集**(core d.ts 逐字;词表是闭集,
326
- * 未知词=形不合=按缺席处置,绝不透传一个消费端词表外的值)。 */
325
+ /**
326
+ * [ref] 第五单(core [ref] 修②)—— `ruleOffers` 缺席因由的**闭三词集**。
327
+ *
328
+ * 🔴 **型从 core 派生,不手抄**(S-249):core 刻意**不导出**这个名字,也不导出运行期常量
329
+ * (`permission-rule-lanes.d.ts` 顶注逐字:「Deliberately not exported: this is a wire vocabulary, and its
330
+ * two faces declare it literally … so a consumer reads the closed set on the type it is holding」)——
331
+ * 于是本仓**必须**自持一份运行期词表(zod 要字面元组),但**不必**自持一份型。做法:型 =
332
+ * `AskRequest["ruleOffersAbsence"]` 的联合(那正是 core 要我们读的那张脸),运行期词表另立一行,
333
+ * 两者之间架**两向编译围栏** ⇒ core 加词 / 改词 / 退役任一,本文件当场 tsc 红。
334
+ * (对照 {@link AskEvidenceAbsenceSchema}:那一族 core **给了**运行期常量,所以那边零词表、用 `z.custom`。
335
+ * 两族的姿势不同不是分歧,是「上游给什么就用什么」的同一条纪律的两种兑现。)
336
+ */
337
+ export type RuleOffersAbsence = NonNullable<CoreAskRequest["ruleOffersAbsence"]>;
327
338
  export declare const RuleOffersAbsenceSchema: z.ZodEnum<{
328
339
  mandated: "mandated";
329
340
  lane_cannot_speak: "lane_cannot_speak";
330
341
  shadowed: "shadowed";
331
342
  }>;
332
- export type RuleOffersAbsence = z.infer<typeof RuleOffersAbsenceSchema>;
333
343
  /**
334
344
  * `AskRequest.ruleOffersAbsence`(core [ref] 修②)的**边界窄读** —— 活卡帧与 `card_json` 的唯一铸造点
335
345
  * (与 {@link readProbeCause} 同款分工),两面结构性同值。闭集词 verbatim(非内容族,零 redact);
@@ -233,11 +233,18 @@ export function readRuleEvidence(req) {
233
233
  ...(raw.personalRuleDotsAbsent !== undefined ? { personalRuleDotsAbsent: raw.personalRuleDotsAbsent } : {}),
234
234
  };
235
235
  }
236
- export const RuleOffersAbsenceSchema = z.enum(["mandated", "lane_cannot_speak", "shadowed"]);
236
+ const RULE_OFFERS_ABSENCE_VALUES = ["mandated", "lane_cannot_speak", "shadowed"];
237
+ export const RuleOffersAbsenceSchema = z.enum(RULE_OFFERS_ABSENCE_VALUES);
237
238
  const RuleOffersAbsenceEnvelopeSchema = z.object({ ruleOffersAbsence: RuleOffersAbsenceSchema.optional() });
238
239
  export function readRuleOffersAbsence(req) {
239
240
  const parsed = RuleOffersAbsenceEnvelopeSchema.safeParse(req);
240
- return parsed.success ? parsed.data.ruleOffersAbsence : undefined;
241
+ if (parsed.success)
242
+ return parsed.data.ruleOffersAbsence;
243
+ const raw = req !== null && typeof req === "object" ? req.ruleOffersAbsence : undefined;
244
+ if (raw === undefined)
245
+ return undefined;
246
+ recordFailOpen("server.approval-card.closed-word-out-of-set", `ruleOffersAbsence=${redactSecrets(String(raw)).slice(0, 40)}`);
247
+ return undefined;
241
248
  }
242
249
  const DenialLimitFallbackSchema = z
243
250
  .object({
@@ -30,7 +30,7 @@ import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, norma
30
30
  import { cwdHonored, deviceCwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
31
31
  import { assertRequestA2aUnlocked, resolveRequestA2a } from "../task-a2a.js";
32
32
  import { assertRequestMcpContentOrigin, assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
33
- import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, shellGateForMode, withPermissionMode } from "../task-settings.js";
33
+ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, autoModeRequestedForMode, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, shellGateForMode, withPermissionMode, writeFaceForMode } from "../task-settings.js";
34
34
  import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationFromBody } from "../task-workflow.js";
35
35
  import { redactSecrets } from "../trace/redact.js";
36
36
  import { DeferredSandboxPathEnv, isSandboxPathAdjudicationLane, sandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
@@ -53,7 +53,7 @@ export function createResolveSpec(ctx) {
53
53
  assertRequestA2aUnlocked(body.a2aPeers, lockedKeys);
54
54
  const gated = await gateScenarioAndAppend(body, auth, opts);
55
55
  const effMode = effectivePermissionMode(body, gated.parsedSettings.settings);
56
- noteSessionShellGate?.(auth?.sessionId, effMode === "bypassPermissions");
56
+ noteSessionShellGate?.(auth?.sessionId, effMode !== undefined && shellGateForMode(effMode) === "off");
57
57
  const lane = bindSettingsCwdEnvAndModel(body, auth, gated, effMode, opts);
58
58
  const anchors = await resolveHistoryAnchors(body, auth);
59
59
  const spec = assembleSpecLiteral(body, auth, req, opts, { ...gated, ...lane, ...anchors }, effMode);
@@ -279,8 +279,8 @@ export function createResolveSpec(ctx) {
279
279
  additionalReadDirectories,
280
280
  enablePlanMode: config.planModeEnabled ? true : undefined,
281
281
  ...(effMode ? { shellGate: shellGateForMode(effMode) } : {}),
282
- ...(effMode === "bypassPermissions" ? { writeFace: "open" } : {}),
283
- ...(effMode === "auto" ? { autoModeRequested: true } : {}),
282
+ ...((f) => (f !== undefined ? { writeFace: f } : {}))(effMode !== undefined ? writeFaceForMode(effMode) : undefined),
283
+ ...((f) => (f !== undefined ? { autoModeRequested: f } : {}))(effMode !== undefined ? autoModeRequestedForMode(effMode) : undefined),
284
284
  selfOrchestration: selfOrchestrationFromBody({ selfOrchestration: body.selfOrchestration === true || parsedSettings.settings?.ultracode === true }, config, Boolean(centerRuntimeCapsResolver)),
285
285
  forwardSubagentEvents: body.forwardSubagentEvents === true ? true : undefined,
286
286
  retainSubagentSessions: normalizeRetainSubagentSessions(body.retainSubagentSessions),
@@ -34,6 +34,23 @@ export interface ConfigCatalogSpec {
34
34
  readonly derivedDefaultNote?: string;
35
35
  readonly danger?: readonly CatalogDangerAxis[];
36
36
  readonly enumValues?: readonly string[];
37
+ /**
38
+ * S-201① —— 本行**回显**得出的词集(`enumValues` 的子集;缺席 ⇒ 与 `enumValues` 同集)。
39
+ *
40
+ * 🔴 `enumValues` 一直是**两义**的:它既是行对外宣告的「你可以写哪些词」([ref] 件⑤/⑧ 两条钉逐字锚
41
+ * 这一义:窄了=少宣告一档、宽了=宣告一个 boot 会拒的词),又被回显校验当成「实算值合不合法」的闭集。
42
+ * 两义在**多数**行上碰巧同集,于是差别只活在散文里 —— `MANUAL_MODE_SHELL_GATE` 的
43
+ * `derivedDefaultNote` 写着「真姿态只有 always/classify 两个」,`MODEL_DEFAULT_THINKING` 写着
44
+ * 「缺席/off ⇒ 不铸」,而**没有任何一条腿读那两句话**:`off` 照样留在回显闭集里,回显校验对它放行。
45
+ *
46
+ * ⇒ 把那半句散文变成一处**校验腿真读的声明**:`sanitizeEnumValue` 问 `echoWords ?? enumValues`,
47
+ * 于是「这一格的合法回显值」从此只有一个属主,不再靠人脑对两个词表做差集。
48
+ *
49
+ * 🔴 **不上 wire**:消费方(UI 下拉、非法值的纠错提示)要的是「可写集」= `enumValues`,那一义不变;
50
+ * 本字段服务的是服务端自己的回显纪律。一个词进得了 `enumValues` 却不在这里 ⇒ 它的语义恒是
51
+ * 「**缺席的另一种拼法**」(写下去与不写同效),那正是目前仅有的两行的形。
52
+ */
53
+ readonly echoWords?: readonly string[];
37
54
  /**
38
55
  * S-100 —— **底层布尔 ↔ 闭集词**的行内映射,只给「实算腿回布尔、而行对外说词」的 enum 行。
39
56
  *
@@ -91,7 +108,7 @@ export interface ConfigCatalogRow {
91
108
  readonly effectiveValue: CatalogValue;
92
109
  /**
93
110
  * S-100(additive;**只在真非法时铸,恒不铸 `false`**)—— 本行的实算值**落在它自己宣告的
94
- * `enumValues` 之外**,或它的实算腿回了一个本行没有声明映射的布尔。
111
+ * 回显闭集之外**(`echoWords ?? enumValues`,S-201①),或它的实算腿回了一个本行没有声明映射的布尔。
95
112
  *
96
113
  * 在场时 `effectiveValue` 恒 `null`:回显一个集外值等于目录在教消费方写一个写回去就非法的词,
97
114
  * 而**静默丢**会让「配错了」与「真的没设」在 wire 上同形([ref] 响亮臂)。
@@ -204,7 +204,7 @@ export const CONFIG_CATALOG = [
204
204
  r("LOG_LEVEL", "observability", "string", "日志档位", { staticDefault: "info", configKey: "logLevel" }),
205
205
  r("LSP_ENABLED", "orchestration", "boolean", "LSP 沙箱腿 opt-in(需烤好的 LSP 模板;host 腿另有 LSP_HOST_ENABLED)", { staticDefault: "false", configKey: "lspEnabled" }),
206
206
  r("LSP_HOST_ENABLED", "orchestration", "boolean", "LSP host 腿开关(本机 language server,优雅降级)", { staticDefault: "true", configKey: "lspHostEnabled" }),
207
- r("MANUAL_MODE_SHELL_GATE", "approval", "enum", "手动模式 shell 门收紧腿(off=no-op 非放松 —— 本键只有收紧半场;拼错拒启)", { danger: SEC, enumValues: ["always", "classify", "off"], resolve: (c) => c.manualModeShellGate ?? null, derivedDefaultNote: "off/未设 ⇒ 本键不贡献任何东西(归一成键缺席)⇒ 回显 null;真姿态只有 always/classify 两个" }),
207
+ r("MANUAL_MODE_SHELL_GATE", "approval", "enum", "手动模式 shell 门收紧腿(off=no-op 非放松 —— 本键只有收紧半场;拼错拒启)", { danger: SEC, enumValues: ["always", "classify", "off"], echoWords: ["always", "classify"], resolve: (c) => c.manualModeShellGate ?? null, derivedDefaultNote: "off/未设 ⇒ 本键不贡献任何东西(归一成键缺席)⇒ 回显 null;真姿态只有 always/classify 两个" }),
208
208
  r("MAX_PRINCIPAL_COST_USD", "limitsHttp", "number", "per-principal 窗内成本天花板(0=关)", { staticDefault: "0", danger: BILL, configKey: "maxPrincipalCostUsd", center: cLimits("maxPrincipalCostUsd") }),
209
209
  r("MAX_TASK_COST_USD", "limitsHttp", "number", "单任务成本天花板(0=关)", { staticDefault: "0", danger: BILL, configKey: "maxTaskCostUsd", center: cLimits("maxTaskCostUsd") }),
210
210
  r("MAX_TASK_TOKENS", "limitsHttp", "number", "单任务 token 天花板(per-slice 窗;0=关)", { staticDefault: "0", danger: BILL, configKey: "maxTaskTokens", center: cLimits("maxTaskTokens") }),
@@ -270,7 +270,7 @@ export const CONFIG_CATALOG = [
270
270
  r("MODEL_COST_CACHE_WRITE", "modelPlane", "number", "主模型 cache-write 单价", { derivedDefaultNote: "四键全缺席 ⇒ 未定价(不铸 cost 键,成本读面缺席);任一设 ⇒ 四键在场,未设的按 0 显式", danger: BILL, resolve: (c) => c.model.cost?.cacheWrite ?? null, center: cModels("models[].cost.cacheWrite") }),
271
271
  r("MODEL_COST_INPUT", "modelPlane", "number", "主模型 input 单价(NaN 会让成本天花板整条消失 ⇒ 坏值拒启)", { derivedDefaultNote: "四键全缺席 ⇒ 未定价(不铸 cost 键,成本读面缺席);任一设 ⇒ 四键在场,未设的按 0 显式", danger: BILL, resolve: (c) => c.model.cost?.input ?? null, center: cModels("models[].cost.input") }),
272
272
  r("MODEL_COST_OUTPUT", "modelPlane", "number", "主模型 output 单价", { derivedDefaultNote: "四键全缺席 ⇒ 未定价(不铸 cost 键,成本读面缺席);任一设 ⇒ 四键在场,未设的按 0 显式", danger: BILL, resolve: (c) => c.model.cost?.output ?? null, center: cModels("models[].cost.output") }),
273
- r("MODEL_DEFAULT_THINKING", "modelPlane", "enum", "主模型默认思考档(llm-core ThinkingLevel;off=不设模型级默认;未知词拒启)", { enumValues: ["minimal", "low", "medium", "high", "xhigh", "max", "off"], derivedDefaultNote: "缺席/off ⇒ 不铸 defaultThinking(逐任务档由 spec>role 决定)", resolve: (c) => c.model.defaultThinking ?? null }),
273
+ r("MODEL_DEFAULT_THINKING", "modelPlane", "enum", "主模型默认思考档(llm-core ThinkingLevel;off=不设模型级默认;未知词拒启)", { enumValues: ["minimal", "low", "medium", "high", "xhigh", "max", "off"], echoWords: ["minimal", "low", "medium", "high", "xhigh", "max"], derivedDefaultNote: "缺席/off ⇒ 不铸 defaultThinking(逐任务档由 spec>role 决定)", resolve: (c) => c.model.defaultThinking ?? null }),
274
274
  r("MODEL_DEGRADE_AT_COST_FRACTION", "modelPlane", "number", "预算降级触发比(spent/budget;>1=实际别降级)", { staticDefault: "0.7", danger: BILL }),
275
275
  r("MODEL_DEGRADE_ON", "modelPlane", "csv", "反应式降级触发词表(core DegradeReason 全集;拼错拒启)", { center: { domain: "models", path: "degrade", centerKey: "models", precedence: "center-wins", timing: "restart", restartSlice: "degrade-route" } }),
276
276
  r("MODEL_DEGRADE_REACTIVE", "modelPlane", "boolean", "反应式降级开关(degrade-route 重启片只在它开时活)", { staticDefault: "false", center: { domain: "models", path: "degrade", centerKey: "models", precedence: "center-wins", timing: "restart", restartSlice: "degrade-route" } }),
@@ -489,7 +489,7 @@ function stripUrlUserinfo(v) {
489
489
  return u.href;
490
490
  }
491
491
  function sanitizeEnumValue(spec, v) {
492
- const words = spec.enumValues;
492
+ const words = spec.echoWords ?? spec.enumValues;
493
493
  if (v === null)
494
494
  return { value: null };
495
495
  if (words === undefined)
@@ -0,0 +1,27 @@
1
+ /**
2
+ * S-189 —— core `GOVERNANCE_CODES` **闭集**在本分诊面上的逐码处置表。
3
+ *
4
+ * 为什么单独一张表而不是继续往下面的 if 链里加行:`failureCodeOf` 是**开集**(顶注写死「消费点一律带
5
+ * default 臂,禁按穷举写」),而开集里嵌着 core 这一个**闭集注册表**。闭集的成员共享两个性质——同一条
6
+ * prepare-throw→failed-result 管道、**零计费**(prepare 期就拒了)——所以它们要么被分诊成 4xx/5xx,要么
7
+ * 就落进 200-with-failed-body 并被 `finalizeTaskResult` **计费**。此前四个成员在 if 链里被点名、另外四个
8
+ * 静默落 200;core 往注册表加一个码时,server 这边不红不报,新码当场进「200 + 计费」。
9
+ *
10
+ * 🔴 表的类型是 `Record<GovernanceCode, …>`:**core 加成员 ⇒ 这里缺行 ⇒ tsc 红**,逼人当场判档。
11
+ * 值 `null` = **有意**不在本腿分诊(理由逐条写在行上),与「忘了写」在类型上不同形 —— 这是本表存在的
12
+ * 全部意义:让「没表态」不可能静默发生。开集那一半(下面的 if 链 + `resume_at.` 前缀臂 + default)不动。
13
+ *
14
+ * 逐码预期的**行为面**镜像在 `test/resume-anchor.test.ts` 的 S-189 段(同型同义、刻意不共用这张表:
15
+ * 测试喂码进函数看状态码,不读本表,否则表写错了两边一起错还全绿)。
16
+ */
17
+ export declare const GOVERNANCE_HTTP_STATUS: {
18
+ readonly "memory.admission_denied": 403;
19
+ readonly "memory.admission_required": 503;
20
+ readonly "memory.capture_optout_denied": 403;
21
+ readonly "config.tool_mount_denied": 400;
22
+ readonly "config.locked_key": 400;
23
+ readonly "usage.window_exhausted": null;
24
+ readonly "config.compliance_required": null;
25
+ readonly "config.compliance_denied": null;
26
+ };
27
+ //# sourceMappingURL=governance-http-status.d.ts.map
@@ -0,0 +1,11 @@
1
+ export const GOVERNANCE_HTTP_STATUS = {
2
+ "memory.admission_denied": 403,
3
+ "memory.admission_required": 503,
4
+ "memory.capture_optout_denied": 403,
5
+ "config.tool_mount_denied": 400,
6
+ "config.locked_key": 400,
7
+ "usage.window_exhausted": null,
8
+ "config.compliance_required": null,
9
+ "config.compliance_denied": null,
10
+ };
11
+ //# sourceMappingURL=governance-http-status.js.map
@@ -3,6 +3,19 @@ import type { QuestionAnswer } from "@sema-agent/core";
3
3
  import type { CheckpointStoreFull } from "../../plugins/store-backend.js";
4
4
  import { type GovernancePosture } from "../active-run-conflict.js";
5
5
  import { type RouteCtx } from "../route-ctx.js";
6
+ import { type KeyClosureIssue } from "../../request-key-closure.js";
7
+ /**
8
+ * FRESH 决裁面的**顶层**键闭集裁决:受理集之外的键 ⇒ 返回拒体(调用方 400 `request.body_shape`);
9
+ * 全部受理 ⇒ `null`。判据边界与孪生门(`taskBodyKeyIssue` / `taskSettingsKeyIssue`)逐字同一条:
10
+ * **只判键名**(值形校验归各自既有的门,本门零抢话);体本身非对象 ⇒ 本门不判(留给「body must be
11
+ * { decision … }」那道根形门,免得同一个体被两句话抢答)。
12
+ *
13
+ * 🔴 **重放不额外过门**,也不需要:本腿的幂等回放(`decideIdempotentReplay`)重放的是**同一次请求的同一份
14
+ * 体**,它与首决走同一条路由、同一道门;而 checkpoint 的 resume 重建腿根本不读这个体(它读盘上的 body,
15
+ * 走 `resolveSpec` 的宽容层)⇒ 4xx 砖不到任何存量 parked 任务。这与 tasks 面「RESUME 重放不过门」是同一条律
16
+ * 的两种兑现方式:**门只站在人**(或客户端)**现写的那份字节**上。
17
+ */
18
+ export declare function decideBodyKeyIssue(raw: unknown): KeyClosureIssue | null;
6
19
  export declare const ASSISTANT_PREEMPT_RE: RegExp;
7
20
  export declare const ASSISTANT_RESUME_RE: RegExp;
8
21
  export declare const ASSISTANT_PLAN_REVIEW_RE: RegExp;
@@ -17,6 +17,34 @@ import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_
17
17
  import { gatedPrincipal, explicitOperatorOk, isOperator, sendPrincipalRefusal } from "../principal-gate.js";
18
18
  import { governanceOriginOf } from "../active-run-conflict.js";
19
19
  import { sendResumeOutcome } from "../route-ctx.js";
20
+ import { MAX_KEY_ECHO, keyClosureClause, scanKeyClosure } from "../../request-key-closure.js";
21
+ const DECIDE_BODY_DECLARED_KEYS = [
22
+ "answer", "boundCallId", "boundInputHash", "checkpointToken", "decision", "reason", "remember", "updatedInput",
23
+ ];
24
+ const ACCEPTED_DECIDE_BODY = new Set(DECIDE_BODY_DECLARED_KEYS);
25
+ const UNSUPPORTED_DECIDE_BODY = new Set(["editedPlan", "sessionId", "taskId"]);
26
+ export function decideBodyKeyIssue(raw) {
27
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
28
+ return null;
29
+ const buckets = { unknown: [], unsupported: [] };
30
+ scanKeyClosure(raw, "", ACCEPTED_DECIDE_BODY, UNSUPPORTED_DECIDE_BODY, buckets);
31
+ if (buckets.unknown.length === 0 && buckets.unsupported.length === 0)
32
+ return null;
33
+ buckets.unknown.sort();
34
+ buckets.unsupported.sort();
35
+ const parts = [
36
+ ...(buckets.unknown.length > 0 ? [keyClosureClause(buckets.unknown, "unknown (not a decide-request key — check the spelling)")] : []),
37
+ ...(buckets.unsupported.length > 0 ? [keyClosureClause(buckets.unsupported, "unsupported (a sibling approval door's field, or a path parameter — never read from this body)")] : []),
38
+ ];
39
+ return {
40
+ message: `body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller ` +
41
+ `(a misspelled \`remember\` or \`boundInputHash\` silently turns off an exemption / the decision binding). ` +
42
+ `${parts.join("; ")}. ` +
43
+ `Accepted: ${[...ACCEPTED_DECIDE_BODY].sort().join(",")}`,
44
+ unknownKeys: buckets.unknown.slice(0, MAX_KEY_ECHO),
45
+ unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
46
+ };
47
+ }
20
48
  export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
21
49
  export const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
22
50
  export const ASSISTANT_PLAN_REVIEW_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/plan_review$/;
@@ -455,6 +483,13 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
455
483
  sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'deny', reason?, answer?, checkpointToken?, boundCallId?, boundInputHash?, updatedInput?, remember? }");
456
484
  return;
457
485
  }
486
+ {
487
+ const keyIssue = decideBodyKeyIssue(body);
488
+ if (keyIssue) {
489
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
490
+ return;
491
+ }
492
+ }
458
493
  if (body.reason !== undefined && typeof body.reason !== "string") {
459
494
  sendError(res, 400, "request.field_invalid", "reason must be a string");
460
495
  return;
@@ -9,7 +9,7 @@ import {} from "../config-center/facade.js";
9
9
  import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, isDestructiveSessionWrite, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../security.js";
10
10
  import { verifyDirectDoorProof } from "../principal-jwt.js";
11
11
  import {} from "../memory-sync.js";
12
- import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, parseTaskSettings, taskSettingsKeyIssue } from "../task-settings.js";
12
+ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, parseTaskSettings, skillListingBudgetFractionOk, skillListingMaxDescOk, taskSettingsKeyIssue } from "../task-settings.js";
13
13
  import { taskBodyKeyIssue } from "../request-key-closure.js";
14
14
  import { selfOrchestrationDenial } from "../task-workflow.js";
15
15
  import { centerEntitlementSourceWired } from "../runtime-caps-resolver.js";
@@ -650,19 +650,13 @@ export function createHttpServer(rawDeps) {
650
650
  sendError(res, 400, "request.field_invalid", "settings.ultracode must be a boolean");
651
651
  return null;
652
652
  }
653
- if (st.skillListingBudgetFraction !== undefined) {
654
- const v = st.skillListingBudgetFraction;
655
- if (typeof v !== "number" || !Number.isFinite(v) || v <= 0 || v > 1) {
656
- sendError(res, 400, "request.field_invalid", "settings.skillListingBudgetFraction must be a number greater than 0 and at most 1");
657
- return null;
658
- }
653
+ if (st.skillListingBudgetFraction !== undefined && !skillListingBudgetFractionOk(st.skillListingBudgetFraction)) {
654
+ sendError(res, 400, "request.field_invalid", "settings.skillListingBudgetFraction must be a number greater than 0 and at most 1");
655
+ return null;
659
656
  }
660
- if (st.skillListingMaxDescChars !== undefined) {
661
- const v = st.skillListingMaxDescChars;
662
- if (typeof v !== "number" || !Number.isSafeInteger(v) || v <= 0) {
663
- sendError(res, 400, "request.field_invalid", "settings.skillListingMaxDescChars must be a positive integer");
664
- return null;
665
- }
657
+ if (st.skillListingMaxDescChars !== undefined && !skillListingMaxDescOk(st.skillListingMaxDescChars)) {
658
+ sendError(res, 400, "request.field_invalid", "settings.skillListingMaxDescChars must be a positive integer");
659
+ return null;
666
660
  }
667
661
  if (st.hooks !== undefined && st.hooks !== null) {
668
662
  const hooksParsed = parseHooksConfig(st.hooks);
@@ -237,6 +237,10 @@ export declare const FAIL_OPEN_TAGS: {
237
237
  readonly cls: "F";
238
238
  readonly note: "S-107 第三十二轮重扫:PuTTY .ppk 容器扫描器(redact.ts redactPpkContainers)在「无合格终止 / 收口后同容器范围再见私钥字段 / 收口后到下一版本头之间夹非空白」三形下从头**吃到输入末尾**(fail-closed 取证丢弃)——终态 errorMessage 座上=整条错误文案变成 «redacted:private-key»,与 redactions_applied_total{private-key} 的诚实替换同形不可分,故要有自己的计数/探针;影子扫不计;计数=多少次整段吃尾。";
239
239
  };
240
+ readonly "server.approval-card.closed-word-out-of-set": {
241
+ readonly cls: "F";
242
+ readonly note: "S-249(`approval-card.ts` 的边界窄读族;今天的唯一成员是 `readRuleOffersAbsence`):活卡帧 / `card_json` 的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40)。";
243
+ };
240
244
  readonly "server.pg-pool.idle-client-error": {
241
245
  readonly cls: "F";
242
246
  readonly note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。";
@@ -207,6 +207,10 @@ export const FAIL_OPEN_TAGS = {
207
207
  "server.run-terminal.output-truncated": { cls: "F", note: "S-107:持久 error 脱后 UTF-8 字节 > 65,000(TiDB TEXT 65,535 字节)⇒ 脱后切并带标记(切在占位里不是泄漏);计数=多少条终态文案被字节封顶截断" },
208
208
  "server.runs.terminal-ledger-write-failed": { cls: "F", note: "S-107(全量门 silent-degradation 棘轮抓出的五处静默 `catch {}`):run 终局链里**账本腿**(flush 缓冲 text/reasoning、append `failed` 行)与终态翻转(setTerminal)已不共命运——账本 INSERT 抛不再让 run 卡 running 到 reaper;但那条抛掉的账本行之前是**静默**吞的。detail `arm=` 四臂(flush-clean-cancel / append-clean-cancel / flush-catch / append-thrown-cancel / append-failed):缺的是 run_events 尾巴(缓冲文本或 failed 事件),run 行本身仍是 durable truth,读面/reaper 容忍缺失,故 F 类;计数=多少次终局账本写失败。" },
209
209
  "server.redact.ppk-eat-to-end": { cls: "F", note: "S-107 第三十二轮重扫:PuTTY .ppk 容器扫描器(redact.ts redactPpkContainers)在「无合格终止 / 收口后同容器范围再见私钥字段 / 收口后到下一版本头之间夹非空白」三形下从头**吃到输入末尾**(fail-closed 取证丢弃)——终态 errorMessage 座上=整条错误文案变成 «redacted:private-key»,与 redactions_applied_total{private-key} 的诚实替换同形不可分,故要有自己的计数/探针;影子扫不计;计数=多少次整段吃尾。" },
210
+ "server.approval-card.closed-word-out-of-set": {
211
+ cls: "F",
212
+ note: "S-249(`approval-card.ts` 的边界窄读族;今天的唯一成员是 `readRuleOffersAbsence`):活卡帧 / `card_json` 的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40)。",
213
+ },
210
214
  "server.pg-pool.idle-client-error": {
211
215
  cls: "F",
212
216
  note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。",
@@ -184,8 +184,13 @@ export interface ParkedDecideResult {
184
184
  *
185
185
  * · D-1 绑定:操作员在 GET 看到的 `boundCallId`/`boundInputHash` 原样透传,server **绝不重算**;
186
186
  * 缺席时回落到行上 persisted 的那两位(core resume 侧做 fail-closed 等值校验)。
187
- * · `reason` 是**存在性**判定不是真值判定 —— 显式空串("运维选择不写理由")与"没传"是两件事
188
- * (姊妹 approval-hmac.ts `env.reason ?? null` 同判据)。
187
+ * · `reason` 走**存在性**判定(`!== undefined`),不是真值判定 —— 本铸点不折叠,原样上送。
188
+ * 🔴 但**别把这条读成「空串与缺席在结算上是两件事」**(7.19.0 / [ref] 之前那句话是真的,之后不是):
189
+ * 上送域的属主是引擎的单铸点,它把空文本折成缺席 ⇒ 到了消费端,`reason:""`、不传 `reason`、
190
+ * 与一条裸拒**同形**。本腿仍不在这里折,理由是「源头修复禁下游旁路」(下游再折一次就是同一语义的
191
+ * 第二个写者),不是因为两者可分辨。真正按「空串 ≠ 缺席」办事的只有两个**别的**域:验签载荷
192
+ * (approval-hmac.ts 的 `env.reason ?? null` —— 线上原值,折一刀就恒 401)与幂等回放的**身份**判定
193
+ * (`decisionReasonOf`,属主是本仓)。三域各有属主,逐域的射程写在 `decisionReasonOf` 的顶注里。
189
194
  * · `hostDecision` = 结算**事实**逐字上 outcome;`gateOutcome` 一格 server **永不**填(core 对宿主
190
195
  * 自铸的结算词是 pre-CAS 响亮拒)。
191
196
  * · `answer` 原样透传([ref]:header 逐字匹配等语义校验全在 core,server 不预判)。
@@ -0,0 +1,27 @@
1
+ /** 铸出一个 ephemeral 工作区路径时登记(persistent / 用户自有目录**不**登记 —— 那不是我们的)。 */
2
+ export declare function trackEphemeralHostWorkspace(dir: string): void;
3
+ /**
4
+ * `destroy()` **删成功之后**注销 —— 注销过的路径兜底腿一个字节都不碰。
5
+ *
6
+ * 🔴 「删成功之后」不是措辞:`destroy()` 的 rm 是 best-effort(teardown 契约不许抛),EBUSY / EACCES 被吞掉
7
+ * 之后目录还在,而 `destroyed` 已置位 ⇒ 第二次 destroy 直接早退。此时若照样注销,这条路径就**永久**失去了
8
+ * 被兜底回收的机会 —— 登记簿的语义是「我还欠一次清理」,删没删掉才是它该跟着走的事实。
9
+ */
10
+ export declare function untrackEphemeralHostWorkspace(dir: string): void;
11
+ /** 当前在册数(判据用:钉"登记了"与"注销了",不必去摸文件系统)。 */
12
+ export declare function liveEphemeralHostWorkspaceCount(): number;
13
+ /** 兜底回收的读数。`failed` 非空 ⇒ 消费方必须**响亮**说出来,不许静默吞。 */
14
+ export interface HostWorkspaceReaping {
15
+ readonly removed: readonly string[];
16
+ readonly failed: readonly {
17
+ readonly dir: string;
18
+ readonly error: string;
19
+ }[];
20
+ }
21
+ /**
22
+ * 同步删掉所有仍在册的工作区;删成功的注销,**删失败的留在册上等下一次机会**。
23
+ * **同步**是硬要求:`process.on("exit")` 里只有同步 I/O 会被执行。幂等(删过的路径 `force:true` 直接 no-op),
24
+ * 可重复调用 —— 重复调用正是失败项的重试路径。
25
+ */
26
+ export declare function reapEphemeralHostWorkspaces(): HostWorkspaceReaping;
27
+ //# sourceMappingURL=host-workspace-registry.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { rmSync } from "node:fs";
2
+ const LIVE = new Set();
3
+ export function trackEphemeralHostWorkspace(dir) {
4
+ LIVE.add(dir);
5
+ }
6
+ export function untrackEphemeralHostWorkspace(dir) {
7
+ LIVE.delete(dir);
8
+ }
9
+ export function liveEphemeralHostWorkspaceCount() {
10
+ return LIVE.size;
11
+ }
12
+ export function reapEphemeralHostWorkspaces() {
13
+ const removed = [];
14
+ const failed = [];
15
+ for (const dir of [...LIVE]) {
16
+ try {
17
+ rmSync(dir, { recursive: true, force: true });
18
+ removed.push(dir);
19
+ LIVE.delete(dir);
20
+ }
21
+ catch (e) {
22
+ failed.push({ dir, error: e instanceof Error ? e.message : String(e) });
23
+ }
24
+ }
25
+ return { removed, failed };
26
+ }
27
+ //# sourceMappingURL=host-workspace-registry.js.map
@@ -8,6 +8,9 @@ export interface OneTimeMigrationReport {
8
8
  readonly retiredSnapshotRows: number;
9
9
  /** M2:退役单座列 —— `dropped` = 本次真 DROP COLUMN,`absent` = 早已不在。 */
10
10
  readonly retiredSteerSeatColumn: "dropped" | "absent";
11
+ /** M2:DROP COLUMN 随手丢掉的**陈旧**格数(行已不是 `pending` ⇒ 那条指令再也送不出去;`absent` 时恒 0)。
12
+ * 非零 = 这次迁移真丢了存量字节,必须让运维看见 —— 与 M1 的 `retiredSnapshotRows` 逐字同一条纪律。 */
13
+ readonly retiredSteerSeatStaleRows: number;
11
14
  /** M3:改名的行数(账本 `tool_end` 行 + 已决 checkpoint 的执行判词列,两者相加)。 */
12
15
  readonly askOriginRowsMigrated: number;
13
16
  }
@@ -4,25 +4,28 @@ export const NO_SQL_MIGRATIONS = {
4
4
  retiredSnapshotTable: "absent",
5
5
  retiredSnapshotRows: 0,
6
6
  retiredSteerSeatColumn: "absent",
7
+ retiredSteerSeatStaleRows: 0,
7
8
  };
8
9
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
9
10
  const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
11
+ const DELIVERABLE_STEER_SEAT_WHERE = "(status = 'pending' OR (status = 'resolved' AND execution_outcome IS NULL))";
10
12
  const RETIRED_ASK_ORIGIN = { from: "unresolvable", to: "ancestor_marked" };
11
13
  const GATE_BEARING_LEDGER_EVENT_TYPE = "tool_end";
12
14
  export class RetiredSteerSeatError extends Error {
13
15
  rows;
14
16
  code = "checkpoint.pending_steer_seat_retired";
15
17
  constructor(rows) {
16
- super(`checkpoint.pending_steer_seat_retired: ${rows} checkpoint row(s) still carry a value in the RETIRED single-seat ` +
17
- `steering column \`${RETIRED_STEER_SEAT_COLUMN}\`. That seat is gone from the engine (a resume of such a row is ` +
18
- `refused pre-CAS), so the value is an operator instruction nothing will ever deliver, and this service refuses ` +
18
+ super(`checkpoint.pending_steer_seat_retired: ${rows} STILL-DELIVERABLE checkpoint row(s) carry a value in the RETIRED ` +
19
+ `single-seat steering column \`${RETIRED_STEER_SEAT_COLUMN}\`. That seat is gone from the engine (a resume of such a row ` +
20
+ `is refused pre-CAS), so the value is an operator instruction nothing will ever deliver, and this service refuses ` +
19
21
  `to guess what it should become (folding a bare seat into the queue would mint a queue entry the engine never ` +
20
- `wrote). Note that no earlier version ever cleared that cell after delivering it, so most non-NULL cells sit on ` +
21
- `rows that can never be resumed again — those are stale and safe to clear: ` +
22
- `\`UPDATE checkpoint SET ${RETIRED_STEER_SEAT_COLUMN} = NULL WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL AND ` +
23
- `(status = 'expired' OR execution_outcome IS NOT NULL)\`. Any cell left after that is on a still-live row and holds an ` +
24
- `undelivered instruction: decide or cancel those runs on server 7.73.x first (or accept dropping the instruction), ` +
25
- `then clear the rest (\`UPDATE checkpoint SET ${RETIRED_STEER_SEAT_COLUMN} = NULL WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL\`) ` +
22
+ `wrote). Only rows a resume can still reach are counted here — \`${DELIVERABLE_STEER_SEAT_WHERE}\`, i.e. pending now, or ` +
23
+ `reopenable back to pending (the store's only reopen CAS). A cell on any other row sits on a checkpoint that can never ` +
24
+ `be resumed again, so its instruction was already undeliverable — those are dropped with the column and reported as ` +
25
+ `\`retiredSteerSeatStaleRows\` in the migration log line, not held against the boot. ` +
26
+ `Two ways out for the rows above: decide or cancel those runs on server 7.73.x first (the resume delivers the ` +
27
+ `instruction and the row settles for good), or accept dropping them ` +
28
+ `(\`UPDATE checkpoint SET ${RETIRED_STEER_SEAT_COLUMN} = NULL WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL AND ${DELIVERABLE_STEER_SEAT_WHERE}\`) ` +
26
29
  `and start this version again.`);
27
30
  this.rows = rows;
28
31
  this.name = "RetiredSteerSeatError";
@@ -112,11 +115,14 @@ async function withPgLock(pool, run) {
112
115
  }
113
116
  async function migrateOn(db, dialect) {
114
117
  const seatColumnPresent = await columnExists(db, dialect, "checkpoint", RETIRED_STEER_SEAT_COLUMN);
118
+ let retiredSteerSeatStaleRows = 0;
115
119
  if (seatColumnPresent) {
116
- const seatRows = await db.rows(`SELECT COUNT(*) AS n FROM checkpoint WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL`);
120
+ const seatRows = await db.rows(`SELECT COUNT(*) AS n FROM checkpoint WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL AND ${DELIVERABLE_STEER_SEAT_WHERE}`);
117
121
  const seats = Number(seatRows[0]?.n ?? 0);
118
122
  if (seats > 0)
119
123
  throw new RetiredSteerSeatError(seats);
124
+ const staleRows = await db.rows(`SELECT COUNT(*) AS n FROM checkpoint WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL`);
125
+ retiredSteerSeatStaleRows = Number(staleRows[0]?.n ?? 0);
120
126
  }
121
127
  const snapshotTablePresent = await tableExists(db, dialect, RETIRED_SNAPSHOT_TABLE);
122
128
  const retiredSchemaPresent = seatColumnPresent || snapshotTablePresent;
@@ -161,7 +167,7 @@ async function migrateOn(db, dialect) {
161
167
  throw new Error(`one-time migration failed AFTER completing destructive steps — record these before retrying, a retry cannot ` +
162
168
  `report them again: ${destroyed.join("; ")}. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
163
169
  }
164
- return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, askOriginRowsMigrated };
170
+ return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated };
165
171
  }
166
172
  export function migrateRetiredAskOrigin(type, data) {
167
173
  if (type !== GATE_BEARING_LEDGER_EVENT_TYPE)
@@ -184,6 +190,7 @@ export function logOneTimeMigrations(log, backend, report) {
184
190
  retiredSnapshotTable: report.retiredSnapshotTable,
185
191
  retiredSnapshotRows: report.retiredSnapshotRows,
186
192
  retiredSteerSeatColumn: report.retiredSteerSeatColumn,
193
+ retiredSteerSeatStaleRows: report.retiredSteerSeatStaleRows,
187
194
  askOriginRowsMigrated: report.askOriginRowsMigrated,
188
195
  note: "one-time persisted-data migrations for this version's retirements (retired whole-tree snapshot index table " +
189
196
  "dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place). This " +
@@ -235,6 +235,21 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
235
235
  }): Promise<Result<string, FileError>>;
236
236
  /** Resolve a (possibly relative) path against {@link cwd} like the other adapters. Host paths are platform-native. */
237
237
  private resolve;
238
+ /**
239
+ * S-260 ②:**建之前**先判「我这个用户能不能在那儿建」。
240
+ *
241
+ * 不判的话,唯一的读数是 `failed to create host workspace …: EACCES: permission denied, mkdir '<随机子目录>'`
242
+ * —— 它说的是那个**随机**子目录建不出来,而真正的事实是**父目录**(`HOST_WORKSPACE_BASE`,缺省
243
+ * `os.tmpdir()`)对本进程的运行用户不可写。运维拿着那条 errno 会去查那个随机路径(它当然不存在),
244
+ * 查不到根因;更糟的是每一个任务都复发一次同一条。⇒ 前判把它换成一句能直接照做的拒绝:哪个目录、
245
+ * 哪个用户、改哪个旋钮。
246
+ *
247
+ * 只对 **ephemeral** 形做(persistent 形是用户自己的目录,`mkdir -p` 一个已存在的只读 checkout 本来
248
+ * 就该成功 —— 在那儿加可写判据会把"只读仓库上跑只读任务"这个正当用法拒掉)。
249
+ * root 与非 POSIX 平台跳过:root 的可写判据恒真(root-squash 的网络盘上反而会假拒),
250
+ * 而 `process.getuid` 不存在时这条 POSIX 权限判据无从谈起。
251
+ */
252
+ private assertWorkspaceParentWritable;
238
253
  /** Lazily create the per-task workspace exactly once (memoized; concurrent first-use shares it). */
239
254
  private ensurePrepared;
240
255
  private shellReady;
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import fs from "node:fs/promises";
5
5
  import { randomBytes } from "node:crypto";
6
- import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync } from "node:fs";
6
+ import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync, accessSync, constants as FS_CONST } from "node:fs";
7
7
  import { StringDecoder } from "node:string_decoder";
8
8
  import { armPipeDestroyGrace, numEnvOr } from "./remote-shell.js";
9
9
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
@@ -11,6 +11,7 @@ import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeS
11
11
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
12
12
  import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
13
13
  import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
14
+ import { trackEphemeralHostWorkspace, untrackEphemeralHostWorkspace } from "./host-workspace-registry.js";
14
15
  const PROVIDER = "host";
15
16
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
16
17
  const HOST_BG_MAX_CONCURRENT = numEnvOr("HOST_BG_MAX_CONCURRENT", 8, 1);
@@ -143,6 +144,7 @@ export class RemoteHostExecutionEnv {
143
144
  return;
144
145
  try {
145
146
  await fs.rm(this.workspaceDir, { recursive: true, force: true });
147
+ untrackEphemeralHostWorkspace(this.workspaceDir);
146
148
  }
147
149
  catch {
148
150
  }
@@ -1084,14 +1086,52 @@ export class RemoteHostExecutionEnv {
1084
1086
  resolve(p) {
1085
1087
  return path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.resolve(this.cwd, p));
1086
1088
  }
1089
+ assertWorkspaceParentWritable() {
1090
+ const uid = process.getuid?.();
1091
+ if (uid === undefined || uid === 0)
1092
+ return;
1093
+ let anc = path.dirname(this.workspaceDir);
1094
+ for (;;) {
1095
+ if (existsSync(anc))
1096
+ break;
1097
+ const up = path.dirname(anc);
1098
+ if (up === anc)
1099
+ break;
1100
+ anc = up;
1101
+ }
1102
+ try {
1103
+ accessSync(anc, FS_CONST.W_OK | FS_CONST.X_OK);
1104
+ }
1105
+ catch (cause) {
1106
+ let who = `uid=${uid} gid=${process.getgid?.() ?? "?"}`;
1107
+ try {
1108
+ who = `${os.userInfo().username} (${who})`;
1109
+ }
1110
+ catch {
1111
+ who = `<no passwd entry> (${who})`;
1112
+ }
1113
+ throw new Error(`host workspace base ${anc} is not writable by ${who} — the per-task workspace ${this.workspaceDir} cannot be created. ` +
1114
+ `Point HOST_WORKSPACE_BASE at a directory this user owns, or run the service as the user that owns ${anc}.`, { cause });
1115
+ }
1116
+ }
1087
1117
  ensurePrepared() {
1088
1118
  if (this.prepared)
1089
1119
  return Promise.resolve();
1120
+ if (!this.persistent) {
1121
+ try {
1122
+ this.assertWorkspaceParentWritable();
1123
+ }
1124
+ catch (e) {
1125
+ return Promise.reject(e instanceof Error ? e : new Error(String(e)));
1126
+ }
1127
+ }
1090
1128
  if (!this.preparing) {
1091
1129
  this.preparing = fs
1092
1130
  .mkdir(this.workspaceDir, { recursive: true })
1093
1131
  .then(() => {
1094
1132
  this.prepared = true;
1133
+ if (!this.persistent)
1134
+ trackEphemeralHostWorkspace(this.workspaceDir);
1095
1135
  })
1096
1136
  .catch((e) => {
1097
1137
  this.preparing = undefined;
package/dist/runs.d.ts CHANGED
@@ -97,6 +97,57 @@ export declare function markChildrenStoppedByUserOnAbort(signal: AbortSignal, ta
97
97
  export declare function isSessionConflictResult(result: {
98
98
  terminal: TerminalCause;
99
99
  }): boolean;
100
+ /**
101
+ * E18 resume-at CALLER error → an HTTP 4xx. A bad/stale resume-at target (the resolved entryId is gone or is not a
102
+ * settled message boundary) surfaces as a FAILED TaskResult carrying a `resume_at.*` errorCode (core swallows the
103
+ * prepare-throw into the result, exactly like a session conflict — runtask catch → status:"failed"+errorCode). The
104
+ * sync path otherwise 200s a failed result; map these to 4xx so a caller mistake reads as one. `not_found` (the
105
+ * entry is genuinely absent) → 404; everything else (not_a_message / conflicts_resume / no_session) → 422.
106
+ *
107
+ * [ref] Phase3: `requireExistingSession` on a genuinely-missing/purged session ALSO surfaces as a failed result
108
+ * (core prepare-task maps the store's `not_found` → `resume.session_not_found`); map it to 404 (the session is gone),
109
+ * the same "caller mistake reads as a 4xx, not a silent 200 fresh run" discipline.
110
+ *
111
+ * [ref] rewind exclusive mode (core 1.292 resumeAtMode:"before"): its two edge rejections ride the same `resume_at.*`
112
+ * prefix (before_target_not_user / before_root_unsupported → 422 via the existing branch, errorCode passed through
113
+ * UNCHANGED for the shell). `rewind_snapshot.unresolvable` (rewindFiles + "before": no snapshot at/above the branch
114
+ * point) is core's third rejection with a DIFFERENT prefix — same caller-mistake shape (prepare-throw, nothing
115
+ * billed), map it to 422 explicitly so it doesn't fall through to a 200-with-failed body.
116
+ *
117
+ * [ref] 件A([ref] 件4): core 5.13.0's org-memory admission rides the SAME prepare-throw pipe
118
+ * (GOVERNANCE_CODES). `memory.admission_denied` is TERMINAL — the principal has no grant on that tenant
119
+ * plane → 403 (a retry cannot change the verdict). `memory.admission_required` is TRANSIENT fail-closed —
120
+ * the directory is unreachable / no resolver is wired → 503 (retry later; the sync leg forwards the
121
+ * result's `retryAfterMs` as body `retryAfterSec`, same family shape as usage.window_exhausted). Exact
122
+ * codes only, no memory.* family grab — an unknown sibling stays a 200-with-failed-body until cataloged.
123
+ *
124
+ * [ref] (core 7.0.0 片2/3, [ref] S-3): the session capture opt-out's four prepare-throw refusals ride the
125
+ * same pipe. `memory.capture_optout_denied` is TERMINAL (the principal's `allowMemoryOptOut` verdict is an
126
+ * explicit `false`, or the posture is `capture-required` / `governed`-with-absent-verdict — a retry cannot
127
+ * change it) → 403, the `memory.admission_denied` twin. `memory.capture_optout_unpersisted` is the
128
+ * declaration arm's "control-plane record could not be written" refusal — a store outage, retry may succeed
129
+ * → 503 (transient fail-closed, the `memory.admission_required` twin; no retryAfterMs on this one — core
130
+ * mints none, so the sync leg forwards no hint rather than inventing one). `config.memory_capture_spelling`
131
+ * is a TaskSpec authoring mistake (the only legal spelling is the string "off") → 400, the
132
+ * `config.memory_erasure_request` family shape. `config.memory_capture_unsupported` is the DEPLOYMENT SHAPE
133
+ * refusing the declaration (remote/per-run execution with no durable capture-record carrier: the record
134
+ * would not survive to a resume replica) → 409 — the request is well-formed, the deployment's state
135
+ * conflicts with it, the `config.refresh_unavailable` family shape ("change the deployment, not the request").
136
+ * Status choices are pinned by the `config.`/`memory.` family status sets in error-code-catalog-live.test.ts.
137
+ *
138
+ * S-125 件⑥(core 7.4.0 [ref]):`rewind.child_scope_unsupported` —— 这条腿是一个**委派子代**,它的文件编辑
139
+ * 记在**根 session** 的文件历史里,所以「把文件还原到某个 entry」是根 session 的动作、不是子代的;core 在
140
+ * prepare 期响亮拒(零计费,文件一个字节没动)。映射 **422**:请求形式合法、语义上找错了执行者 —— 与
141
+ * `resume_at.*` 的「调用方指错了锚」同一族同一档,而不是 404(目标在,只是不归你)也不是 409(部署状态没冲突)。
142
+ * ⚠️ **前缀不吞**:本码前缀是 `rewind.`,与下面那条 `resume_at.` **开集前缀臂**不同族 —— 它不会被那一臂顺手
143
+ * 兜走(兜走会把一条「找错执行者」的拒答成「锚解析不了」),也不是 `rewind_snapshot.`(那是「快照找不到」)。
144
+ * 三个前缀各自成族,逐码对表见 `test/resume-anchor.test.ts` 的 S-125 `rewind.` 族格。
145
+ * 🔴 **可达性如实**:本仓的 HTTP 面**今天铸不出**这条腿 —— 子代由 core 内部的 Agent 工具派生,
146
+ * `fileHistoryLineage` 是 core prepare 的 internals(不在 `TaskSpec` 上),server 从不直接 prepare 一条带
147
+ * lineage 的子代腿。本臂是**分诊面的完备性**(core 说 X ⇒ wire 形是 Y),不是一条今天在跑的路;将来的
148
+ * 委派入口接线那天零改动即生效。同族其余五码今天**仍落 200-with-failed-body**,那是本批**之前**就存在的
149
+ * 缺口(不是本批回归),已在上述对表格里逐码显式登记为债务,并由那道门看住不许陈腐。
150
+ */
100
151
  export declare function resumeAtHttpStatus(result: {
101
152
  terminal: TerminalCause;
102
153
  }): number | undefined;
package/dist/runs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { runWithVerification, runCascade, defaultTaskRegistry, terminalProjection, DURABLE_AGENT_HANDLE_RE } from "@sema-agent/core";
2
2
  import { failureCodeOf, stripResumeToken } from "./terminal.js";
3
+ import { GOVERNANCE_HTTP_STATUS } from "./governance-http-status.js";
3
4
  import { redactErrorMessage } from "./observability/run-terminal-log.js";
4
5
  import { withPrincipal } from "./observability/principal-context.js";
5
6
  import { redactSecrets } from "./trace/redact.js";
@@ -66,16 +67,6 @@ export function isSessionConflictResult(result) {
66
67
  return result.terminal.code === "conflict";
67
68
  return /stale write|concurrent write|conflict/i.test(result.terminal.message ?? "");
68
69
  }
69
- const GOVERNANCE_HTTP_STATUS = {
70
- "memory.admission_denied": 403,
71
- "memory.admission_required": 503,
72
- "memory.capture_optout_denied": 403,
73
- "config.tool_mount_denied": 400,
74
- "config.locked_key": 400,
75
- "usage.window_exhausted": null,
76
- "config.compliance_required": null,
77
- "config.compliance_denied": null,
78
- };
79
70
  export function resumeAtHttpStatus(result) {
80
71
  const code = failureCodeOf(result.terminal);
81
72
  if (code === undefined)
@@ -37,8 +37,8 @@ export declare function normalizeAttachments(v: unknown): TaskSpec["attachments"
37
37
  */
38
38
  export declare function normalizeResumeAtMode(v: unknown, resumeAtResolved: boolean): "at" | "before" | undefined;
39
39
  /** [ref] 防御上限:一份请求最多带多少个 agent 定义。core 侧无上限;这里封一个防御值 —— 每个定义都进
40
- * 系统提示(whenToUse/skills/systemPrompt 全是 prompt 载荷),不设界就是 per-turn token 成本洞(与
41
- * 上限)。32 足够覆盖 CC 式 `.claude/agents/` 全家桶还有余量。⚠️ 这条帽守的是 **agent 定义**的条数
40
+ * 系统提示(whenToUse/skills/systemPrompt 全是 prompt 载荷),不设界就是 per-turn token 成本洞。
41
+ * 32 足够覆盖 CC 式 `.claude/agents/` 全家桶还有余量。⚠️ 这条帽守的是 **agent 定义**的条数
42
42
  * (每个定义的 whenToUse/systemPrompt 都是无条件进系统提示的 prompt 载荷),与 [ref] 退役的
43
43
  * **技能**条数帽不是同一轴:技能清单由引擎按预算渲染(超了裁描述),agent 定义没有那个预算面。 */
44
44
  export declare const MAX_TASK_AGENTS = 32;
package/dist/task-a2a.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { resolveComplianceDenies } from "@sema-agent/core";
2
2
  import { isUsableWireName } from "./wire-name-bound.js";
3
3
  import { HttpError } from "./security.js";
4
+ import { isToolAxisIrreversibility, isToolEffect } from "./tool-axis-words.js";
5
+ import { GOVERNANCE_HTTP_STATUS } from "./governance-http-status.js";
4
6
  export const MAX_REQUEST_A2A_PEERS = 32;
5
7
  export function a2aInjectionHonored(config) {
6
8
  if (config.lockedConfigKeys?.includes("a2a") === true)
@@ -49,16 +51,16 @@ function normalizeA2aPeer(e) {
49
51
  if (!ax || typeof ax !== "object" || Array.isArray(ax))
50
52
  return null;
51
53
  const a = ax;
52
- if (a.effect !== undefined && a.effect !== "read" && a.effect !== "write" && a.effect !== "idempotent")
54
+ if (a.effect !== undefined && !isToolEffect(a.effect))
53
55
  return null;
54
56
  if (a.egress !== undefined && typeof a.egress !== "boolean")
55
57
  return null;
56
- if (a.irreversibility !== undefined && a.irreversibility !== "always" && a.irreversibility !== "never")
58
+ if (a.irreversibility !== undefined && !isToolAxisIrreversibility(a.irreversibility))
57
59
  return null;
58
60
  const entry = {
59
- ...(a.effect !== undefined ? { effect: a.effect } : {}),
60
- ...(a.egress !== undefined ? { egress: a.egress } : {}),
61
- ...(a.irreversibility !== undefined ? { irreversibility: a.irreversibility } : {}),
61
+ ...(isToolEffect(a.effect) ? { effect: a.effect } : {}),
62
+ ...(typeof a.egress === "boolean" ? { egress: a.egress } : {}),
63
+ ...(isToolAxisIrreversibility(a.irreversibility) ? { irreversibility: a.irreversibility } : {}),
62
64
  };
63
65
  if (Object.keys(entry).length > 0)
64
66
  axes[skill] = entry;
@@ -91,7 +93,7 @@ export function assertRequestA2aUnlocked(bodyA2a, lockedKeys) {
91
93
  return;
92
94
  if (bodyA2a === undefined || bodyA2a === null)
93
95
  return;
94
- throw new HttpError(400, "`a2aPeers` is administratively locked by this deployment (locked key \"a2a\") — task-supplied A2A peers are refused; locks are config-time and there is no request-time unlock", {
96
+ throw new HttpError(GOVERNANCE_HTTP_STATUS["config.locked_key"], "`a2aPeers` is administratively locked by this deployment (locked key \"a2a\") — task-supplied A2A peers are refused; locks are config-time and there is no request-time unlock", {
95
97
  code: "config.locked_key",
96
98
  });
97
99
  }
package/dist/task-mcp.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { mcpInjectionDroppedNotice, resolveComplianceDenies } from "@sema-agent/core";
2
2
  import { isUsableWireName } from "./wire-name-bound.js";
3
3
  import { HttpError } from "./security.js";
4
+ import { GOVERNANCE_HTTP_STATUS } from "./governance-http-status.js";
4
5
  import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "./mcp-content-origin.js";
5
6
  import { readMcpSourceLabel } from "./mcp-source-label.js";
6
7
  import { readMcpToolFaces } from "./mcp-tool-faces.js";
8
+ import { isToolAxisIrreversibility, isToolEffect } from "./tool-axis-words.js";
7
9
  export const MAX_REQUEST_MCP_SERVERS = 32;
8
10
  export const MAX_REQUEST_MCP_DROP_NAMES = MAX_REQUEST_MCP_SERVERS;
9
11
  export function mcpInjectionHonored(config) {
@@ -72,9 +74,9 @@ function normalizeServer(e, noteBadOrigin) {
72
74
  continue;
73
75
  const a = ax;
74
76
  const entry = {
75
- ...(a.effect === "read" || a.effect === "write" || a.effect === "idempotent" ? { effect: a.effect } : {}),
77
+ ...(isToolEffect(a.effect) ? { effect: a.effect } : {}),
76
78
  ...(typeof a.egress === "boolean" ? { egress: a.egress } : {}),
77
- ...(a.irreversibility === "always" || a.irreversibility === "never" ? { irreversibility: a.irreversibility } : {}),
79
+ ...(isToolAxisIrreversibility(a.irreversibility) ? { irreversibility: a.irreversibility } : {}),
78
80
  };
79
81
  if (Object.keys(entry).length > 0)
80
82
  axes[tool] = entry;
@@ -123,7 +125,7 @@ export function assertRequestMcpUnlocked(bodyMcp, lockedKeys) {
123
125
  return;
124
126
  if (bodyMcp === undefined || bodyMcp === null)
125
127
  return;
126
- throw new HttpError(400, "`mcpServers` is administratively locked by this deployment (locked key \"mcp\") — task-supplied MCP servers are refused; locks are config-time and there is no request-time unlock", {
128
+ throw new HttpError(GOVERNANCE_HTTP_STATUS["config.locked_key"], "`mcpServers` is administratively locked by this deployment (locked key \"mcp\") — task-supplied MCP servers are refused; locks are config-time and there is no request-time unlock", {
127
129
  code: "config.locked_key",
128
130
  });
129
131
  }
@@ -119,10 +119,40 @@ export declare function effectivePermissionMode(body: {
119
119
  * 把它折进来会逼出一个 `undefined` 返回值,而那正是「写 off」与「不写」被混同的入口(core 的
120
120
  * 缺席默认是 off,但**写**一个 off 会成为 governance 的 base,与缺席不是同一件事)。
121
121
  *
122
- * 🔴 **闭集 exhaustive switch,无 default 臂**:五个模式词是封闭词表,新增一个模式而不更新本表
122
+ * 🔴 **闭集穷举表,无 default 臂**:五个模式词是封闭词表,新增一个模式而不更新本表
123
123
  * 是**编译错误**([ref] 的安全轴纪律:词表的未知项不许有静默兜底臂)。
124
124
  */
125
125
  export declare function shellGateForMode(effMode: SettingsPermissionMode): "off" | "classify";
126
+ /**
127
+ * [ref] —— 生效模式 → `TaskSpec.writeFace`(**写**面的 roots 容纳判断)。`"open"` = 工作区根之外也能写;
128
+ * `undefined` = **不写这个键**(core 缺省 `"roots"`,与 7.77.0 及以前逐字节相同)。表逐字在
129
+ * {@link MODE_ENGINE_FACES},裁定与硬边界写在 `resolve-spec.ts` 的写点上。
130
+ *
131
+ * 🔴 与 {@link shellGateForMode} 同律:入参非可选 —— 「表态缺席」不是这张表的一行(那是调用点判的
132
+ * 「整个键都不写」),把它折进来就会逼出第二个 `undefined` 语义。
133
+ */
134
+ export declare function writeFaceForMode(effMode: SettingsPermissionMode): "open" | undefined;
135
+ /**
136
+ * [ref] ① —— 生效模式 → `TaskSpec.autoModeRequested`(auto **意图**座,CC 250「用户开」半场)。
137
+ * `true` 只有 auto 一行;`undefined` = 不写键。表逐字在 {@link MODE_ENGINE_FACES}。
138
+ */
139
+ export declare function autoModeRequestedForMode(effMode: SettingsPermissionMode): true | undefined;
140
+ /**
141
+ * S-346 —— [ref] 技能清单预算两旋钮的**值域谓词**(一对具名单铸点)。
142
+ *
143
+ * 两层同问这一只:FRESH 提交面的 400 门(`http/server.ts`)问「合不合法」,RESUME 宽容层
144
+ * ({@link parseTaskSettings})问「收不收下」—— 这两句话在这两个键上是**同一个问题的正反面**
145
+ * (400 的条件恰是受理条件的否定)。修前它们各写一遍字面比较,而承重的两件事
146
+ * (`Number.isFinite` 挡 `NaN`/`Infinity`、`Number.isSafeInteger` 与引擎受理域**同字**)
147
+ * 靠两份注释各自记着 —— 任一处漏一个判据,同一个坏值就会在一条腿上 400、在另一条腿上被收下。
148
+ *
149
+ * 值域**抄 CC settings schema**,不自创;server 侧零加工(不夹紧、不换算、不复刻 core 的默认值),
150
+ * 判据全文见两个调用点的顶注。
151
+ */
152
+ export declare function skillListingBudgetFractionOk(v: unknown): v is number;
153
+ /** {@link skillListingBudgetFractionOk} 的孪生(`int().positive()`;`isSafeInteger` 而不是 `isInteger` 的
154
+ * 理由见那里)。 */
155
+ export declare function skillListingMaxDescOk(v: unknown): v is number;
126
156
  /** The validated, service-trusted subset of `SemaSettings` we project onto the spec. `env` is parsed only to REPORT
127
157
  * it as received-but-deferred when off the host lane (never silently dropped); a MALFORMED `hooks` likewise reports
128
158
  * deferred (submit 路径另有 400 fail-loud),valid `hooks` 进 applied shape(hook-runner 阶段一)。 */
@@ -35,15 +35,26 @@ export function effectivePermissionMode(body, settings) {
35
35
  return requested;
36
36
  }
37
37
  export function shellGateForMode(effMode) {
38
- switch (effMode) {
39
- case "bypassPermissions":
40
- return "off";
41
- case "auto":
42
- case "default":
43
- case "acceptEdits":
44
- case "plan":
45
- return "classify";
46
- }
38
+ return MODE_ENGINE_FACES[effMode].shellGate;
39
+ }
40
+ const MODE_ENGINE_FACES = {
41
+ bypassPermissions: { shellGate: "off", writeFace: "open", autoModeRequested: undefined },
42
+ auto: { shellGate: "classify", writeFace: undefined, autoModeRequested: true },
43
+ default: { shellGate: "classify", writeFace: undefined, autoModeRequested: undefined },
44
+ acceptEdits: { shellGate: "classify", writeFace: undefined, autoModeRequested: undefined },
45
+ plan: { shellGate: "classify", writeFace: undefined, autoModeRequested: undefined },
46
+ };
47
+ export function writeFaceForMode(effMode) {
48
+ return MODE_ENGINE_FACES[effMode].writeFace;
49
+ }
50
+ export function autoModeRequestedForMode(effMode) {
51
+ return MODE_ENGINE_FACES[effMode].autoModeRequested;
52
+ }
53
+ export function skillListingBudgetFractionOk(v) {
54
+ return typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1;
55
+ }
56
+ export function skillListingMaxDescOk(v) {
57
+ return typeof v === "number" && Number.isSafeInteger(v) && v > 0;
47
58
  }
48
59
  export const MAX_SETTINGS_OUTPUT_STYLE_CHARS = 16_384;
49
60
  export const MAX_SETTINGS_PERMISSION_RULES = 1_000;
@@ -162,10 +173,10 @@ export function parseTaskSettings(raw) {
162
173
  if (hooksConfig)
163
174
  out.hooks = hooksConfig;
164
175
  const fraction = s.skillListingBudgetFraction;
165
- if (typeof fraction === "number" && Number.isFinite(fraction) && fraction > 0 && fraction <= 1)
176
+ if (skillListingBudgetFractionOk(fraction))
166
177
  out.skillListingBudgetFraction = fraction;
167
178
  const maxDesc = s.skillListingMaxDescChars;
168
- if (typeof maxDesc === "number" && Number.isSafeInteger(maxDesc) && maxDesc > 0)
179
+ if (skillListingMaxDescOk(maxDesc))
169
180
  out.skillListingMaxDescChars = maxDesc;
170
181
  return { settings: Object.keys(out).length > 0 ? out : undefined, deferred };
171
182
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * S-249(DT-13)—— per-tool **风险轴覆写**的值域词表与谓词,**两面共用一份**。
3
+ *
4
+ * ## 病(修前)
5
+ * `task-mcp.ts` 与 `task-a2a.ts` 各自在请求腿上逐键校验同一个三成员结构
6
+ * (`effect` / `egress` / `irreversibility`),而两个**闭集词表**是**手抄的字面串**:四份字面量
7
+ * (两文件 × 两个词轴),没有一份钉在 core 的型上。后果不是「今天判错了」,而是**判据比声明窄**这件事
8
+ * 没有任何门看得见 —— core 哪天给 `ToolEffect` 加一个词(或把 `irreversibility` 的两词改成三词,它在
9
+ * `AskOrigin` 那一面**已经**是 `never|maybe|always` 三词了),两条请求腿会把那个**合法**的新词
10
+ * **静默剔除**(MCP 腿)或**整条 server 判假**(A2A 腿),而 tsc 一声不吭。
11
+ *
12
+ * ## 修法
13
+ * 词表留在本仓(运行期要它),但**两向钉死在 core 的型上**:core 加词 ⇒ 正向门红;本表留了一个 core
14
+ * 已经没有的词 ⇒ 反向门红。两个面共用**同一只**谓词,于是「什么算一个合法的轴值」在本仓只有一个答案。
15
+ *
16
+ * 🔴 **只管值域,不管处置**:两条腿对坏值的处置**刻意不同**(MCP = 丢这一键、留 server;A2A = 整条拒),
17
+ * 判据与理由各写在各自的调用点。合并处置会抹掉那条真实的分歧(MCP 的 `toolAxes` 有 server-hint 腿可折,
18
+ * A2A 收下的就是最终值),而分歧本身是裁定过的。
19
+ */
20
+ import type { McpServerSpec, ToolEffect } from "@sema-agent/core";
21
+ /** 一条 per-tool 轴覆写的形。取 MCP 那一面作代表 —— 下面的同型钉证明两面逐字同声明。 */
22
+ export type ToolAxisEntry = NonNullable<McpServerSpec["toolAxes"]>[string];
23
+ /** 轴覆写上那个**两词**的不可逆档(⚠️ 与 `AskOrigin` 面的**三词** `never|maybe|always` 不是同一个词表:
24
+ * 覆写面只收两极,`maybe` 是引擎按工具自己的声明定的,调用方说不出口)。 */
25
+ export type ToolAxisIrreversibility = NonNullable<ToolAxisEntry["irreversibility"]>;
26
+ /** 值是不是一个合法的 `effect` 词(两条请求腿共用)。 */
27
+ export declare function isToolEffect(v: unknown): v is ToolEffect;
28
+ /** 值是不是一个合法的**覆写面** `irreversibility` 词(两条请求腿共用)。 */
29
+ export declare function isToolAxisIrreversibility(v: unknown): v is ToolAxisIrreversibility;
30
+ //# sourceMappingURL=tool-axis-words.d.ts.map
@@ -0,0 +1,9 @@
1
+ const TOOL_EFFECT_WORDS = ["read", "write", "idempotent"];
2
+ const TOOL_AXIS_IRREVERSIBILITY_WORDS = ["always", "never"];
3
+ export function isToolEffect(v) {
4
+ return typeof v === "string" && TOOL_EFFECT_WORDS.includes(v);
5
+ }
6
+ export function isToolAxisIrreversibility(v) {
7
+ return typeof v === "string" && TOOL_AXIS_IRREVERSIBILITY_WORDS.includes(v);
8
+ }
9
+ //# sourceMappingURL=tool-axis-words.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.78.1",
3
+ "version": "7.78.2",
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",