@sema-agent/server 7.78.1 → 7.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -0
  2. package/USAGE.md +4 -0
  3. package/dist/approval-card.d.ts +14 -4
  4. package/dist/approval-card.js +9 -2
  5. package/dist/auto-mode-face.d.ts +23 -11
  6. package/dist/auto-mode-face.js +5 -2
  7. package/dist/boot/deferred-sandbox-path-env.d.ts +47 -0
  8. package/dist/boot/deferred-sandbox-path-env.js +1 -5
  9. package/dist/boot/resolve-spec.js +25 -6
  10. package/dist/capabilities/code-review-council.js +2 -1
  11. package/dist/config-catalog.d.ts +18 -1
  12. package/dist/config-catalog.js +3 -3
  13. package/dist/config.js +4 -3
  14. package/dist/env-facts.d.ts +61 -0
  15. package/dist/env-facts.js +32 -6
  16. package/dist/execution-lane-caps.d.ts +12 -0
  17. package/dist/execution-lane-caps.js +8 -0
  18. package/dist/governance-http-status.d.ts +27 -0
  19. package/dist/governance-http-status.js +11 -0
  20. package/dist/http/routes/approvals-assistant.d.ts +13 -0
  21. package/dist/http/routes/approvals-assistant.js +35 -0
  22. package/dist/http/server.js +7 -13
  23. package/dist/main.js +4 -3
  24. package/dist/model-select.d.ts +4 -0
  25. package/dist/model-select.js +2 -1
  26. package/dist/observability/fail-open.d.ts +4 -0
  27. package/dist/observability/fail-open.js +4 -0
  28. package/dist/parked-decide.d.ts +7 -2
  29. package/dist/plugins/host-workspace-registry.d.ts +27 -0
  30. package/dist/plugins/host-workspace-registry.js +27 -0
  31. package/dist/plugins/one-time-migrations.d.ts +3 -0
  32. package/dist/plugins/one-time-migrations.js +18 -11
  33. package/dist/plugins/remote-env-adb.d.ts +11 -0
  34. package/dist/plugins/remote-env-adb.js +1 -1
  35. package/dist/plugins/remote-env-device.d.ts +11 -0
  36. package/dist/plugins/remote-env-device.js +1 -1
  37. package/dist/plugins/remote-env-host.d.ts +26 -0
  38. package/dist/plugins/remote-env-host.js +42 -2
  39. package/dist/plugins/remote-env-k8s.d.ts +11 -0
  40. package/dist/plugins/remote-env-k8s.js +1 -1
  41. package/dist/plugins/remote-env-ssh.d.ts +11 -0
  42. package/dist/plugins/remote-env-ssh.js +1 -1
  43. package/dist/run-local.js +2 -1
  44. package/dist/runs.d.ts +51 -0
  45. package/dist/runs.js +1 -10
  46. package/dist/spec-fields.d.ts +2 -2
  47. package/dist/task-a2a.js +8 -6
  48. package/dist/task-cwd.d.ts +24 -0
  49. package/dist/task-cwd.js +9 -0
  50. package/dist/task-mcp.js +5 -3
  51. package/dist/task-settings.d.ts +31 -1
  52. package/dist/task-settings.js +22 -11
  53. package/dist/tool-axis-words.d.ts +30 -0
  54. package/dist/tool-axis-words.js +9 -0
  55. package/package.json +2 -2
@@ -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);
package/dist/main.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { DEFAULT_MODEL_REF } from "./model-select.js";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
- import { Runner, terminalProjection, createPermissionRuleStoreProvider, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, expandTiers, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
5
+ import { Runner, terminalProjection, createPermissionRuleStoreProvider, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
5
6
  import { createSessionTitler } from "./session-titler.js";
6
7
  import { posIntEnv } from "./session-watch.js";
7
8
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
@@ -469,7 +470,7 @@ async function main() {
469
470
  const ensureChildSessionDurable = checkpointStore && config.sessionBackend === "tidb" ? (sessionId) => ensureChildSessionDurableWithPromotion(sessionStore, subRunnerSessions, sessionId) : undefined;
470
471
  const webFetchSummarize = createWebFetchSummarizeLane({ brain, config, getKeyResolver: () => configCenter.getKeyResolver(), logger });
471
472
  const scenarioDeps = {
472
- runner, subRunner, handslessSubRunner, model: "default", skills, repoClient, requirePrincipal: config.requirePrincipal,
473
+ runner, subRunner, handslessSubRunner, model: DEFAULT_MODEL_REF, skills, repoClient, requirePrincipal: config.requirePrincipal,
473
474
  webSearch: webSearch ? webSearch : undefined, metrics, logger, webFetchSummarize,
474
475
  backgroundAgentStore: backgroundAgentStore ? backgroundAgentStore : undefined,
475
476
  checkpointStore: checkpointStore ? checkpointStore : undefined,
@@ -692,7 +693,7 @@ async function main() {
692
693
  engineArmsOnIntent: coreArmsAutoOnIntent(),
693
694
  resolveCaps: runtimeCapsPeekResolver,
694
695
  classifierModel: () => {
695
- const route = classifierRouteFor({ models: expandTiers(config.models, config.tiers ?? {}) ?? config.models, roles: config.roles });
696
+ const route = classifierRouteFor(runnerDeps);
696
697
  if (route.fault !== undefined)
697
698
  logger.debug("auto_mode_classifier_route_unresolved", { fault: route.fault });
698
699
  return route.id;
@@ -23,6 +23,10 @@
23
23
  * 🔴 Was a real defect: resolveSpec previously read ONLY the inline @mention, so `body.model` (the picker + the
24
24
  * `--model` flag) was SILENTLY DROPPED — the picker UX was a no-op server-side. This restores it.
25
25
  */
26
+ /** 调用方**什么都没选**时这条 task 落到的目录 ref —— 即 `config.models.default` 那一格(引擎侧
27
+ * `resolveModel("default", catalog)` 解它)。**单点**:读面要说「这台部署的主模型是谁」时读同一个词
28
+ * (`auto-mode-face.ts` 的席解析器入参),两处各写一个 `"default"` 字面串正是漂移的成因。 */
29
+ export declare const DEFAULT_MODEL_REF = "default";
26
30
  export declare function resolveTaskModel(bodyModel: unknown, objective: string, catalog: Record<string, unknown>, allowlist?: readonly string[]): {
27
31
  model: string;
28
32
  cleanedObjective: string;
@@ -1,11 +1,12 @@
1
1
  import { parseModelMention } from "@sema-agent/core";
2
+ export const DEFAULT_MODEL_REF = "default";
2
3
  export function resolveTaskModel(bodyModel, objective, catalog, allowlist) {
3
4
  const mention = parseModelMention(objective, Object.keys(catalog));
4
5
  const explicit = typeof bodyModel === "string" ? matchCatalogModel(bodyModel, catalog) : undefined;
5
6
  const present = bodyModel !== undefined && bodyModel !== null;
6
7
  const unknown = present && explicit === undefined;
7
8
  const repr = typeof bodyModel !== "string" ? `<non-string:${typeof bodyModel}>` : bodyModel === "" ? "<empty-string>" : bodyModel;
8
- const model = explicit ?? mention.model ?? "default";
9
+ const model = explicit ?? mention.model ?? DEFAULT_MODEL_REF;
9
10
  const picked = explicit !== undefined ? { ref: explicit, source: "explicit", requested: repr } : mention.model !== undefined ? { ref: mention.model, source: "mention", requested: mention.model } : undefined;
10
11
  const notAllowed = picked !== undefined && !isModelAllowlisted(picked.ref, catalog, allowlist)
11
12
  ? { requested: picked.requested, resolved: terminalCatalogName(picked.ref, catalog) ?? picked.ref, source: picked.source }
@@ -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 " +
@@ -44,9 +44,20 @@ export interface AdbEnvDeps {
44
44
  * typed retryable (reconnect + retry per idempotency), never a fake command result. Exported for tests. */
45
45
  export declare function isAdbTransportLost(stderr: string): boolean;
46
46
  export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
47
+ /**
48
+ * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath`
49
+ * 就是**命令真正跑的那块文件系统**的权威答案(`posix-shell-fs` 的 `canonicalPath` 原语,在设备 shell 里解到落点)。声明它 = 告诉引擎:整链答不出来是**解析器出了事**
50
+ * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。
51
+ * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。
52
+ * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的
53
+ * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
54
+ * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。
55
+ * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。
56
+ */
47
57
  readonly capabilities: {
48
58
  isolation: boolean;
49
59
  suspendable: boolean;
60
+ canonicalPathAuthoritative: boolean;
50
61
  };
51
62
  cwd: string;
52
63
  private connected;
@@ -18,7 +18,7 @@ export function isAdbTransportLost(stderr) {
18
18
  return /device offline|device '.*' not found|no devices\/emulators found|connection reset|protocol fault|device disconnected/i.test(stderr);
19
19
  }
20
20
  export class RemoteAdbExecutionEnv {
21
- capabilities = { isolation: false, suspendable: false };
21
+ capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true };
22
22
  cwd;
23
23
  connected = false;
24
24
  destroyed = false;
@@ -94,9 +94,20 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
94
94
  * 谁要把 executor 的工作区改成临时目录,先来改这一行:那一刻这个 `false` 就变成谎话,park 赎回会
95
95
  * 落到一个空工作区上而链路上没有任何一层会报错。
96
96
  */
97
+ /**
98
+ * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath`
99
+ * 就是**命令真正跑的那块文件系统**的权威答案(设备 WS 协议的**必答** `canonicalPath` op)。声明它 = 告诉引擎:整链答不出来是**解析器出了事**
100
+ * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。
101
+ * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。
102
+ * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的
103
+ * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
104
+ * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。
105
+ * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。
106
+ */
97
107
  readonly capabilities: {
98
108
  isolation: boolean;
99
109
  suspendable: boolean;
110
+ canonicalPathAuthoritative: boolean;
100
111
  };
101
112
  readonly externalContentTarget = true;
102
113
  /** [ref]:设备路径不在 worker 宿主文件系统上 —— host-path 回执消费方据此拒绝把它当宿主路径呈现。 */
@@ -110,7 +110,7 @@ function assertPosixDevicePlatform(os, deviceId) {
110
110
  export class RemoteDeviceExecutionEnv {
111
111
  cfg;
112
112
  homeDir;
113
- capabilities = { isolation: false, suspendable: false };
113
+ capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true };
114
114
  externalContentTarget = true;
115
115
  hostLocalPaths = false;
116
116
  cwd;
@@ -58,9 +58,20 @@ export interface HostEnvConfig {
58
58
  }
59
59
  export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, SchedulerCapability, BackgroundShellCapability {
60
60
  /** [ref] §9 / DUAL-MODE-DESIGN §5: a `host` env is the operator's real machine — no isolation, no snapshot. */
61
+ /**
62
+ * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath`
63
+ * 就是**命令真正跑的那块文件系统**的权威答案(worker 本机的 `fs.realpath`)。声明它 = 告诉引擎:整链答不出来是**解析器出了事**
64
+ * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。
65
+ * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。
66
+ * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的
67
+ * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
68
+ * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。
69
+ * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。
70
+ */
61
71
  readonly capabilities: {
62
72
  isolation: boolean;
63
73
  suspendable: boolean;
74
+ canonicalPathAuthoritative: boolean;
64
75
  };
65
76
  /** R7 自唤醒: structural delegation to the injected scheduler backend. `hasScheduler(env)` checks BOTH the method
66
77
  * presence (always, since we implement the interface) AND `schedulerCapabilities.supported===true` — which is only
@@ -235,6 +246,21 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
235
246
  }): Promise<Result<string, FileError>>;
236
247
  /** Resolve a (possibly relative) path against {@link cwd} like the other adapters. Host paths are platform-native. */
237
248
  private resolve;
249
+ /**
250
+ * S-260 ②:**建之前**先判「我这个用户能不能在那儿建」。
251
+ *
252
+ * 不判的话,唯一的读数是 `failed to create host workspace …: EACCES: permission denied, mkdir '<随机子目录>'`
253
+ * —— 它说的是那个**随机**子目录建不出来,而真正的事实是**父目录**(`HOST_WORKSPACE_BASE`,缺省
254
+ * `os.tmpdir()`)对本进程的运行用户不可写。运维拿着那条 errno 会去查那个随机路径(它当然不存在),
255
+ * 查不到根因;更糟的是每一个任务都复发一次同一条。⇒ 前判把它换成一句能直接照做的拒绝:哪个目录、
256
+ * 哪个用户、改哪个旋钮。
257
+ *
258
+ * 只对 **ephemeral** 形做(persistent 形是用户自己的目录,`mkdir -p` 一个已存在的只读 checkout 本来
259
+ * 就该成功 —— 在那儿加可写判据会把"只读仓库上跑只读任务"这个正当用法拒掉)。
260
+ * root 与非 POSIX 平台跳过:root 的可写判据恒真(root-squash 的网络盘上反而会假拒),
261
+ * 而 `process.getuid` 不存在时这条 POSIX 权限判据无从谈起。
262
+ */
263
+ private assertWorkspaceParentWritable;
238
264
  /** Lazily create the per-task workspace exactly once (memoized; concurrent first-use shares it). */
239
265
  private ensurePrepared;
240
266
  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);
@@ -33,7 +34,7 @@ const UNSUPPORTED_SCHEDULER_CAPS = {
33
34
  maxScheduledHorizonSec: 0,
34
35
  };
35
36
  export class RemoteHostExecutionEnv {
36
- capabilities = { isolation: false, suspendable: false };
37
+ capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true };
37
38
  get schedulerCapabilities() {
38
39
  return this.cfg.scheduler?.schedulerCapabilities ?? UNSUPPORTED_SCHEDULER_CAPS;
39
40
  }
@@ -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;
@@ -119,9 +119,20 @@ export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
119
119
  export declare function errorMessageOf(e: unknown): string;
120
120
  export { exitCodeFromStatus } from "./k8s-exec-protocol.js";
121
121
  export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, BackgroundShellCapability {
122
+ /**
123
+ * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath`
124
+ * 就是**命令真正跑的那块文件系统**的权威答案(`posix-shell-fs` 的 `canonicalPath` 原语,经 pod exec 解到落点)。声明它 = 告诉引擎:整链答不出来是**解析器出了事**
125
+ * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。
126
+ * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。
127
+ * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的
128
+ * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
129
+ * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。
130
+ * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。
131
+ */
122
132
  readonly capabilities: {
123
133
  isolation: boolean;
124
134
  suspendable: boolean;
135
+ canonicalPathAuthoritative: true;
125
136
  };
126
137
  cwd: string;
127
138
  private podName?;