@sema-agent/server 7.78.2 → 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.
@@ -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";
@@ -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" });
@@ -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,
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
  }
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 }
@@ -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
@@ -34,7 +34,7 @@ const UNSUPPORTED_SCHEDULER_CAPS = {
34
34
  maxScheduledHorizonSec: 0,
35
35
  };
36
36
  export class RemoteHostExecutionEnv {
37
- capabilities = { isolation: false, suspendable: false };
37
+ capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true };
38
38
  get schedulerCapabilities() {
39
39
  return this.cfg.scheduler?.schedulerCapabilities ?? UNSUPPORTED_SCHEDULER_CAPS;
40
40
  }
@@ -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?;
@@ -78,7 +78,7 @@ export class RemoteK8sExecutionEnv {
78
78
  this.cwd = this.cfg.mountPath;
79
79
  this.homeDir = config.homeDir;
80
80
  this.deps = deps ?? {};
81
- this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot };
81
+ this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot, canonicalPathAuthoritative: true };
82
82
  const bgMax = Math.max(1, Math.min(K8S_BG_MAX_TIMEOUT_SEC, Math.floor(this.cfg.timeoutMs / 1000)));
83
83
  this.backgroundCapabilities = {
84
84
  supported: true,
@@ -61,9 +61,20 @@ export interface SshEnvDeps {
61
61
  connectOnce?: (signal?: AbortSignal) => Promise<ConnectResult>;
62
62
  }
63
63
  export declare class RemoteSshExecutionEnv implements RemoteExecutionEnv {
64
+ /**
65
+ * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath`
66
+ * 就是**命令真正跑的那块文件系统**的权威答案(`posix-shell-fs` 的 `canonicalPath` 原语,在真机上解到落点)。声明它 = 告诉引擎:整链答不出来是**解析器出了事**
67
+ * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。
68
+ * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。
69
+ * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的
70
+ * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
71
+ * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。
72
+ * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。
73
+ */
64
74
  readonly capabilities: {
65
75
  isolation: boolean;
66
76
  suspendable: boolean;
77
+ canonicalPathAuthoritative: boolean;
67
78
  };
68
79
  /** Working directory; relative paths resolve against it (ExecutionEnv contract). Starts at mountPath. */
69
80
  cwd: string;
@@ -16,7 +16,7 @@ function exitCodeForSignalKill(signal) {
16
16
  return 128 + (signalNumber(`SIG${signal}`) ?? 9);
17
17
  }
18
18
  export class RemoteSshExecutionEnv {
19
- capabilities = { isolation: false, suspendable: false };
19
+ capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true };
20
20
  cwd;
21
21
  conn;
22
22
  sftpPromise;
package/dist/run-local.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { DEFAULT_MODEL_REF } from "./model-select.js";
2
3
  import { terminalProjection } from "@sema-agent/core";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
@@ -429,7 +430,7 @@ export async function runLocal(argv, deps = {}) {
429
430
  const webSearch = webSearchCfg ? createWebSearchBackend(webSearchCfg) : undefined;
430
431
  const taskListLane = createTaskListLane({ logger });
431
432
  const scenarioDeps = {
432
- runner, subRunner, handslessSubRunner, model: "default", skills, repoClient, metrics, logger,
433
+ runner, subRunner, handslessSubRunner, model: DEFAULT_MODEL_REF, skills, repoClient, metrics, logger,
433
434
  ...(webSearch ? { webSearch } : {}),
434
435
  backgroundAgentStore: fileBackend.backgroundAgentStore,
435
436
  taskListStoreFor: taskListLane.storeFor,
@@ -18,6 +18,30 @@ export declare const MAX_CWD_CHARS = 4096;
18
18
  * 先改这里**:把判据换成 core 的 `isAbsoluteForFamily`,并把家族从这条 cwd 自己的拼法取。
19
19
  * 钉见 `test/task-cwd.test.ts` 的「[ref] 路径家族接缝」格(实测读数,不是推断)。 */
20
20
  export declare function isValidCwd(cwd: unknown): cwd is string;
21
+ /**
22
+ * 词法归一:纯字符串运算,折 `.` / `..` / 重复分隔符,**不碰 fs**。
23
+ * `posix.normalize` 把 `..` 在根部截断(`/../x` → `/x`),与「根之上没有东西」的语义一致;尾部分隔符
24
+ * 统一剥掉(根 `/` 除外),让同一目标只有一个 key —— 前缀判域(见 {@link dirCoversPath})靠的就是
25
+ * key 的唯一性。
26
+ *
27
+ * 🔴 **单点**(S-351 起):此前它的一份私有副本住在 `boot/deferred-sandbox-path-env.ts`(沙箱代理的
28
+ * `absolutePath` 词法臂),而写门豁免域与任务根的判域现在读的是同一条律 —— 两份各自归一就是两个 key
29
+ * 空间,而「豁免域盖没盖住任务根」这种判断在两个 key 空间里可以同时答是和否。
30
+ * (core 内部有同形的 `normalizeAbsPathLexically`,但未从包根导出;此处是 Node 标准库的等价运算,
31
+ * 不是它的抄本——若日后 core 导出,这里应改为直接复用。)
32
+ */
33
+ export declare function normalizeAbsolutePathLexically(p: string): string;
34
+ /**
35
+ * **词法**判域:`dir` 是不是 `target` 本身或它的祖先(= `target` ⊆ `dir`,「等于 ∪ 后代」)。
36
+ *
37
+ * 这是 core `assertExemptDirsDoNotCoverRoot` 那条律的本仓同形(亲读安装树
38
+ * `dist/core/fs-write-gate-policy.js:11-28` → `swallowedFenceRoot` → `isWithin(root, p)`:
39
+ * 先各自词法归一,再判 `p === root` 或 `p` 以 `root + "/"` 起头)。**两端都只做词法**,不 realpath ——
40
+ * 写门的构造期本来就没有 I/O(core 同理),承诺 canonical 是承诺一件此刻做不到的事。
41
+ *
42
+ * 段感知由「归一后 + 分隔符前缀」保证:`/a/bc` **不在** `/a/b` 域内。
43
+ */
44
+ export declare function dirCoversPath(dir: string, target: string): boolean;
21
45
  /**
22
46
  * The deployment-level gate: does THIS deployment honor a caller-supplied `cwd`? TRUE only for the single-user
23
47
  * `host` lane (see module header). All cloud/container lanes + any multi-tenant deployment → FALSE (cwd ignored).
package/dist/task-cwd.js CHANGED
@@ -8,6 +8,15 @@ export function isValidCwd(cwd) {
8
8
  path.isAbsolute(cwd) &&
9
9
  !cwd.split(/[/\\]/).includes(".."));
10
10
  }
11
+ export function normalizeAbsolutePathLexically(p) {
12
+ const collapsed = path.posix.normalize(p.replace(/^\/+/, "/"));
13
+ return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
14
+ }
15
+ export function dirCoversPath(dir, target) {
16
+ const root = normalizeAbsolutePathLexically(dir);
17
+ const p = normalizeAbsolutePathLexically(target);
18
+ return p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
19
+ }
11
20
  export function cwdHonored(config) {
12
21
  return config.remoteExec?.provider === "host" && config.requirePrincipal !== true;
13
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.78.2",
3
+ "version": "7.79.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "7.19.0",
57
+ "@sema-agent/core": "7.20.0",
58
58
  "@sema-agent/settings-schema": "2.1.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",