@sema-agent/server 7.95.1 → 7.97.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 (82) hide show
  1. package/USAGE.md +10 -5
  2. package/deploy/sema-up/README.md +8 -1
  3. package/deploy/sema-up/chart/templates/server-v4pro.yaml +3 -0
  4. package/deploy/sema-up/chart/templates/server.yaml +3 -0
  5. package/deploy/sema-up/lib/lanes.json +56 -0
  6. package/deploy/sema-up/lib/lanes.py +179 -0
  7. package/deploy/sema-up/lib/parse-machines.py +7 -1
  8. package/deploy/sema-up/machines.example.yaml +5 -2
  9. package/deploy/sema-up/preflight.sh +7 -4
  10. package/deploy/sema-up/sema-up.sh +201 -24
  11. package/deploy/sema-up/stack/compose.yaml +4 -0
  12. package/deploy/sema-up/stack/sema-stack-cluster.yaml +2 -0
  13. package/dist/approval.d.ts +28 -0
  14. package/dist/approval.js +24 -0
  15. package/dist/boot/budget-tracing.d.ts +1 -0
  16. package/dist/boot/budget-tracing.js +3 -2
  17. package/dist/boot/config-center.js +2 -5
  18. package/dist/boot/coordinators.js +2 -2
  19. package/dist/boot/deferred-sandbox-path-env.d.ts +5 -2
  20. package/dist/boot/deferred-sandbox-path-env.js +3 -1
  21. package/dist/boot/memory-boundary.d.ts +17 -2
  22. package/dist/boot/memory-boundary.js +11 -0
  23. package/dist/boot/reapers.d.ts +3 -1
  24. package/dist/boot/reapers.js +2 -2
  25. package/dist/boot/resolve-spec.js +6 -6
  26. package/dist/boot/runner-deps.d.ts +2 -1
  27. package/dist/boot/session-faces.d.ts +4 -1
  28. package/dist/boot/session-faces.js +5 -5
  29. package/dist/boot/stage-04-stores.d.ts +1 -0
  30. package/dist/boot/stage-04-stores.js +2 -2
  31. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  32. package/dist/boot/stage-06-runners.d.ts +1 -0
  33. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  34. package/dist/boot/stage-07-capability-layer.js +10 -14
  35. package/dist/boot/stage-08-reapers.d.ts +2 -3
  36. package/dist/boot/stage-08-reapers.js +2 -2
  37. package/dist/boot/stage-09-leader.d.ts +1 -0
  38. package/dist/boot/stage-09-leader.js +2 -2
  39. package/dist/boot/stage-10-http-server.d.ts +1 -0
  40. package/dist/boot/stores.js +5 -11
  41. package/dist/capabilities/execution-lane.d.ts +5 -7
  42. package/dist/capabilities/execution-lane.js +2 -1
  43. package/dist/capabilities/memory-notice.d.ts +30 -12
  44. package/dist/capabilities/memory-notice.js +8 -1
  45. package/dist/capabilities/skills.d.ts +14 -0
  46. package/dist/capabilities/skills.js +4 -0
  47. package/dist/config-catalog.js +1 -0
  48. package/dist/config-types.d.ts +8 -1
  49. package/dist/config.d.ts +16 -0
  50. package/dist/config.js +13 -12
  51. package/dist/env-facts.js +2 -4
  52. package/dist/execution-lane-caps.d.ts +36 -6
  53. package/dist/execution-lane-caps.js +4 -0
  54. package/dist/file-history-capture.d.ts +65 -0
  55. package/dist/file-history-capture.js +21 -0
  56. package/dist/hosted-posture.d.ts +1 -1
  57. package/dist/http/resume-legs.d.ts +14 -4
  58. package/dist/http/resume-legs.js +26 -17
  59. package/dist/http/route-ctx.d.ts +35 -9
  60. package/dist/http/routes/approvals-assistant.d.ts +1 -1
  61. package/dist/http/routes/approvals-assistant.js +16 -6
  62. package/dist/http/routes/capabilities.js +4 -1
  63. package/dist/http/routes/runs.js +2 -2
  64. package/dist/http/routes/session-sync.js +6 -1
  65. package/dist/http/routes/tasks.js +2 -2
  66. package/dist/http/server.d.ts +4 -1
  67. package/dist/http/server.js +1 -1
  68. package/dist/http/verify-rounds.d.ts +1 -1
  69. package/dist/memory-scope.d.ts +6 -2
  70. package/dist/memory-scope.js +3 -2
  71. package/dist/observability/fail-open.d.ts +4 -0
  72. package/dist/observability/fail-open.js +4 -0
  73. package/dist/observability/run-terminal.js +1 -0
  74. package/dist/run-local.js +4 -5
  75. package/dist/session-sync.d.ts +4 -1
  76. package/dist/session-sync.js +2 -2
  77. package/dist/task-cwd.d.ts +9 -3
  78. package/dist/task-cwd.js +3 -1
  79. package/dist/trace/core-keyset-guard.d.ts +1 -1
  80. package/dist/trace/project.d.ts +11 -0
  81. package/dist/trace/project.js +1 -0
  82. package/package.json +4 -3
@@ -15,6 +15,9 @@
15
15
  set -euo pipefail
16
16
 
17
17
  HERE="$(cd "$(dirname "$0")" && pwd)"
18
+ # 执行车道表的唯一读者(S-599)。定义在**最前面**:`--help` 的词表也从表渲(手抄的词表会在加一条
19
+ # 车道时静默分叉),而 `-h` 在参数循环里就被处理,那时后面的函数还没定义。
20
+ lanes() { python3 "$HERE/lib/lanes.py" "$@"; }
18
21
  MODE_INTERACTIVE="tty" # tty | json | none(--yes)
19
22
  PROFILE="" USERS="" REGION="auto" CONF="" PROJECT="sema" TEARDOWN=false SKIP_PREFLIGHT=false
20
23
  DATA_ROOT="" SERVER_PORT=8090 PG_PORT=5432 MINIO_PORT=9000 MINIO_CONSOLE_PORT=9001
@@ -37,7 +40,11 @@ usage() {
37
40
  --dry-run 只打印将执行计划(不写 .env、不起任何容器)
38
41
  --port=N 服务端口(默认 8090);--pg-port/--minio-port/--minio-console-port 同理
39
42
  --gateway=URL --model=ID --model-key=K 模型面(带 agent 的档必需)
40
- --sandbox=LANE kata|e2b-cloud|runc(full-single)
43
+ EOF
44
+ # 词表从 lib/lanes.json 渲(单一属主):加/删一条车道,这行自己跟着变
45
+ printf ' --sandbox=LANE %s(full-single / local-plus;kube 路径不选词,runtimeClass 自动)\n' \
46
+ "$(lanes choices | tr '\n' '|' | sed 's/|$//')"
47
+ cat <<'EOF'
41
48
  --registry=BOOL 配置中心网站(默认 true;local-plus 默认 false)
42
49
  --registry-image=REF sema-registry 镜像(registry=true 必需,可 -f 配置 SEMA_REGISTRY_IMAGE)
43
50
  --git=MODE none(默认)|provision(一键自建 Gitea)|connect(接已有 git)
@@ -113,6 +120,79 @@ if [ -n "$CONF" ]; then
113
120
  fi
114
121
 
115
122
  WORK="$HOME/.sema-up/$PROJECT"; mkdir -p "$WORK"; chmod 700 "$HOME/.sema-up" "$WORK"
123
+ # 凭据/配置文件的坐标 —— 与 `has_env` 一起提到这里(S-599):车道决策要在模型面之前读既有 .env
124
+ # (冲突检测 + 「必需键已给过」判定),而生成段在几百行之后。纯搬家,零语义变。
125
+ ENVF="$WORK/.env"
126
+ has_env() { [ -f "$ENVF" ] && grep -q "^$1=..*" "$ENVF"; }
127
+ # ── 决策键的 `.env` 严格读口(7.96.0 合并复审)────────────────────────────────────────────────────
128
+ # 决策(租户形 / 车道)读到的每个键 —— `REQUIRE_PRINCIPAL`、`REMOTE_EXEC`、所选车道的必需键 —— 只经这一只读口。
129
+ # 它**不去猜** compose 会怎么读 `.env`:compose 的 dotenv 方言认 `export ` 前缀、键两侧空格、`K: v`、行内注释、
130
+ # 重复键末次生效、`$VAR` 展开……在 bash 里复刻其中任何一部分都是一份手抄方言,漏一处就是「读口看到
131
+ # false、容器拿到 true」(合并复审实证:`=false` 之后再来一行 `export …=true` ⇒ 本脚本落 host,容器拿到 true)。
132
+ # 所以本读口是一条**闭文法**,fail-closed:
133
+ # ① 凡是在任一方言下**可能**给 <键> 赋值的行(行首空白?+ `export `? + <键> + 空白? + `=` 或 `:`)至多一行;
134
+ # ② 那一行必须是规范形 `<键>=<值>`:值 = 空 | 一个不含空白 / 引号 / `#` / `$` / 反斜杠 / 反引号的词 |
135
+ # 一对同型引号包住的这样一个词(sema-up 自己写出的形全在这里面)。
136
+ # 违反任一条 ⇒ 响亮拒并点名行号(**不回显行内容**:必需键里有凭据)。通过 ⇒ 打印值(剥掉那对引号;空 = 未设)。
137
+ # 调用形恒为 `v="$(envf_get K)" || exit 1`(拒因走 stderr,退出码带回调用方)。
138
+ # ⚠️ **已知未闭环(如实披露,不是本读口的承诺范围)**:本读口按**物理行**判,不认引号跨行 —— 另一个键的跨行
139
+ # 单/双引号值里若有一行恰好长成 `K=<值>`,本读口会把它当成 K 的那一行。生成段的 setk / has_env / resetk
140
+ # 同样按物理行判(早于本读口就是这样),两者对这种输入的误读方向一致:容器拿到的是 compose 的缺省值。
141
+ # 统一形(另立一件):生成 .env 之后用 `docker compose config` 渲染一次,拿容器真正拿到的值与决策对账 ——
142
+ # 那一步以 compose 自己为唯一属主,本读口与 setk 的物理行假设可以一起退役。
143
+ ENVF_WORD='[^[:space:]"'"'"'#$`\\]'
144
+ envf_get() {
145
+ [ -f "$ENVF" ] || return 0
146
+ local k="$1" hits n nums line v
147
+ hits="$(grep -nE "^[[:space:]]*(export[[:space:]]+)?${k}[[:space:]]*[=:]" "$ENVF" || true)"
148
+ [ -n "$hits" ] || return 0
149
+ n="$(printf '%s\n' "$hits" | wc -l | tr -d '[:space:]')"
150
+ nums="$(printf '%s\n' "$hits" | cut -d: -f1 | paste -sd, -)"
151
+ if [ "$n" -gt 1 ]; then
152
+ log "⛔ $ENVF 里可能给 $k 赋值的行有 $n 行(第 $nums 行)—— 决策键只许一行"
153
+ log " ↳ 下一步:只留一行规范形 $k=<值>,其余删掉"
154
+ log " ↳ 为什么不挑一行用:compose 读 .env 的规则(export 前缀、键旁空格、重复键末次生效)与本脚本不同,挑哪一行都可能不是容器真正拿到的那一行"
155
+ return 1
156
+ fi
157
+ line="${hits#*:}"
158
+ if ! printf '%s\n' "$line" | grep -qE "^${k}=(${ENVF_WORD}*|\"${ENVF_WORD}*\"|'${ENVF_WORD}*')\$"; then
159
+ log "⛔ $ENVF 第 $nums 行给 $k 赋值,但不是本脚本认得的规范形"
160
+ log " ↳ 下一步:改成 $k=<值>(行首顶格、不带 export、键旁无空格、值是一个词或一对同型引号包住的一个词、行尾无注释),或删掉这一行"
161
+ log " ↳ 为什么不猜:这一行 compose 怎么读(注释、空白、展开)与本脚本怎么读一旦不一致,容器拿到的就不是决策读到的值"
162
+ return 1
163
+ fi
164
+ v="${line#*=}"
165
+ case "$v" in \"*\") v="${v#\"}"; v="${v%\"}" ;; \'*\') v="${v#\'}"; v="${v%\'}" ;; esac
166
+ printf '%s' "$v"
167
+ }
168
+ # ── 部署事实(租户形与车道两个决策共用)────────────────────────────────────────────────
169
+ # 事实 = `.env`(严格读口)∪ shell / `-f` 环境。compose 插值里 shell **优先于** --env-file(本脚本 teardown 段同一
170
+ # 事实),所以两处都非空而不同 ⇒ 拒并点名两处 —— 不「取第一个非空」:放行的唯一形是两处至多一个值,无论谁赢,
171
+ # 赢的都是它。设 FACT_VAL(可空 = 没有事实)与 FACT_SRC(来源描述)。直接调用,不进命令替换(拒要退整个脚本)。
172
+ deploy_fact() {
173
+ local k="$1" fenv fsh shsrc
174
+ fenv="$(envf_get "$k")" || exit 1
175
+ eval "fsh=\${$k:-}"
176
+ shsrc="${CONF:+-f $CONF / }shell 环境"
177
+ if [ -n "$fenv" ] && [ -n "$fsh" ] && [ "$fenv" != "$fsh" ]; then
178
+ log "⛔ 部署事实冲突:$ENVF 里 $k=$fenv,$shsrc 里 $k=$fsh —— 两处不一致"
179
+ log " ↳ 下一步:两处只留一处(或改成同一个值)"
180
+ log " ↳ 为什么两边都要查:compose 插值里 shell 环境**优先于** --env-file,只对齐 .env 不足以决定容器真正拿到哪个值"
181
+ exit 1
182
+ fi
183
+ FACT_VAL="${fenv:-$fsh}"
184
+ if [ -n "$fenv" ]; then FACT_SRC="$ENVF"; elif [ -n "$fsh" ]; then FACT_SRC="$shsrc"; else FACT_SRC=""; fi
185
+ }
186
+ # 本次决策落盘的值与部署事实不一致 ⇒ 拒并点名两处(不静默覆盖:替运维挑一个是替他做决定)。
187
+ # 只有**显式旗**(或交互里改掉了缺省)走得到这里:不带旗时决策就取自事实,两者恒等。
188
+ # fact_conflict <键> <事实值> <事实来源> <本次要写的值> <本次的旗>
189
+ fact_conflict() {
190
+ [ -n "$2" ] || return 0
191
+ [ "$2" = "$4" ] && return 0
192
+ log "⛔ 部署事实冲突:$3 里已有 $1=$2,本次 $5 要写 $1=$4"
193
+ log " ↳ 下一步(二选一):① 去掉 ${5%%=*} 重跑(不带旗 = 沿用这台部署的事实);② 真要改:先清掉 $3 里的 $1,再带 ${5%%=*} 重跑"
194
+ exit 1
195
+ }
116
196
  # 协议流(--interactive=json 的 question/summary 行)固定走 fd3=原始 stdout——
117
197
  # $(ask …) 的命令替换只捕获答案,协议行不被吞(真机验出的坑)
118
198
  exec 3>&1
@@ -208,7 +288,8 @@ if $TEARDOWN; then
208
288
  exit 0
209
289
  fi
210
290
 
211
- KVM_OK=$([ -e /dev/kvm ] && echo true || echo false)
291
+ # (KVM 探测已随 `kata` 词退役删除 —— compose 路径不再有需要 KVM 的车道;kube 路径的 kata/runc
292
+ # 由 kube-up.sh 按 runtimeClass 自动定,preflight 的 kvm 项仍如实报给要走 kube 的人看。)
212
293
 
213
294
  # ── 决策点(三轴+registry+git,§3b;问句都带「为什么问」)─────────────────────────────────────
214
295
  [ -z "$PROFILE" ] && PROFILE=$(ask profile "要装成哪种形态?" "full-single" "决定这台机器上起哪些服务" \
@@ -216,9 +297,39 @@ KVM_OK=$([ -e /dev/kvm ] && echo true || echo false)
216
297
  "data-center:只装数据面(PG+MinIO+备份)——agent 在你自己机器上,数据放这里" \
217
298
  "full-cluster:k8s 准生产——3 机 ssh 装配 / 已有集群 kubeconfig / 本机装 k3s" \
218
299
  "local-plus:本机只跑 agent,连一个已经部署好的 data-center 数据面")
219
- [ -z "$USERS" ] && USERS=$(ask users "几个人用?" "single" "单人可跳过全部权限配置,多人要开身份隔离" \
300
+ # ── 两个决策(租户形 / 车道)同一条规则:决策 = 显式旗 ∨ 部署事实 ∨ 表缺省 ─────────────────────
301
+ # 「部署事实」只对 compose 的 agent 档成立(`.env` 就是它们的部署状态;kube 路径的状态在 helm values 里,
302
+ # data-center 不起 agent)。事实 → 决策词只经属主翻译,本脚本不抄第二份:
303
+ # · REQUIRE_PRINCIPAL → --users:只认 sema-up 自己写出的两形 true / false(别的写法 ⇒ 拒;不复刻 server 的布尔词表);
304
+ # · REMOTE_EXEC → --sandbox:`lanes choice-of`(车道表是唯一属主;表里没有的落盘值 ⇒ 拒并列在役词),见车道块。
305
+ # 不带旗重跑 ⇒ 沿用事实(多租部署重跑不必再带 --users=multi);显式旗与事实不一致 ⇒ fact_conflict 拒并点名两处。
306
+ # 🔴 为什么缺省不能先于事实(合并复审两条真案):把表缺省当成意图去和事实比,合法的无旗重跑就被拒;
307
+ # 反过来若只比不拒,保留的多租 `.env` 在缺省 single 下会被车道表判成 host —— 容器拿到 REQUIRE_PRINCIPAL=true ∧ host。
308
+ # 租户形 ⇒ 容器上 REQUIRE_PRINCIPAL 的值 —— 本脚本内**唯一**一处这条翻译。正向(生成段 setk、事实核对)与反向
309
+ # (事实 → --users 缺省)都经它:反向是拿两个租户词逐个过这只函数去比,不另写一张反表(与车道的 `lanes choice-of`
310
+ # 同一姿势)。⚠️ kube 路径另有一处同义翻译(`kube-up.sh` 的 V_REQ_PRINCIPAL,v7.95.1 既有,登记 DEBTS)。
311
+ principal_of_users() { [ "$1" = "multi" ] && echo true || echo false; }
312
+ USERS_DEF="single" RP_FACT="" RP_FACT_SRC=""
313
+ case "$PROFILE" in
314
+ full-single|local-plus)
315
+ deploy_fact REQUIRE_PRINCIPAL; RP_FACT="$FACT_VAL" RP_FACT_SRC="$FACT_SRC"
316
+ if [ -n "$RP_FACT" ]; then
317
+ USERS_DEF=""
318
+ for _u in single multi; do [ "$(principal_of_users "$_u")" = "$RP_FACT" ] && USERS_DEF="$_u"; done
319
+ if [ -z "$USERS_DEF" ]; then
320
+ log "⛔ 部署事实 REQUIRE_PRINCIPAL=$RP_FACT(来自 $RP_FACT_SRC):本脚本只读自己写出的两形 $(principal_of_users multi) / $(principal_of_users single)"
321
+ log " ↳ 下一步:改成 REQUIRE_PRINCIPAL=$(principal_of_users multi)(多人,身份隔离)或 REQUIRE_PRINCIPAL=$(principal_of_users single)(单人),再重跑"
322
+ log " ↳ 为什么不认别的写法:它们在 server 那边也许同样是布尔,但在这里认它们就得抄一份 server 的布尔词表,两份词表迟早分叉"
323
+ exit 1
324
+ fi
325
+ fi
326
+ ;;
327
+ esac
328
+ [ -z "$USERS" ] && USERS=$(ask users "几个人用?" "$USERS_DEF" "单人可跳过全部权限配置,多人要开身份隔离" \
220
329
  "single:就我一个(超级管理员 turnkey,零权限配置)" \
221
330
  "multi:一个团队(身份隔离 ON,能力默认全开,之后可用 policy 收紧)")
331
+ REQUIRE_PRINCIPAL_WANT="$(principal_of_users "$USERS")"
332
+ fact_conflict REQUIRE_PRINCIPAL "$RP_FACT" "$RP_FACT_SRC" "$REQUIRE_PRINCIPAL_WANT" "--users=$USERS"
222
333
 
223
334
  # registry:各档默认带上(clay 2026-07-12 拍;显式 --registry=false 可关)。
224
335
  # local-plus 例外默认 false——它的数据面在远端,配置中心应随数据面部署,本机再起一个=两个配置中心。
@@ -237,16 +348,63 @@ if [ "$GIT_MODE" = "connect" ]; then
237
348
  [ -z "$GIT_TOKEN" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ --git=connect 需要 --git-token(该服务的 access token)"; exit 1; } || GIT_TOKEN=$(ask git_token "该服务的 access token?" "" "验证连通用;Gitea 还会用它自动建 OAuth 登录应用"); }
238
349
  fi
239
350
 
240
- if [ "$PROFILE" = "full-single" ] && [ -z "$SANDBOX_LANE" ]; then
241
- if [ "$KVM_OK" = "true" ]; then
242
- SANDBOX_LANE=$(ask sandbox "agent 跑代码用哪种沙箱?" "kata" "本机有 KVM,VM 级隔离可用" \
243
- "kata:Kata VM(默认建议,VM 级隔离)" "e2b-cloud:E2B 云沙箱(填 key 即用)" "runc:容器隔离(弱于 VM,不建议生产)")
244
- else
245
- SANDBOX_LANE=$(ask sandbox "agent 跑代码用哪种沙箱?" "e2b-cloud" "本机无 KVM,kata 不可行" \
246
- "kata:Kata VM:infeasible=本机无虚拟化支持(/dev/kvm 缺失)|云上开嵌套虚拟化/换 bare metal,或选 e2b" \
247
- "e2b-cloud:E2B 云沙箱(填 key,海外快)" "runc:容器隔离(弱于 VM,不建议生产)")
248
- fi
249
- fi
351
+ # ── 执行车道(S-599;单一属主 = lib/lanes.json,本脚本里零车道词字面量)──────────────────────
352
+ # 病:compose 路径的 `kata` / `runc` 对**执行运行时零接线**(有 KVM 时 kata 还是默认值)⇒ .env 里
353
+ # 压根不出现 `REMOTE_EXEC` ⇒ core 落 StubExecutionEnv ⇒ 起来的 worker 一只手都没有,而三条部署
354
+ # 路径没有任何一处会红。现在:词表 / 默认 / 退役指路 / 落 .env 的键值**全部**从表渲,拒一律非零退出。
355
+ # 位置刻意在模型面(gateway/model)**之前**:选错车道时失败原因要来自车道门,不是「你还没填网关」。
356
+ LANE_ENV="" LANE_REQUIRED="" LANE_REMOTE_EXEC=""
357
+ case "$PROFILE" in
358
+ full-single|local-plus)
359
+ # 同一条规则的车道半场(见租户形块):决策 = --sandbox ∨ 部署事实(REMOTE_EXEC 反查成车道词)∨ 表缺省。
360
+ # 租户形已在上面定死(事实或显式旗),所以这里 `lanes default "$USERS"` 读到的是容器真正会拿到的租户形 ——
361
+ # 表里「multi 不许 host」那条规则的输入是真的。
362
+ deploy_fact REMOTE_EXEC; RE_FACT="$FACT_VAL" RE_FACT_SRC="$FACT_SRC" RE_FACT_CHOICE=""
363
+ if [ -n "$RE_FACT" ]; then RE_FACT_CHOICE="$(lanes choice-of "$RE_FACT")" || exit 1; fi
364
+ if [ -z "$SANDBOX_LANE" ]; then
365
+ if [ -n "$RE_FACT_CHOICE" ]; then LANE_DEF="$RE_FACT_CHOICE"; else LANE_DEF="$(lanes default "$USERS")" || exit 1; fi
366
+ LANE_OPTS=()
367
+ while IFS= read -r _o; do [ -n "$_o" ] && LANE_OPTS+=("$_o"); done < <(lanes options "$USERS")
368
+ SANDBOX_LANE=$(ask sandbox "agent 跑代码用哪种沙箱?" "$LANE_DEF" \
369
+ "决定 agent 的手落在哪里——不点名车道就是**没有手**(工具面整排关)" \
370
+ ${LANE_OPTS[@]+"${LANE_OPTS[@]}"})
371
+ fi
372
+ LANE_ENV="$(lanes resolve "$USERS" "$SANDBOX_LANE")" || exit 1
373
+ LANE_REQUIRED="$(printf '%s\n' "$LANE_ENV" | sed -n 's/^#REQUIRED //p')"
374
+ LANE_ENV="$(printf '%s\n' "$LANE_ENV" | { grep -v '^#REQUIRED ' || true; })"
375
+ LANE_REMOTE_EXEC="$(printf '%s\n' "$LANE_ENV" | sed -n 's/^REMOTE_EXEC=//p')"
376
+ fact_conflict REMOTE_EXEC "$RE_FACT" "$RE_FACT_SRC" "$LANE_REMOTE_EXEC" "--sandbox=$SANDBOX_LANE"
377
+ # 选了车道却没给它的必需键 ⇒ 当场拒。否则 .env 照写、容器起来才在 server 的执行车道拒启门上被拒,
378
+ # 运维要翻容器日志才看得到「缺 X」,而那个键在 sema-up 的对话里从没被问过。
379
+ for _k in $LANE_REQUIRED; do
380
+ eval "_v=\${$_k:-}"
381
+ _ev="$(envf_get "$_k")" || exit 1
382
+ if [ -z "$_v" ] && [ -z "$_ev" ]; then
383
+ log "⛔ --sandbox=$SANDBOX_LANE(车道 $LANE_REMOTE_EXEC)需要 $_k,但 -f 配置 / 环境 / $ENVF 里都没有"
384
+ log " ↳ 下一步:-f 配置里加 $_k=…(或 export $_k=…)后重跑;换车道见 --help 的 --sandbox 词表"
385
+ exit 1
386
+ fi
387
+ done
388
+ ;;
389
+ full-cluster)
390
+ # kube 路径**不选词**:runtime 由 kube-up.sh 按 runtimeClass `kata-qemu` 在不在自动定。收到
391
+ # `--sandbox`(或 -f 里的 SANDBOX_LANE)⇒ 响亮拒,不静默忽略 —— 一个「设了却没有任何效果」的
392
+ # 旋钮正是本批要消灭的那类病。
393
+ if [ -n "$SANDBOX_LANE" ]; then
394
+ log "⛔ --profile=full-cluster(kube 路径)不选沙箱词,收到 --sandbox=$SANDBOX_LANE"
395
+ log " ↳ 下一步:删掉 --sandbox / -f 里的 SANDBOX_LANE。kube 路径的 runtime 按 runtimeClass kata-qemu 在不在自动定(在 ⇒ kata VM,不在 ⇒ runc),车道恒 REMOTE_EXEC=k8s"
396
+ exit 1
397
+ fi
398
+ ;;
399
+ *)
400
+ # data-center:不启 agent ⇒ 没有执行车道可言;收到 --sandbox 同样响亮拒(同上一条理由)。
401
+ if [ -n "$SANDBOX_LANE" ]; then
402
+ log "⛔ --profile=$PROFILE 不启 agent,没有执行车道;收到 --sandbox=$SANDBOX_LANE"
403
+ log " ↳ 下一步:删掉 --sandbox;要跑 agent 用 --profile=full-single 或 local-plus"
404
+ exit 1
405
+ fi
406
+ ;;
407
+ esac
250
408
  if [ "$PROFILE" = "full-single" ] || [ "$PROFILE" = "local-plus" ]; then
251
409
  [ -z "$GATEWAY" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ $PROFILE 档要跑 agent,需要 --gateway/--model(模型面)。例:--gateway=https://api.deepseek.com/v1 --model=deepseek-v4-flash --model-key=sk-…"; exit 1; } || GATEWAY=$(ask gateway "模型网关 baseURL?(OpenAI 兼容,如 https://api.deepseek.com/v1)" "" "引擎的大脑入口,没有它 agent 无法思考"); }
252
410
  [ -z "$MODEL" ] && MODEL=$(ask model "模型 id?" "deepseek-v4-flash" "按成本推荐 flash;要更强推理换 pro")
@@ -349,7 +507,7 @@ if [ "$REGION" = "auto" ]; then
349
507
  REGION=$(awk -v a="$t_cn" -v b="$t_gl" 'BEGIN{print (a<b)?"cn":"global"}')
350
508
  fi
351
509
  HOSTIP=$(host_ip)
352
- log "region=$REGION profile=$PROFILE users=$USERS sandbox=${SANDBOX_LANE:-n/a} registry=$REGISTRY_ENABLED git=$GIT_MODE"
510
+ log "region=$REGION profile=$PROFILE users=$USERS sandbox=${SANDBOX_LANE:-n/a} lane=${LANE_REMOTE_EXEC:-n/a} registry=$REGISTRY_ENABLED git=$GIT_MODE"
353
511
 
354
512
  # ── 数据盘选择(多盘机自动选大容量盘;--data-root 显式优先;macOS 不自动——bind 要经 VM 挂载,
355
513
  # 显式给了才用)。首装落 .env 后不漂移:换盘=先 --teardown-volumes 迁数据再重装。──────────────
@@ -380,8 +538,6 @@ case "$PROFILE" in full-single|local-plus) COMPOSE_PROFILES="$COMPOSE_PROFILES,a
380
538
  [ "$REGISTRY_ENABLED" = "true" ] && COMPOSE_PROFILES="$COMPOSE_PROFILES,registry"
381
539
  [ "$GIT_MODE" = "provision" ] && COMPOSE_PROFILES="$COMPOSE_PROFILES,git"
382
540
  COMPOSE_PROFILES="${COMPOSE_PROFILES#,}"; COMPOSE_PROFILES="${COMPOSE_PROFILES%,}"
383
- ENVF="$WORK/.env"
384
- has_env() { [ -f "$ENVF" ] && grep -q "^$1=..*" "$ENVF"; }
385
541
  case ",$COMPOSE_PROFILES," in *,agent,*)
386
542
  if [ -z "${SEMA_SERVER_IMAGE:-}" ] && ! has_env SEMA_SERVER_IMAGE; then
387
543
  log "⛔ $PROFILE 档需要 sema-server 镜像。下一步:-f 配置里加 SEMA_SERVER_IMAGE=<registry>/sema-server:<tag>(默认=ghcr.io/sema-agent/sema-server:<tag> 公开仓匿名可拉;CN 兜底=内网 Gitea 仓;SWR 已弃用);镜像分发=D4"
@@ -436,11 +592,25 @@ PG_TUNE_FLAGS="-c max_connections=100 -c shared_buffers=${SB}MB -c effective_cac
436
592
  # ── .env 生成(幂等:已存在则只补缺,凭据绝不轮换;0600) ───────────────────────────────────
437
593
  genpw() { openssl rand -hex 24; }
438
594
  touch "$ENVF"; chmod 600 "$ENVF"
439
- setk() { grep -q "^$1=" "$ENVF" || printf '%s=%s\n' "$1" "$2" >> "$ENVF"; }
595
+ # ── .env 的**唯一**写口:一次写入恰好一个物理行 ────────────────────────────────────────────────
596
+ # 病(7.96.0 修复验证轮 [high]):`--model-key=$'k\nREQUIRE_PRINCIPAL=true'` 经旧 setk 原样写成两行 —— 第二行是
597
+ # 决策检查**之后**才出现在 .env 里的一条 REQUIRE_PRINCIPAL,紧接着的 `setk REQUIRE_PRINCIPAL` 见键在就不写 ⇒
598
+ # 容器拿到多租 + host。严格读口只管「读」;写口与它是同一条约定的两半:.env 一行一条赋值。值里带换行 / 回车
599
+ # ⇒ **写之前**拒(先判后动:resetk 的删旧行也排在判定之后),拒因不回显值(可能是凭据)。
600
+ envf_check() {
601
+ case "$2" in
602
+ *$'\n'*|*$'\r'*)
603
+ log "⛔ 要写进 $ENVF 的 $1 的值里有换行 / 回车 —— .env 一行一条赋值,换行后的内容会被 compose 当成另一条赋值"
604
+ log " ↳ 下一步:去掉值里的换行(查 $1 对应的命令行参数或 -f 配置项)后重跑"
605
+ exit 1 ;;
606
+ esac
607
+ }
608
+ envf_put() { envf_check "$1" "$2"; printf '%s=%s\n' "$1" "$2" >> "$ENVF"; }
609
+ setk() { grep -q "^$1=" "$ENVF" || envf_put "$1" "$2"; }
440
610
  # setk_new:同 setk,但新写入时返回 0(summary 用来判断「密码只显示一次」)
441
- setk_new() { if grep -q "^$1=" "$ENVF"; then return 1; else printf '%s=%s\n' "$1" "$2" >> "$ENVF"; return 0; fi; }
442
- # resetk:非凭据键每次重写(翻转要落 .env,不然 teardown/汇总口径漂)
443
- resetk() { grep -v "^$1=" "$ENVF" > "$ENVF.tmp" || true; mv "$ENVF.tmp" "$ENVF"; chmod 600 "$ENVF"; printf '%s=%s\n' "$1" "$2" >> "$ENVF"; }
611
+ setk_new() { if grep -q "^$1=" "$ENVF"; then return 1; else envf_put "$1" "$2"; return 0; fi; }
612
+ # resetk:非凭据键每次重写(翻转要落 .env,不然 teardown/汇总口径漂)。先判后删:拒了就一行都不动。
613
+ resetk() { envf_check "$1" "$2"; grep -v "^$1=" "$ENVF" > "$ENVF.tmp" || true; mv "$ENVF.tmp" "$ENVF"; chmod 600 "$ENVF"; envf_put "$1" "$2"; }
444
614
  setk SEMA_PROJECT "$PROJECT"
445
615
  setk PG_PASSWORD "$(genpw)"
446
616
  setk MINIO_PASSWORD "$(genpw)"
@@ -452,16 +622,23 @@ setk SERVER_PORT "$SERVER_PORT"
452
622
  # data-center 数据面对外服务(agent 在别处连);full-single 数据面仅回环、只有 server 对外
453
623
  if [ "$PROFILE" = "data-center" ]; then setk PG_BIND "0.0.0.0"; setk MINIO_BIND "0.0.0.0"; else setk PG_BIND "127.0.0.1"; setk MINIO_BIND "127.0.0.1"; fi
454
624
  # PG_TUNE_FLAGS 每次重算(规格可能变;非凭据,轮换无害);引号:source 与 compose env-file 双方兼容
455
- grep -v '^PG_TUNE_FLAGS=' "$ENVF" > "$ENVF.tmp" && mv "$ENVF.tmp" "$ENVF"; chmod 600 "$ENVF"
456
- printf 'PG_TUNE_FLAGS="%s"\n' "$PG_TUNE_FLAGS" >> "$ENVF"
625
+ resetk PG_TUNE_FLAGS "\"$PG_TUNE_FLAGS\""
457
626
  if [ "$PROFILE" = "full-single" ] || [ "$PROFILE" = "local-plus" ]; then
458
627
  setk MODEL_GATEWAY_BASEURL "$GATEWAY"
459
628
  setk MODEL_ID "$MODEL"
460
629
  [ -n "$MODEL_KEY" ] && setk MODEL_API_KEY "$MODEL_KEY"
461
- setk REQUIRE_PRINCIPAL "$([ "$USERS" = "multi" ] && echo true || echo false)"
630
+ setk REQUIRE_PRINCIPAL "$REQUIRE_PRINCIPAL_WANT"
462
631
  setk SANDBOX_PKG_SOURCE "$([ "$REGION" = "global" ] && echo global || echo '')"
463
632
  [ -n "${SEMA_SERVER_IMAGE:-}" ] && setk SEMA_SERVER_IMAGE "$SEMA_SERVER_IMAGE"
464
- [ "${SANDBOX_LANE:-}" = "e2b-cloud" ] && setk REMOTE_EXEC e2b
633
+ # 执行车道落 .env(S-599:从上面 `lanes resolve` 的结果渲,脚本里零车道词)。
634
+ # **resetk 不是 setk**:`setk` 见键在就不改,而升级路径上最常见的既有值恰是空串 `REMOTE_EXEC=`
635
+ # (旧 compose 的 `${REMOTE_EXEC:-}` 渲出来的)——用 setk 就永远修不好那批无手部署。冲突检测在
636
+ # 上面的车道块里已做过(非空且不同 ⇒ 早已拒),所以这里的重写只会落在「空串 / 同值」两种情形上。
637
+ while IFS='=' read -r _k _v; do
638
+ [ -n "$_k" ] && resetk "$_k" "$_v"
639
+ done <<EOF_LANE
640
+ $LANE_ENV
641
+ EOF_LANE
465
642
  fi
466
643
  if [ "$PROFILE" = "local-plus" ]; then
467
644
  setk AGENT_PG_HOST "$AGENT_PG_HOST"
@@ -94,7 +94,11 @@ services:
94
94
  MODEL_API_KEY: ${MODEL_API_KEY:-}
95
95
  REQUIRE_PRINCIPAL: ${REQUIRE_PRINCIPAL:-false}
96
96
  SANDBOX_PKG_SOURCE: ${SANDBOX_PKG_SOURCE:-}
97
+ # 执行车道:值由 sema-up.sh 从 lib/lanes.json 渲进 .env(resetk,每次重写)。
98
+ # `REQUIRE_EXECUTION_LANE` 缺省 true —— 手工起这份 compose 而没点名车道时,server 拒启
99
+ # 并说「没有手」,而不是安静地起成一个什么都干不了的 worker(S-599)。
97
100
  REMOTE_EXEC: ${REMOTE_EXEC:-}
101
+ REQUIRE_EXECUTION_LANE: ${REQUIRE_EXECUTION_LANE:-true}
98
102
  E2B_API_KEY: ${E2B_API_KEY:-}
99
103
  ports:
100
104
  - "${SERVER_BIND:-0.0.0.0}:${SERVER_PORT:-8090}:8090"
@@ -171,6 +171,8 @@ spec:
171
171
  - { name: MODEL_VISION, value: "false" }
172
172
  - { name: SANDBOX_PKG_SOURCE, value: "__PKG_SOURCE__" }
173
173
  - { name: REMOTE_EXEC, value: k8s }
174
+ # S-599:显式表态「这台有手」(车道被改没了 ⇒ 拒启,不是安静的无手 worker)
175
+ - { name: REQUIRE_EXECUTION_LANE, value: "true" }
174
176
  - { name: K8S_API_URL, value: "https://kubernetes.default.svc" }
175
177
  - { name: K8S_INSECURE_TLS, value: "true" }
176
178
  - { name: K8S_NAMESPACE, value: sema-sandbox }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { type ToolPolicy } from "@sema-agent/core";
7
7
  import { type PostureSource } from "./posture-source.js";
8
+ import { type ExecutionLane } from "./execution-lane-caps.js";
8
9
  /**
9
10
  * 运维**是否表达了门意图** —— boot 的单用户 allow-all 基线只在"零门意图"时才允许铺开,
10
11
  * 所以这个谓词漏一格 = 那一格的意图被 allow-all 静默吞掉。
@@ -39,6 +40,33 @@ export declare function assertGateIntentServiceable(config: {
39
40
  durableApproval: boolean;
40
41
  durableApprovalSource: PostureSource;
41
42
  }, checkpointStorePresent: boolean): void;
43
+ /**
44
+ * S-602 C3 —— 「**无应答面**」两条 boot warn 的判据与文案(纯函数;stage-07 只负责打日志)。
45
+ *
46
+ * 两条 warn 警的是同一类失败:一个 `ask` 发生了,而这台部署既没有活体协调器(`TOOL_APPROVAL_ENABLED`)也没有
47
+ * durable 停车(`DURABLE_APPROVAL`)⇒ core `resolveAsk` 无 onAsk ⇒ headless 自动拒(fail-closed,写进转录)。
48
+ * 运维要在 **boot** 就知道,否则会把随之而来的拒绝读成故障。两条 warn 按「ask 从哪来」**划分车道**,互补不重叠:
49
+ * · `fs_write_gate_no_responder` —— 写门的 Write/Edit ask。它只在**手写本机盘**时才会发生(写门挂在 host
50
+ * 语义上,而 ask 要有手去触发)⇒ 判据 = {@link handsOnWorkerFilePlane}(今天 = `host`)。改前写的是
51
+ * `remoteExec === undefined || provider === "host"`(门 D R3 同义折):把没有手的 in-process 也算进来,
52
+ * 那一形上一次 Write ask 都不会发生,warn 是假话;
53
+ * · `workflow_gate_no_responder` —— `run_workflow` 的 ask,**每条车道都挂**(不以挂手为前提,见
54
+ * `createWorkflowAskPolicy`)⇒ 判据 = 自编排开着 ∧ **不在上一条的射程里**(上一条的文案已覆盖本机手的车道)。
55
+ * 改前写的是 `remoteExec !== undefined && provider !== "host"` —— 依赖上一条对 in-process 的兜底;
56
+ * 上一条收窄之后,in-process ∧ 自编排 ∧ 无应答面必须由这一条接住(否则整形零告警)。
57
+ */
58
+ export declare function buildNoResponderBootWarns(input: {
59
+ /** 本部署的执行车道(归一读口 `executionLaneOf` 的答案)。 */
60
+ lane: ExecutionLane;
61
+ selfOrchestrationEnabled: boolean;
62
+ /** 活体协调器 ∨ durable 停车 —— 任何一面在场,ask 都有人答。 */
63
+ approvalFaceWired: boolean;
64
+ }): Array<{
65
+ event: "fs_write_gate_no_responder" | "workflow_gate_no_responder";
66
+ fields: {
67
+ hint: string;
68
+ };
69
+ }>;
42
70
  /**
43
71
  * [ref] 件⑩ —— **center 的 `forceDurableGate` 在无 checkpoint 店的部署上会静默 inert**,补一条 boot 披露。
44
72
  *
package/dist/approval.js CHANGED
@@ -2,6 +2,7 @@ import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "@sema-ag
2
2
  import { operatorAsk } from "./operator-ask.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "./approval-content-kind.js";
4
4
  import { isExplicitPosture } from "./posture-source.js";
5
+ import { handsOnWorkerFilePlane } from "./execution-lane-caps.js";
5
6
  export function hasOperatorGateIntent(config) {
6
7
  return (config.approvalRequire.length > 0 ||
7
8
  config.approvalDeny.length > 0 ||
@@ -16,6 +17,29 @@ export function assertGateIntentServiceable(config, checkpointStorePresent) {
16
17
  }
17
18
  throw new Error("DURABLE_APPROVAL=true but no checkpoint store is available — the configured DB backend has no checkpoint face (or no backend is configured). Wire a MySQL-protocol/PostgreSQL/file backend, or unset DURABLE_APPROVAL.");
18
19
  }
20
+ export function buildNoResponderBootWarns(input) {
21
+ if (input.approvalFaceWired)
22
+ return [];
23
+ const handsOnThisDisk = handsOnWorkerFilePlane(input.lane);
24
+ const out = [];
25
+ if (handsOnThisDisk) {
26
+ out.push({
27
+ event: "fs_write_gate_no_responder",
28
+ fields: {
29
+ hint: "host-semantics lane with no approval face: permissionMode default/auto Write/Edit asks will auto-deny (fail-closed); wire TOOL_APPROVAL_ENABLED=true or DURABLE_APPROVAL=true to answer them",
30
+ },
31
+ });
32
+ }
33
+ if (input.selfOrchestrationEnabled && !handsOnThisDisk) {
34
+ out.push({
35
+ event: "workflow_gate_no_responder",
36
+ fields: {
37
+ hint: "self-orchestration with no approval face: permissionMode default/auto/acceptEdits run_workflow asks will auto-deny (fail-closed); wire TOOL_APPROVAL_ENABLED=true or DURABLE_APPROVAL=true to answer them",
38
+ },
39
+ });
40
+ }
41
+ return out;
42
+ }
19
43
  export function buildForceDurableGateInertNotice(input) {
20
44
  if (!input.entitlementSourceWired || input.checkpointStorePresent)
21
45
  return undefined;
@@ -43,5 +43,6 @@ export declare function createBudgetAndTracing(ctx: BudgetTracingCtx): {
43
43
  toolResultStore: import("../plugins/store-backend.js").ToolResultStoreFull | undefined;
44
44
  sessionPolicyStore: import("@sema-agent/core").SessionPolicyStore | undefined;
45
45
  fileHistoryStore: import("../plugins/store-backend.js").ServiceFileHistoryStore | undefined;
46
+ fileHistoryErasure: import("../file-history-capture.js").FileHistoryErasureFace | undefined;
46
47
  };
47
48
  //# sourceMappingURL=budget-tracing.d.ts.map
@@ -4,6 +4,7 @@ import { FleetUsageAccumulator } from "../fleet-client.js";
4
4
  import { createFleetLeaseFromEnv } from "../fleet-lease.js";
5
5
  import { CostQuota } from "../observability/cost-quota.js";
6
6
  import { buildQuotaBounds } from "./limit-sync.js";
7
+ import { assembleFileHistory } from "../file-history-capture.js";
7
8
  export function createBudgetAndTracing(ctx) {
8
9
  const { config, logger, metrics, backend, breakerState } = ctx;
9
10
  const brain = createBrain(config, { breakerState });
@@ -29,10 +30,10 @@ export function createBudgetAndTracing(ctx) {
29
30
  const sideQueryAccounting = createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetLease, (m) => config.modelQuotaWeights[m] ?? 1);
30
31
  const toolResultStore = backend?.toolResult ? backend.toolResult() : undefined;
31
32
  const sessionPolicyStore = backend ? backend.sessionPolicy() : undefined;
32
- const fileHistoryStore = backend ? backend.fileHistory() : undefined;
33
+ const { store: fileHistoryStore, erasure: fileHistoryErasure } = assembleFileHistory(backend, config.fileHistoryCapture);
33
34
  return {
34
35
  brain, pricing, counterDegradeHook, costQuota, modelUsageTracker, promptManifestTracker,
35
- fleetUsage, fleetLease, tracer, sideQueryAccounting, toolResultStore, sessionPolicyStore, fileHistoryStore,
36
+ fleetUsage, fleetLease, tracer, sideQueryAccounting, toolResultStore, sessionPolicyStore, fileHistoryStore, fileHistoryErasure,
36
37
  };
37
38
  }
38
39
  //# sourceMappingURL=budget-tracing.js.map
@@ -388,11 +388,8 @@ export async function createConfigCenterRuntime(ctx) {
388
388
  try {
389
389
  const fileRx = await loadRemoteExec(localRoot);
390
390
  if (fileRx) {
391
- const preemptedLane = config.remoteExec?.provider ?? "in-process";
392
- const isolatedLane = (lane) => {
393
- const word = executionLaneOf(lane === "in-process" ? undefined : lane);
394
- return word !== undefined && executionLaneCaps(word).isolation === "supported";
395
- };
391
+ const preemptedLane = executionLaneOf(config.remoteExec?.provider);
392
+ const isolatedLane = (lane) => executionLaneCaps(lane).isolation === "supported";
396
393
  const disclosePreemption = (winner) => {
397
394
  if (winner === preemptedLane)
398
395
  return;
@@ -7,6 +7,7 @@ import { createSandboxFileSend, TaskEnvRegistry } from "../capabilities/sandbox-
7
7
  import { createSendUserFileIssuer } from "../plugins/send-user-file.js";
8
8
  import { withLedgerRecording } from "../plugins/send-file-ledger.js";
9
9
  import { basename, resolve } from "node:path";
10
+ import { toolsRunOnThisHost } from "../task-cwd.js";
10
11
  import { stat as fsStat, readFile as fsReadFile } from "node:fs/promises";
11
12
  export function createLiveCoordinators(ctx) {
12
13
  const { config, logger, backend, sendUserFileTaskEnvs, ruleConsent, ruleScopeRootFor, getParkedRedeem } = ctx;
@@ -75,8 +76,7 @@ export function createLiveCoordinators(ctx) {
75
76
  return undefined;
76
77
  if (sendUserFileTaskEnvs)
77
78
  return new SendUserFileEmitter();
78
- const provider = config.remoteExec?.provider ?? "host";
79
- if (provider === "host" && config.requirePrincipal !== true)
79
+ if (toolsRunOnThisHost(config) && config.requirePrincipal !== true)
80
80
  return new SendUserFileEmitter();
81
81
  return undefined;
82
82
  })();
@@ -46,8 +46,11 @@
46
46
  * 该面由 test/task-settings.test.ts 的「真身胜过名字」特征化钉机器可见。
47
47
  */
48
48
  import { FileError, StubExecutionEnv, type ExecutionEnv, type ExecutionEnvFactory, type FileInfo, type Result } from "@sema-agent/core";
49
- /** 哪些 lane 用本模块的代理裁决写门 —— `REMOTE_EXEC` 未设(进程内 host)与显式 `host` 之外的全部。
50
- * 与 resolve-spec 的 `hostSemanticsLane` 是同一判别式的两面,取值处**只此一个**(两处各写一份正是漂移的成因)。 */
49
+ /** 哪些 lane 用本模块的代理裁决写门 —— 文件平面**不在** worker 本机的全部(= 能力表 `hostFilePlane` 答否)。
50
+ * 与 resolve-spec 的 `hostSemanticsLane` 是同一判别式的两面,取值处**只此一个**(两处各写一份正是漂移的成因)。
51
+ * 🔴 S-602:改前写的是 `provider !== undefined && provider !== "host"` —— 把缺席并进 host 的同义折(门 D R3),
52
+ * 今天改读表那一位(`in-process` / `host` 答 YES,其余答 NO;读数逐格不变)。认不出的词 ⇒ 走代理裁决:
53
+ * 代理 slot 空 ⇒ 写门 fail-closed(本模块头注裁定 2),与改前同向。 */
51
54
  export declare function isSandboxPathAdjudicationLane(provider: string | undefined): boolean;
52
55
  /** slot 取值的三态。`ok:false` 的两支都是 fail-closed,分开只为让留痕说得出**哪一种**缺席。 */
53
56
  export type SandboxPathEnvLookup = {
@@ -1,7 +1,9 @@
1
1
  import { FileError, StubExecutionEnv, err, ok } from "@sema-agent/core";
2
2
  import { normalizeAbsolutePathLexically } from "../task-cwd.js";
3
+ import { executionLaneCaps, executionLaneOf } from "../execution-lane-caps.js";
3
4
  export function isSandboxPathAdjudicationLane(provider) {
4
- return provider !== undefined && provider !== "host";
5
+ const lane = executionLaneOf(provider);
6
+ return lane === undefined || executionLaneCaps(lane).hostFilePlane !== "supported";
5
7
  }
6
8
  const MAX_BOUND_SESSIONS = 4096;
7
9
  export class SandboxPathEnvSlots {
@@ -54,6 +54,7 @@
54
54
  * `TaskSpec.memoryPersistenceCapable` 一条路进去(`boot/resolve-spec.ts` 直通),我方显式**不铸**受限会话、
55
55
  * 也**不投**这个读面(硬造一个消费点 = 造给自己看的 wire 键)。哪天真要在诊断面露它,先有消费方需求。
56
56
  */
57
+ import { type ExecutionLane, type RemoteExecProvider } from "../execution-lane-caps.js";
57
58
  /**
58
59
  * [ref] A3 源头修(黑板 [ref]/[ref]/[ref],[ref])—— `memoryPersistenceCapable` 的**装配层有效值**:
59
60
  * operator 显式 env 恒赢;env 缺席时,`REMOTE_EXEC=host` 腿自动补 `true`;其余一律缺席(现行为)。
@@ -93,8 +94,8 @@ export interface MemoryWriteBoundaryFacts {
93
94
  * core 7.16.0([ref])之后写家还要再深一层(`<平面>/<label>`),但 label 归引擎注册簿,boot 期说不出口;
94
95
  * 平面是**包含关系上等价**的那一只(家恒为平面的直接子目录),所以判据停在平面,不猜 label。 */
95
96
  memoryRoot: string | undefined;
96
- /** 执行车道名,只进文案(`config.remoteExec?.provider ?? "in-process"`)。 */
97
- lane: string;
97
+ /** 执行车道名,只进文案(归一读口 `executionLaneOf` 的答案;S-602 起不在调用点手写 `?? "in-process"`)。 */
98
+ lane: ExecutionLane;
98
99
  /** 该车道的文件工具是否跑在**本机** fs 上(in-process / `REMOTE_EXEC=host`)。false = 沙箱盘,与记忆根分平面。
99
100
  * ⚠️ 这一位管的是**文件平面**,与下面的 `coreRemoteExecutionEnv` 是**两根轴**,别合并(codex round2
100
101
  * [medium] 就是把它们当成一根轴的后果):`host` 车道的文件平面就是本机(这一位 true),但 core 眼里它
@@ -116,6 +117,20 @@ export interface MemoryWriteBoundaryFacts {
116
117
  /** boot 期算得出的文件工具围栏根。空数组 = 算不出(每任务临时目录 / 未配 workspace)⇒ 不判。 */
117
118
  containmentRoots: readonly string[];
118
119
  }
120
+ /**
121
+ * S-602 —— 审计的**车道那四位**由本函数从能力表派生(boot/stores.ts 的装配点只调它,不再就地手写):
122
+ * 改前 stores 里是 `lane === undefined || lane === "host"`(同义折,门 D R3)+ `config.remoteExec !== undefined`
123
+ * (座位上的裸缺席判,R4)+ `lane ?? "in-process"` ×3(R2)。两根轴各读表里各自那一位,不合并:
124
+ * · `hostSemanticsLane` ⇐ `hostFilePlane`(文件平面在不在本机);
125
+ * · `coreRemoteExecutionEnv` ⇐ 同名位(core 拿没拿到执行环境)。
126
+ * 读数逐车道不变(对账:`test/execution-lane-caps.test.ts` 的真装配点联合覆盖格)。`processCwd` 由调用点传
127
+ * (stores 传 `process.cwd()`),本函数保持纯。
128
+ */
129
+ export declare function buildMemoryBoundaryLaneFacts(config: {
130
+ readonly remoteExec?: {
131
+ readonly provider: RemoteExecProvider;
132
+ } | undefined;
133
+ }, processCwd: string): Pick<MemoryWriteBoundaryFacts, "lane" | "hostSemanticsLane" | "coreRemoteExecutionEnv" | "containmentRoots">;
119
134
  /** 一条启动告警:`tag` 进日志事件名(可被运维按名过滤/告警),`detail` 是给人读的整句(含恢复路径)。 */
120
135
  export interface MemoryWriteBoundaryWarning {
121
136
  tag: "memory_root_outside_write_boundary" | "memory_remote_lane_write_instruction_dropped";
@@ -1,9 +1,20 @@
1
1
  import { resolve } from "node:path";
2
+ import { executionLaneCaps, executionLaneOf } from "../execution-lane-caps.js";
2
3
  export function effectiveMemoryPersistenceCapable(config) {
3
4
  if (config.memoryPersistenceCapable !== undefined)
4
5
  return config.memoryPersistenceCapable;
5
6
  return config.remoteExec?.provider === "host" ? true : undefined;
6
7
  }
8
+ export function buildMemoryBoundaryLaneFacts(config, processCwd) {
9
+ const lane = executionLaneOf(config.remoteExec?.provider);
10
+ const caps = executionLaneCaps(lane);
11
+ return {
12
+ lane,
13
+ hostSemanticsLane: caps.hostFilePlane === "supported",
14
+ coreRemoteExecutionEnv: caps.coreRemoteExecutionEnv === "supported",
15
+ containmentRoots: lane === "in-process" ? [processCwd] : [],
16
+ };
17
+ }
7
18
  function isInside(child, root) {
8
19
  const c = resolve(child);
9
20
  const r = resolve(root);
@@ -20,6 +20,7 @@ import type { Logger } from "../observability/logger.js";
20
20
  import type { Metrics } from "../observability/metrics.js";
21
21
  import type { WorkflowNotifyGate, WorkflowNotifyJournalStore } from "../orchestration/workflow-notify-journal.js";
22
22
  import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
23
+ import type { FileHistoryErasureFace } from "../file-history-capture.js";
23
24
  import type { ApprovalNonceStore, CheckpointStoreFull, CostQuotaStore, ImageBake, RateLimiterStore, ServiceWorkflowJournalStore, StoreBackend, ToolResultStoreFull } from "../plugins/store-backend.js";
24
25
  import type { ToolApprovalCoordinator } from "../tool-approval.js";
25
26
  /** [ref] / [ref]§五(sema-comms `audits/failopen-governance-176.md` §7 "Z 类 1 条"):default consecutive-failure
@@ -69,7 +70,8 @@ export interface ReapersCtx {
69
70
  rateLimiter: RateLimiter | RateLimiterStore | undefined;
70
71
  costQuota: CostQuota | CostQuotaStore | undefined;
71
72
  toolResultStore: ToolResultStoreFull | undefined;
72
- fileHistoryStore: ReturnType<StoreBackend["fileHistory"]> | undefined;
73
+ /** S-589:文件历史的**擦除脸**(后端在即在,与 `FILE_HISTORY_CAPTURE` 无关)—— 本段只用它的孤儿 blob GC。 */
74
+ fileHistoryErasure: FileHistoryErasureFace | undefined;
73
75
  taskAttachmentStore: TaskAttachmentStore | undefined;
74
76
  imageBakes: ImageBake | undefined;
75
77
  worktreeReap: (() => Promise<void>) | undefined;
@@ -56,7 +56,7 @@ export function createPlacementAwareReapSessions(deps) {
56
56
  };
57
57
  }
58
58
  export function startReapers(ctx) {
59
- const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileHistoryStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, approvalNonceStore, getRunDenySweep, } = ctx;
59
+ const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileHistoryErasure, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, approvalNonceStore, getRunDenySweep, } = ctx;
60
60
  const leg = (p) => {
61
61
  if (p !== undefined)
62
62
  trackMaintenanceLeg(p);
@@ -227,7 +227,7 @@ export function startReapers(ctx) {
227
227
  leg(toolResultStore
228
228
  ?.reapOlderThan?.(Date.now() - config.toolResultTtlSec * 1000)
229
229
  ?.then(() => toolResultReapOlderThanGuard.onSuccess(), toolResultReapOlderThanGuard.onError));
230
- leg(fileHistoryStore
230
+ leg(fileHistoryErasure
231
231
  ?.sweepOrphanBlobs?.()
232
232
  .then(() => fileHistorySweepOrphanBlobsGuard.onSuccess(), fileHistorySweepOrphanBlobsGuard.onError));
233
233
  leg(backend?.legacySnapshotBlobSweep?.()