@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
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({
@@ -1,17 +1,29 @@
1
- import { type Model, type ModelRoles, type RuntimeCaps } from "@sema-agent/core";
1
+ import { type RunnerDeps, type RuntimeCaps } from "@sema-agent/core";
2
2
  import type { SettingsPermissionMode } from "./task-settings.js";
3
3
  /**
4
- * 分类器**配置**的模型路由 —— 直接复用 core 的 `resolveTaskModel({ modelRole: "classifier" })`(prepare-task 武装时
5
- * 走的同一函数:role 回落链 `classifier → summarize → default`,且 `{select}` 形 RoleSpec 按目录选型)。
6
- * codex r4 [medium] 验真后改:首版按名字级自算,遇 center 下发的 `{select:{tier}}` 形会错落到 summarize/default,
7
- * 对 `sema doctor permissions` 报另一个模型。答的是「配置了什么」;运行期 `derivedRouteFallsBack` 的回落不在射程
8
- * (那是 core 状态座的事)。什么都解不出 ⇒ `{id:undefined, fault}`(core 的拒句当数据交出,读面不写 model 键):
9
- * 本面是纯披露、不是执法——读面 500 掉整张能力表比少一个 model 键更伤。
4
+ * 分类器席的模型 —— **调 core 的单一解析器** `classifierSeatModel({deps, main})`(core 7.20.0 / [ref],
5
+ * 本仓 [ref] 的跨仓诉求兑现;契约句 `classifier.seat.one_resolver` 逐字:引擎的武装点与**显示这个席**
6
+ * 的宿主读面调同一只导出函数,而不是各自从目录/角色/档位表再推一遍 —— 第二份推导就是第二个答案)。
7
+ *
8
+ * 🔴 **S-350:本仓的自算分支已删**。修前这里调的是 `resolveTaskModel({modelRole:"classifier"})`,那只
9
+ * 回答的是「classifier **角色**解到谁」;引擎真正挑席时在角色没点名模型的情况下还要走 **tierBelowMain**
10
+ * (主模型档位**严格之下**、路由不同的最近一档)。两者在「档位表在场 + 没有 classifier 角色」这一形上
11
+ * 给出**不同的模型**(车FL U-3 坐实),读面于是对着 `sema doctor permissions` 报另一只模型。规则变少的
12
+ * 修法只有一条:把这个问题整个交回给上游的那一只解析器。
13
+ *
14
+ * 递进去的 `deps` 就是**交给 `Runner` 的那一只**(main.ts 递 `runnerDeps`):`models`/`roles`/`tiers` 三席
15
+ * 与引擎看到的是同一批**活引用**(center 原地热更新 ⇒ 读面按请求现算)。解析器对**原始**与**已展开**
16
+ * 两形 deps 同答(它自己 `normalizeSeatDeps` 一次 `expandTiers`),且**永不抛**。
17
+ *
18
+ * 唯一还会抛的是 `main` —— **这条 run 的主模型**,也就是本仓给一条没选模型的 task 铸的那个 ref
19
+ * ({@link DEFAULT_MODEL_REF};`boot/resolve-spec.ts` 每条 spec 都盖 `spec.model`,所以引擎眼里的 main
20
+ * 恒是它解出来的那一只,**不是** core 的 `default` **角色**链)。席是相对主模型定义的(tierBelowMain =
21
+ * 「主模型档位之下」),主模型解不出就没有席可言 ⇒ `{id:undefined, fault}`(core 的拒句当数据交出,读面
22
+ * 不写 model 键):本面是纯披露、不是执法——读面 500 掉整张能力表比少一个 model 键更伤。`main` 这一步
23
+ * 喂**展开**目录,与引擎构造期 `expandTiers(models, tiers)` 之后再解逐字同源(邻居惯例:
24
+ * `routes/capabilities.ts` 的 `workflowModels` / `/v1/models` 两处同法)。
10
25
  */
11
- export declare function classifierRouteFor(deps: {
12
- models?: Record<string, Model>;
13
- roles?: ModelRoles;
14
- }): ClassifierRoute;
26
+ export declare function classifierRouteFor(deps: Pick<RunnerDeps, "models" | "roles" | "tiers">): ClassifierRoute;
15
27
  /** `classifierRouteFor` 的答:解出 ⇒ `{id}`;解不出 ⇒ `{id:undefined, fault}`(core 的拒句原文,不吞)。 */
16
28
  export type ClassifierRoute = {
17
29
  readonly id: string;
@@ -2,10 +2,13 @@ import { createRequire } from "node:module";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { z } from "zod";
5
- import { resolveTaskModel } from "@sema-agent/core";
5
+ import { classifierSeatModel, expandTiers, resolveTaskModel } from "@sema-agent/core";
6
+ import { DEFAULT_MODEL_REF } from "./model-select.js";
6
7
  export function classifierRouteFor(deps) {
7
8
  try {
8
- return { id: resolveTaskModel({ modelRole: "classifier" }, deps).model.id };
9
+ const expanded = expandTiers(deps.models, deps.tiers ?? {}) ?? deps.models;
10
+ const main = resolveTaskModel({ model: DEFAULT_MODEL_REF }, { ...deps, ...(expanded !== undefined ? { models: expanded } : {}) }).model;
11
+ return { id: classifierSeatModel({ deps, main }).model.id };
9
12
  }
10
13
  catch (err) {
11
14
  return { id: undefined, fault: err instanceof Error ? err.message : String(err) };
@@ -1,3 +1,50 @@
1
+ /**
2
+ * [ref] —— 沙箱 lane(e2b/k8s/ssh/adb/local-docker)写门的**真 env 裁决**接法。
3
+ *
4
+ * ## 缺口与它的形状
5
+ *
6
+ * core 的 `createFsWriteGatePolicy` / `createSensitivePathPolicy` 把每一个写目标交给
7
+ * `canonicalizeTarget(env, …)` 去问 fs 要真身(`absolutePath` → `exists` → `canonicalPath` →
8
+ * `fileInfo`/`readLink` 逐跳,dist 亲读)。host lane 上那个 env 就是 hand 工具真正写的那块盘。
9
+ * 沙箱 lane 不是:per-task 的沙箱 env 由 core 的 `executionEnvFactory` 在 spec **之后**才铸,
10
+ * 而写门 wiring 必须在 spec 期就交出去。
11
+ *
12
+ * 出路不是把裁决搬去 hook 层(那要在本仓复刻 `canonicalizeTarget` 的逐级 exists / symlink 解析 /
13
+ * 段匹配 / 折叠序——复制 core 的裁决逻辑=同源谎),而是利用一个部署事实:**那个 per-task 沙箱 env
14
+ * 本来就是本仓铸的**。于是本模块给写门一个 ExecutionEnv **代理**:它在**调用时刻**(runToolGate 裁决,
15
+ * 必在 prepare 之后)去 slot 里取该 task 的真 env 并转发 fs 读原语。call-time 转发消除了时序问题,
16
+ * 与「从调用上下文读 env」是同一性质。
17
+ *
18
+ * ## 三条不可动的裁定
19
+ *
20
+ * 1. **`absolutePath` 一面不转发**,沿用词法规则(POSIX 绝对形归一 / NUL 拒 / 非绝对形 err)。
21
+ * 真 env 的 `absolutePath` 会按 **env 自己的 cwd** 解析相对路径,而工具真写处按 engine 跟踪的
22
+ * cwd 解析——两个基准不同,转发它等于拿错基准铸 canonical key,而**错误的 allow 比没有门更糟**。
23
+ * 相对形 / `~` / 盘符形因此照旧 err ⇒ `canonicalizeTarget` 失败 ⇒ 写门 `ask`(fail-closed)。
24
+ * 「不猜沙箱工作目录」这条公理不随真 env 到场而松。
25
+ * 2. **slot 空 = fail-closed 且响亮**:转发面返回错误 ⇒ core 把 `exists` 报错读成
26
+ * `unresolvedSymlink` ⇒ `createSensitivePathPolicy` 判 deny、写门判 ask,折叠后 deny 恒赢。
27
+ * 保护型缺席的失败方向必须与能力语义同向。留痕走结构化 `warn`(每实例一次)而**不**走
28
+ * `observability/fail-open.ts`:那个词表按其模块头是 `F`(体验/缓存回退)与 `P-DEBT`(明知方向
29
+ * 不对的保护型欠账)两类的准入面,本臂方向是**对的**(fail-closed),登记进去会把「已收口的
30
+ * 保护臂」混进「待还的债」计数里。
31
+ * 3. **slot 只认「当前活腿」**:同一 session 同时只有一条活腿(HTTP `conflict.session_active_run` +
32
+ * core session acquire 双重串行化;委派子代另用一个 sessionId),所以同键改铸即顶替旧登记——
33
+ * 那些旧登记来自 park-only 之类「不 destroy 也不 suspend」的终局,留着只会让 resume 腿失去真身裁决。
34
+ *
35
+ * ## slot 的键 = sessionId
36
+ *
37
+ * 工厂 ctx 只有 `{sessionId, taskId?, isolation?, parentCwd?}`(core `ExecutionEnvFactoryContext`),
38
+ * 而 `/v1/runs` 会在 resolveSpec **之后**铸自己的持久 taskId 覆盖 `spec.taskId`,所以 taskId 在主路径
39
+ * 上对不上——sessionId 是两侧都稳的那一个(per-task 镜像登记簿是同一个先例)。
40
+ * 委派子任务(Agent / workflow 子代)拿的是**另一个** sessionId,所以父的 slot 不会被子代覆盖(「不许
41
+ * 最后写赢」由此成立)。**残余面**:子代的写仍按**父**的 env 裁决——子代的 policy 就是父的那一个实例
42
+ * (core `inheritedGateForChildren` 把它当 parentConstraint 继承),而 policy 收到的只有
43
+ * `{toolName, args, toolCallId}`,读侧根本没有腿身份可键控。方向上它逼近真相而非远离(过渡形是「谁的盘
44
+ * 都不看」),但有一条**新的松面**:父盘上一个名叫守卫段、真身良性的软链,会让子代在自己盘上指向真守卫段
45
+ * 的同名路径拿到父盘的宽松答案。收口需要一条「按当前工具调用的 env 裁决」的引擎缝,属设计件;
46
+ * 该面由 test/task-settings.test.ts 的「真身胜过名字」特征化钉机器可见。
47
+ */
1
48
  import { FileError, StubExecutionEnv, type ExecutionEnv, type ExecutionEnvFactory, type FileInfo, type Result } from "@sema-agent/core";
2
49
  /** 哪些 lane 用本模块的代理裁决写门 —— `REMOTE_EXEC` 未设(进程内 host)与显式 `host` 之外的全部。
3
50
  * 与 resolve-spec 的 `hostSemanticsLane` 是同一判别式的两面,取值处**只此一个**(两处各写一份正是漂移的成因)。 */
@@ -1,12 +1,8 @@
1
- import { posix } from "node:path";
2
1
  import { FileError, StubExecutionEnv, err, ok } from "@sema-agent/core";
2
+ import { normalizeAbsolutePathLexically } from "../task-cwd.js";
3
3
  export function isSandboxPathAdjudicationLane(provider) {
4
4
  return provider !== undefined && provider !== "host";
5
5
  }
6
- function normalizeAbsolutePathLexically(path) {
7
- const collapsed = posix.normalize(path.replace(/^\/+/, "/"));
8
- return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
9
- }
10
6
  const MAX_BOUND_SESSIONS = 4096;
11
7
  export class SandboxPathEnvSlots {
12
8
  bySession = new Map();
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { NodeExecutionEnv, QUESTION_AWAITS_RESUME, deriveAskEffective, expandTiers, resolveAskSeamForm, tightenTaskSpec } from "@sema-agent/core";
3
+ import { NodeExecutionEnv, QUESTION_AWAITS_RESUME, WRITE_EXEMPT_DIR_COVERS_ROOT_CODE, deriveAskEffective, expandTiers, resolveAskSeamForm, tightenTaskSpec } from "@sema-agent/core";
4
4
  import { cappedCeiling } from "../budget.js";
5
5
  import { centerPromptProvider, centerIdentityAssembled } from "../capabilities/center-prompts.js";
6
6
  import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilities/select-environment-tool.js";
@@ -10,7 +10,7 @@ import { stampHandsLane, toolPolicyForHands } from "../capabilities/hands-lane.j
10
10
  import { shouldDiscloseNoPersistentMemory, withNoPersistentMemoryNotice } from "../capabilities/memory-notice.js";
11
11
  import { applyLongtailDefer } from "../capabilities/tool-defer.js";
12
12
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs } from "../deployment-governance.js";
13
- import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane } from "../env-facts.js";
13
+ import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane, scratchpadCoveringTaskRoot } from "../env-facts.js";
14
14
  import { FleetEventBus } from "../fleet/fleet-bus.js";
15
15
  import { composeHooks, createTaskHooks } from "../hooks/hook-runner.js";
16
16
  import { headerStr } from "../http/principal-gate.js";
@@ -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";
@@ -46,6 +46,19 @@ export function createResolveSpec(ctx) {
46
46
  assertRequestMcpUnlocked(body.mcpServers, lockedKeys);
47
47
  if (opts?.leg === "fresh")
48
48
  assertRequestMcpContentOrigin(body.mcpServers);
49
+ if (opts?.leg === "fresh") {
50
+ const conflict = scratchpadCoveringTaskRoot({
51
+ cwd: body.cwd,
52
+ shellScratchpadDir: body.scratchpadDir,
53
+ localDataRoot: config.localDataRoot ?? localRoot,
54
+ sessionId: auth?.sessionId,
55
+ requirePrincipal: config.requirePrincipal,
56
+ cwdHonored: cwdHonored(config),
57
+ });
58
+ if (conflict) {
59
+ throw new HttpError(400, `cwd "${conflict.cwd.asSpelled}" is inside this session's scratchpad "${conflict.scratchpadDir.asSpelled}" — the scratchpad is the fs-write gate's EXEMPT directory, so a task rooted there would auto-approve every write under its own root and the engine refuses to build the gate at all (config.write_exempt_dir_covers_root). Point cwd at the project directory instead (or send no cwd and let the session keep the one it has): this session registers the cwd you send, so a task that happens not to mount the write gate would still leave every LATER task on this session rooted in the exemption. A workflow that really is meant to write without prompts says so with a knob that means it (a permission mode, an open write face) and still keeps its root outside the scratchpad — those knobs do not lift this refusal, because the conflict is between two fields of this request, not a permission level.`, { code: "request.field_conflict", extra: { cwd: conflict.cwd, scratchpadDir: conflict.scratchpadDir } });
60
+ }
61
+ }
49
62
  const rawMemoryCapture = body.memoryCapture;
50
63
  if (rawMemoryCapture !== undefined && rawMemoryCapture !== "off") {
51
64
  throw new HttpError(400, "memoryCapture must be the string 'off' when present (the session-level memory-capture opt-out declaration; absent = capture as usual — there is no 'on' spelling)", { code: "request.field_invalid" });
@@ -53,7 +66,7 @@ export function createResolveSpec(ctx) {
53
66
  assertRequestA2aUnlocked(body.a2aPeers, lockedKeys);
54
67
  const gated = await gateScenarioAndAppend(body, auth, opts);
55
68
  const effMode = effectivePermissionMode(body, gated.parsedSettings.settings);
56
- noteSessionShellGate?.(auth?.sessionId, effMode === "bypassPermissions");
69
+ noteSessionShellGate?.(auth?.sessionId, effMode !== undefined && shellGateForMode(effMode) === "off");
57
70
  const lane = bindSettingsCwdEnvAndModel(body, auth, gated, effMode, opts);
58
71
  const anchors = await resolveHistoryAnchors(body, auth);
59
72
  const spec = assembleSpecLiteral(body, auth, req, opts, { ...gated, ...lane, ...anchors }, effMode);
@@ -279,8 +292,8 @@ export function createResolveSpec(ctx) {
279
292
  additionalReadDirectories,
280
293
  enablePlanMode: config.planModeEnabled ? true : undefined,
281
294
  ...(effMode ? { shellGate: shellGateForMode(effMode) } : {}),
282
- ...(effMode === "bypassPermissions" ? { writeFace: "open" } : {}),
283
- ...(effMode === "auto" ? { autoModeRequested: true } : {}),
295
+ ...((f) => (f !== undefined ? { writeFace: f } : {}))(effMode !== undefined ? writeFaceForMode(effMode) : undefined),
296
+ ...((f) => (f !== undefined ? { autoModeRequested: f } : {}))(effMode !== undefined ? autoModeRequestedForMode(effMode) : undefined),
284
297
  selfOrchestration: selfOrchestrationFromBody({ selfOrchestration: body.selfOrchestration === true || parsedSettings.settings?.ultracode === true }, config, Boolean(centerRuntimeCapsResolver)),
285
298
  forwardSubagentEvents: body.forwardSubagentEvents === true ? true : undefined,
286
299
  retainSubagentSessions: normalizeRetainSubagentSessions(body.retainSubagentSessions),
@@ -507,6 +520,12 @@ export function createResolveSpec(ctx) {
507
520
  governed = applyTaskSettings(governedBase, effectiveSettings, fsWriteGate, workflowGate);
508
521
  }
509
522
  catch (e) {
523
+ if (e.code === WRITE_EXEMPT_DIR_COVERS_ROOT_CODE) {
524
+ throw new HttpError(400, e.message, {
525
+ code: "request.field_conflict",
526
+ ...(e.detail !== undefined ? { extra: { writeExemptDirCoversRoot: e.detail } } : {}),
527
+ });
528
+ }
510
529
  throw new HttpError(422, `settings are tighten-only and cannot loosen the deployment policy: ${e.message}`);
511
530
  }
512
531
  }
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import { DEFAULT_MODEL_REF } from "../model-select.js";
2
3
  import { runTeamDiscussion } from "@sema-agent/core";
3
4
  import { REVIEW_LENSES, GROUNDING, LENS_SYSTEM_TPL, DEBATE_MEMBER_TPL, DEBATE_TOPIC_TPL, ARBITER_SYSTEM, ARBITER_FALLBACK_HEADER } from "./prompts/code-review.js";
4
5
  const LENSES = REVIEW_LENSES;
@@ -79,7 +80,7 @@ async function runConvergence(subRunner, repoTools, objective, findings, opts, s
79
80
  if (opts.debate) {
80
81
  const team = await (opts.teamFn ?? runTeamDiscussion)({
81
82
  runner: subRunner,
82
- model: "default",
83
+ model: DEFAULT_MODEL_REF,
83
84
  members: lenses.map((l) => ({ role: l.name, systemPrompt: debateMemberSystem(l) })),
84
85
  topic: debateTopic(objective, findings),
85
86
  rounds: opts.rounds ?? 1,
@@ -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)
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
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
5
  import { fileURLToPath } from "node:url";
@@ -872,14 +873,14 @@ function parseModelDomain() {
872
873
  const models = { default: model, [model.id]: model };
873
874
  if (cheapModel)
874
875
  models[cheapModel.id] = cheapModel;
875
- const cheapRef = cheapModel ? cheapModel.id : "default";
876
+ const cheapRef = cheapModel ? cheapModel.id : DEFAULT_MODEL_REF;
876
877
  const verifierEnv = process.env.MODEL_VERIFIER;
877
- const verifierRef = verifierEnv && models[verifierEnv] ? verifierEnv : "default";
878
+ const verifierRef = verifierEnv && models[verifierEnv] ? verifierEnv : DEFAULT_MODEL_REF;
878
879
  const roles = {
879
880
  verifier: verifierRef,
880
881
  };
881
882
  for (const r of ROSTER_PRIMARY_ROLES)
882
- roles[r] = "default";
883
+ roles[r] = DEFAULT_MODEL_REF;
883
884
  for (const r of ROSTER_CHEAP_ROLES)
884
885
  roles[r] = cheapRef;
885
886
  for (const r of csv("MODEL_CODE_ROLES")) {
@@ -96,6 +96,67 @@ export declare function acceptShellScratchpadDir(raw: unknown, opts: {
96
96
  hostSemanticsLane: boolean;
97
97
  warn: (msg: string, meta?: object) => void;
98
98
  }): Promise<string | undefined>;
99
+ /**
100
+ * {@link acceptShellScratchpadDir} 的**词法半场**(S-351 拆出):零 I/O、可在请求的任何一点问。
101
+ *
102
+ * 为什么拆:S-351 的前判要在**任何副作用之前**回答「这条请求会把哪个目录当写门豁免域」,而验收门的
103
+ * 后半场是 `mkdir`(副作用 + I/O)。把判据抄一份到前判处 = 两份验收规则,而两份规则漂移的那天,前判
104
+ * 会替一条**根本不会被接受**的壳供路径拒掉请求(或反过来放过一条会被接受的)。所以规则只有这一份,
105
+ * 两个调用点各取所需:验收门 = 本函数 + mkdir;前判 = 本函数。
106
+ *
107
+ * 返回的是**词法归一后**(`resolve`)的拼法 —— 与验收门交给写门的那一个逐字相同。
108
+ */
109
+ export declare function screenShellScratchpadDir(raw: unknown, opts: {
110
+ requirePrincipal: boolean;
111
+ hostSemanticsLane: boolean;
112
+ warn: (msg: string, meta?: object) => void;
113
+ }): string | undefined;
114
+ /**
115
+ * S-351(core 7.20.0 / [ref])—— **这条请求会不会把任务根放进写门的豁免域里**。
116
+ *
117
+ * ## 病形(引擎侧的硬拒,不是判决期的宽严)
118
+ * 写门的豁免域就是 scratchpad(`exemptDirs: [scratchpadDir]`,`task-settings.ts` 的
119
+ * `createFsWriteGatePolicy` 那一句),而门的根是这条 task 的 cwd。core 7.20.0 起,**豁免域等于根或是
120
+ * 根的祖先 ⇒ 构造期抛**(亲读安装树 `dist/core/fs-write-gate-policy.js:11-28`,
121
+ * `code = "config.write_exempt_dir_covers_root"`)——理由逐字:一条盖住根的豁免把根下**每一次写**都自动
122
+ * 批准,那就是整个工作区,门宁可拒绝被建出来,也不肯带着一扇静默关掉的写治理去跑。
123
+ *
124
+ * 本仓真正够得着这一形的只有**一条边**:调用方自设 `body.cwd` 进 scratchpad 树(server 自己**从不**
125
+ * 把 cwd 缺省到 scratchpad —— scratchpad 与 task root 是两棵子树)。它今天的下场是 spec 折叠期抛出一条
126
+ * 没人分诊过的错,而调用方看到的是一条与病因无关的 422。所以前判把它提前成一条**读得懂的请求拒绝**。
127
+ *
128
+ * ## 判据只有词法(不承诺 canonical)
129
+ * 引擎那一侧的构造期**没有 I/O**(同上坐标:`lexicalViewOf` 纯字符串),所以这里也只做词法 —— 承诺
130
+ * canonical 就是承诺一件此刻做不到的事(软链形的真身要到裁决时刻才问 fs,那是写门自己的第二道)。
131
+ *
132
+ * ## 射程(每一条都是「不这样写就会误拒」)
133
+ * · **只在 cwd 真会被尊重时判**:`cwdHonored` 为假的部署(多租户 / 非 host 车道)把 cwd 直接忽略,
134
+ * 它永远不会变成门的根 —— 在那里拒等于凭空多一条 400;
135
+ * · **豁免域取这条会话可能挂上的每一只**(不是「这一次挂哪只」):server 自算那条恒是候选(有 session
136
+ * 就算得出),壳供 `body.scratchpadDir` **过了词法验收**才追加为候选(过不了的那条永远挂不上,拿它拒
137
+ * 就是凭空多一条 400)。只判其一会留一条**跨请求**的洞:一条请求供一只合法壳 scratchpad 把判据引开、
138
+ * 同时把 cwd 指进自算那只,放行后 cwd 坐进会话表,下一条不带路径字段的请求回落自算形、死在引擎构造期,
139
+ * 而它根本没送过 cwd(codex r2 [medium] 实证);
140
+ * · **`in-process` 车道(provider 未设)不在射程**:那条腿的门根是**进程自己的 cwd**,与请求字段无关 ——
141
+ * 对着一个请求字段拒一件不是它造成的事,是把诊断指向错的地方(如实登记,不假装覆盖)。
142
+ */
143
+ export declare function scratchpadCoveringTaskRoot(args: {
144
+ cwd: unknown;
145
+ shellScratchpadDir: unknown;
146
+ localDataRoot: string;
147
+ sessionId: string | undefined;
148
+ requirePrincipal: boolean;
149
+ cwdHonored: boolean;
150
+ }): {
151
+ cwd: {
152
+ asSpelled: string;
153
+ lexical: string;
154
+ };
155
+ scratchpadDir: {
156
+ asSpelled: string;
157
+ lexical: string;
158
+ };
159
+ } | undefined;
99
160
  /** 删除一个 session 的 scratchpad 目录(rm -rf,幂等:目录不存在=no-op)。失败吞 warn 不抛——
100
161
  * 清理绝不 fault 调用它的 session-delete 路径;下一轮 {@link sweepStaleScratchpads} 仍会兜到它。 */
101
162
  export declare function purgeScratchpadDir(localDataRoot: string, sessionId: string): Promise<void>;
package/dist/env-facts.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, join, resolve, sep } from "node:path";
2
2
  import { mkdir, readdir, rm, stat } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
+ import { dirCoversPath, isValidCwd, normalizeAbsolutePathLexically } from "./task-cwd.js";
4
5
  const MAX_STR = 64;
5
6
  const MAX_CAPS = 16;
6
7
  const MAX_PATH = 160;
@@ -61,6 +62,19 @@ export async function ensureScratchpadDir(localDataRoot, sessionId) {
61
62
  }
62
63
  }
63
64
  export async function acceptShellScratchpadDir(raw, opts) {
65
+ const screened = screenShellScratchpadDir(raw, opts);
66
+ if (screened === undefined)
67
+ return undefined;
68
+ try {
69
+ await mkdir(screened, { recursive: true });
70
+ return screened;
71
+ }
72
+ catch (err) {
73
+ opts.warn("shell_scratchpad_ignored", { reason: "mkdir_failed", error: err instanceof Error ? err.message : String(err) });
74
+ return undefined;
75
+ }
76
+ }
77
+ export function screenShellScratchpadDir(raw, opts) {
64
78
  if (typeof raw !== "string" || raw.length === 0)
65
79
  return undefined;
66
80
  if (opts.requirePrincipal) {
@@ -85,14 +99,26 @@ export async function acceptShellScratchpadDir(raw, opts) {
85
99
  opts.warn("shell_scratchpad_ignored", { reason: "too_shallow", canonical });
86
100
  return undefined;
87
101
  }
88
- try {
89
- await mkdir(canonical, { recursive: true });
90
- return canonical;
91
- }
92
- catch (err) {
93
- opts.warn("shell_scratchpad_ignored", { reason: "mkdir_failed", error: err instanceof Error ? err.message : String(err) });
102
+ return canonical;
103
+ }
104
+ export function scratchpadCoveringTaskRoot(args) {
105
+ if (!args.cwdHonored || !isValidCwd(args.cwd))
94
106
  return undefined;
107
+ const candidates = [];
108
+ const shell = screenShellScratchpadDir(args.shellScratchpadDir, { requirePrincipal: args.requirePrincipal, hostSemanticsLane: true, warn: () => { } });
109
+ if (shell !== undefined)
110
+ candidates.push({ asSpelled: typeof args.shellScratchpadDir === "string" ? args.shellScratchpadDir : shell, dir: shell });
111
+ if (args.sessionId !== undefined) {
112
+ const own = scratchpadDirFor(args.localDataRoot, args.sessionId);
113
+ candidates.push({ asSpelled: own, dir: own });
95
114
  }
115
+ const hit = candidates.find((c) => dirCoversPath(c.dir, args.cwd));
116
+ if (hit === undefined)
117
+ return undefined;
118
+ return {
119
+ cwd: { asSpelled: args.cwd, lexical: normalizeAbsolutePathLexically(args.cwd) },
120
+ scratchpadDir: { asSpelled: hit.asSpelled, lexical: normalizeAbsolutePathLexically(hit.dir) },
121
+ };
96
122
  }
97
123
  export async function purgeScratchpadDir(localDataRoot, sessionId) {
98
124
  const dir = scratchpadDirFor(localDataRoot, sessionId);
@@ -52,6 +52,18 @@ export interface ExecutionLaneCaps {
52
52
  /** core 的 `isRemoteExecutionEnv` 对本车道的判决(鸭子类型;判别位 = `config.remoteExec` 在不在场,
53
53
  * **含 host**)。5.26.0 起它决定 `# Memory` 写指令撤不撤。 */
54
54
  readonly coreRemoteExecutionEnv: LaneCapability;
55
+ /**
56
+ * S-354 / core 7.20.0([ref]):这条车道的适配器**声明**了 `capabilities.canonicalPathAuthoritative`
57
+ * 没有 —— 即「我的 `canonicalPath` 是路径落点的权威,整链答不出来是解析器出了事,不是一个我看不见的
58
+ * 命名空间」。声明 ⇒ 读边界的执行期复核在解析器沉默时**拒**;缺席 ⇒ 今天每个适配器的读法(词法判决
59
+ * 成立、命令照跑)—— **缺席不是「声明为 false」**,所以这一位的 `unsupported` 读作「本车道没表态」。
60
+ *
61
+ * 判别式:各 adapter 的 `capabilities` 字面量(`plugins/remote-env-{host,ssh,adb,k8s,device,e2b,local-docker}.ts`),
62
+ * 由 core 的严格读器 `canonicalPathIsAuthoritative(env)` 读;对账测试逐 lane 拿**真 adapter 实例**问它。
63
+ * 本位存在的理由 = 让「新车道/新适配器不表态」变成**编译红**(穷举 switch 少一行即 tsc 红),而不是
64
+ * 静默继承一个没人表过态的缺席。
65
+ */
66
+ readonly canonicalPathAuthority: LaneCapability;
55
67
  }
56
68
  /**
57
69
  * per-lane 能力表。**穷举 switch,禁 default 臂**(hands-lane.ts:39-46 先例)。
@@ -19,6 +19,7 @@ export function executionLaneCaps(lane) {
19
19
  memoryPersistenceCapable: NO,
20
20
  hostFilePlane: YES,
21
21
  coreRemoteExecutionEnv: NO,
22
+ canonicalPathAuthority: NO,
22
23
  };
23
24
  case "host":
24
25
  return {
@@ -35,6 +36,7 @@ export function executionLaneCaps(lane) {
35
36
  memoryPersistenceCapable: YES,
36
37
  hostFilePlane: YES,
37
38
  coreRemoteExecutionEnv: YES,
39
+ canonicalPathAuthority: YES,
38
40
  };
39
41
  case "e2b":
40
42
  return {
@@ -51,6 +53,7 @@ export function executionLaneCaps(lane) {
51
53
  memoryPersistenceCapable: NO,
52
54
  hostFilePlane: NO,
53
55
  coreRemoteExecutionEnv: YES,
56
+ canonicalPathAuthority: NO,
54
57
  };
55
58
  case "k8s":
56
59
  return {
@@ -67,6 +70,7 @@ export function executionLaneCaps(lane) {
67
70
  memoryPersistenceCapable: NO,
68
71
  hostFilePlane: NO,
69
72
  coreRemoteExecutionEnv: YES,
73
+ canonicalPathAuthority: YES,
70
74
  };
71
75
  case "ssh":
72
76
  return {
@@ -83,6 +87,7 @@ export function executionLaneCaps(lane) {
83
87
  memoryPersistenceCapable: NO,
84
88
  hostFilePlane: NO,
85
89
  coreRemoteExecutionEnv: YES,
90
+ canonicalPathAuthority: YES,
86
91
  };
87
92
  case "adb":
88
93
  return {
@@ -99,6 +104,7 @@ export function executionLaneCaps(lane) {
99
104
  memoryPersistenceCapable: NO,
100
105
  hostFilePlane: NO,
101
106
  coreRemoteExecutionEnv: YES,
107
+ canonicalPathAuthority: YES,
102
108
  };
103
109
  case "local-docker":
104
110
  return {
@@ -115,6 +121,7 @@ export function executionLaneCaps(lane) {
115
121
  memoryPersistenceCapable: NO,
116
122
  hostFilePlane: NO,
117
123
  coreRemoteExecutionEnv: YES,
124
+ canonicalPathAuthority: NO,
118
125
  };
119
126
  case "device":
120
127
  return {
@@ -131,6 +138,7 @@ export function executionLaneCaps(lane) {
131
138
  memoryPersistenceCapable: NO,
132
139
  hostFilePlane: NO,
133
140
  coreRemoteExecutionEnv: YES,
141
+ canonicalPathAuthority: YES,
134
142
  };
135
143
  }
136
144
  }