@sema-agent/server 7.95.1 → 7.96.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.
package/USAGE.md CHANGED
@@ -24,7 +24,7 @@ npm run dev # 或 bun run src/main.ts → http://<
24
24
  ```bash
25
25
  cd sema-server
26
26
  MODEL_GATEWAY_BASEURL=https://<your-gateway>/v1 MODEL_ID=<your-model-name> \
27
- SESSION_BACKEND=memory \
27
+ SESSION_BACKEND=memory REMOTE_EXEC=host \
28
28
  docker compose up --build
29
29
  # 起来后 OA 只认 http://<host>:8090(LB)
30
30
  ```
@@ -40,10 +40,12 @@ docker compose up --build
40
40
  ```bash
41
41
  IMAGE_REGISTRY=<registry-namespace> \
42
42
  DB_BACKEND=mysql MYSQL_HOST=… MYSQL_USER=… MYSQL_PASSWORD=… MYSQL_DATABASE=sema_server \
43
- MODEL_GATEWAY_BASEURL=… MODEL_ID=<your-model-name> \
43
+ MODEL_GATEWAY_BASEURL=… MODEL_ID=<your-model-name> REMOTE_EXEC=e2b E2B_API_KEY=… \
44
44
  docker compose up --build --scale service=4 # 4 个无状态 worker,nginx LB 扇发,TiDB 协调
45
45
  ```
46
46
 
47
+ > 🔴 7.96.0 起根 `docker-compose.yml` 的 `REMOTE_EXEC`(执行车道)是**必填**:不设 ⇒ `docker compose config` 当场失败(此前静默起成一个没有手的 worker)。单人自用 `REMOTE_EXEC=host`(手落在 worker 容器里);多人 / 生产选隔离车道 `e2b`(配 `E2B_API_KEY`)或 `k8s`(配 `K8S_SANDBOX_IMAGE`)。
48
+
47
49
  > 拓扑:LB(入口)→ N 个一样的 worker → 共享 SQL 库(数据中心;MySQL 协议或 PG)+ 模型网关。无中央调度器,跨 worker 靠 DB 协调。
48
50
 
49
51
  **可选 — 多网关 failover + Anthropic 路由(消除单网关 SPOF)**
@@ -793,8 +795,8 @@ TASK_WRITE_FACE_ESCALATION=allow # 词表 allow|refuse;坏词启动期响亮
793
795
  - **续跑腿**:一条在托管形机器上带着 `bypassPermissions` 的 parked 任务,赎回时被同一道门挡住,
794
796
  上层折成 `409 resume_blocked_by_policy`(`blockedBy: request.host_posture_refused`)。
795
797
  运维的解法是 `TASK_WRITE_FACE_ESCALATION=allow`。
796
- - **能力面披露位**:`GET /v1/capabilities` 的 `taskWriteFaceOpen`(`false` ⇒ 发那个键会拿 422)——
797
- 壳不必靠踩一次 422 才学会。
798
+ - **能力面披露位**:`GET /v1/capabilities` 的 `taskWriteFaceOpen`(`false` ⇒ 发那个键会拿 422;或 —— 在
799
+ `AUTONOMY=read-only|plan` 的部署上(7.96.0 起)—— 受理但写面被钳回 roots)——壳不必靠踩一次才学会。
798
800
  - **`AUTONOMY=read-only|plan` 一行未动**。⚠️ 两者叠加时**本门先说话**:托管形 + `AUTONOMY=read-only`
799
801
  + `bypassPermissions` 的请求现在是 422,而 7.94 及以前它会跑起来(写被 autonomy 禁掉)。
800
802
  要保留旧结局就 `TASK_WRITE_FACE_ESCALATION=allow`(写面仍被 autonomy 压回 roots)。
@@ -62,7 +62,7 @@ summary 会打印手动接法。
62
62
  | `--gateway=URL` | — | 模型网关 baseURL(OpenAI 兼容);带 agent 的档必需 |
63
63
  | `--model=ID` | `deepseek-v4-flash` | 模型 id |
64
64
  | `--model-key=K` | — | 模型 API key(也可 -f 里 MODEL_API_KEY) |
65
- | `--sandbox=LANE` | kvm 有→`kata` | `kata`/`e2b-cloud`/`runc`(full-single) |
65
+ | `--sandbox=LANE` | single→`host`;multi→`e2b-cloud` | `host`/`e2b-cloud`(full-single / local-plus)。**执行车道**=agent 的手落在哪里,不点名就是没有手;词表与必需键的单一属主=`lib/lanes.json`。`kata`/`runc`/`e2b-self` 已退役(compose 路径对它们零接线),收到即非零退出并指路。kube 路径(full-cluster)**不选词**:runtime 按 runtimeClass `kata-qemu` 在不在自动定,车道恒 `REMOTE_EXEC=k8s` |
66
66
  | `--registry=BOOL` | `true`(local-plus `false`) | 配置中心网站开关 |
67
67
  | `--registry-image=REF` | — | sema-registry 镜像(registry=true 必需) |
68
68
  | `--git=MODE` | `none` | `none`/`provision`/`connect` |
@@ -105,6 +105,13 @@ data-center:2c/2G/20G)以下 BLOCK——确实无法稳定部署,提前警告不
105
105
  全部凭据生成一次、落 `~/.sema-up/<project>/.env`(0600),重跑**绝不轮换**;
106
106
  summary 里管理员密码只显示一次(新生成那次),不进日志不进 git。
107
107
 
108
+ 重跑时租户形与车道同一条规则:**决策 = 显式旗 ∨ 部署事实 ∨ 表缺省**。部署事实 = `.env`(与 shell / `-f` 环境)里
109
+ 上一次落下的 `REQUIRE_PRINCIPAL` / `REMOTE_EXEC` —— **不带旗重跑即沿用它**(多租部署、e2b 部署都不必再带旗);显式
110
+ `--users` / `--sandbox` 与事实不一致 ⇒ 非零退出并点名两处(不静默覆盖;要换就先清掉旧值)。`.env` 与 shell / `-f`
111
+ 两处都给了且不同 ⇒ 同样拒。读 `.env` 的这几个键(连同所选车道的必需键,如 `E2B_API_KEY`)只认 sema-up 自己写出的
112
+ **规范形**:一行、行首顶格 `K=<值>`,值是一个词或一对同型引号包住的一个词;`export ` 前缀、键旁空格、`K: v`、
113
+ 行内注释、重复行 ⇒ 非零退出并点名行号(不去猜 compose 会怎么读那一行)。`REQUIRE_PRINCIPAL` 只认 `true` / `false`。
114
+
108
115
  ## 验收
109
116
 
110
117
  ```bash
@@ -57,6 +57,9 @@ spec:
57
57
  - { name: MODEL_VISION, value: {{ .Values.server.modelVision | quote }} }
58
58
  - { name: SANDBOX_PKG_SOURCE, value: {{ .Values.server.sandboxPkgSource | quote }} }
59
59
  - { name: REMOTE_EXEC, value: k8s }
60
+ {{/* S-599:车道点名了就显式表态「这台有手」——REQUIRE_EXECUTION_LANE 让「车道被谁改没了」
61
+ 变成拒启而不是安静的无手 worker。server 侧默认仍 off,sema-up 产物一律写 true。 */}}
62
+ - { name: REQUIRE_EXECUTION_LANE, value: "true" }
60
63
  - { name: K8S_API_URL, value: "https://kubernetes.default.svc" }
61
64
  {{- if .Values.server.k8sInsecureTls }}
62
65
  - { name: K8S_INSECURE_TLS, value: "true" }
@@ -70,6 +70,9 @@ spec:
70
70
  - { name: REQUIRE_PRINCIPAL, value: {{ .Values.server.requirePrincipal | default "false" | quote }} }
71
71
  - { name: SANDBOX_PKG_SOURCE, value: {{ .Values.server.sandboxPkgSource | quote }} }
72
72
  - { name: REMOTE_EXEC, value: k8s }
73
+ {{/* S-599:车道点名了就显式表态「这台有手」——REQUIRE_EXECUTION_LANE 让「车道被谁改没了」
74
+ 变成拒启而不是安静的无手 worker。server 侧默认仍 off,sema-up 产物一律写 true。 */}}
75
+ - { name: REQUIRE_EXECUTION_LANE, value: "true" }
73
76
  - { name: K8S_API_URL, value: "https://kubernetes.default.svc" }
74
77
  {{- if .Values.server.k8sInsecureTls }}
75
78
  - { name: K8S_INSECURE_TLS, value: "true" }
@@ -0,0 +1,56 @@
1
+ {
2
+ "lanes": [
3
+ {
4
+ "path": "compose",
5
+ "choice": "host",
6
+ "users": ["single"],
7
+ "env": {
8
+ "REMOTE_EXEC": "host",
9
+ "REQUIRE_EXECUTION_LANE": "true"
10
+ },
11
+ "isolation": "unsupported",
12
+ "note": "server 容器本身就是沙箱:agent 的手落在这只容器里(worktree 隔离在、OS 级隔离不在)。只给单用户 —— 多租户下一个租户的输入会把手指向另一个租户的文件与进程。"
13
+ },
14
+ {
15
+ "path": "compose",
16
+ "choice": "e2b-cloud",
17
+ "users": ["single", "multi"],
18
+ "env": {
19
+ "REMOTE_EXEC": "e2b",
20
+ "REQUIRE_EXECUTION_LANE": "true",
21
+ "E2B_API_KEY": null
22
+ },
23
+ "isolation": "supported",
24
+ "note": "E2B 云沙箱:每任务一只 microVM。E2B_API_KEY 由运维自备(sema-up 只登记键名,绝不写值)。"
25
+ },
26
+ {
27
+ "path": "kube",
28
+ "choice": null,
29
+ "users": ["single", "multi"],
30
+ "env": {
31
+ "REMOTE_EXEC": "k8s",
32
+ "REQUIRE_EXECUTION_LANE": "true",
33
+ "K8S_SANDBOX_IMAGE": null
34
+ },
35
+ "isolation": "supported",
36
+ "note": "kube 路径**不选词**:runtime 由 kube-up.sh 按 runtimeClass `kata-qemu` 在不在自动定(在 ⇒ kata VM,不在 ⇒ runc 容器),沙箱镜像由 chart/清单给。本行只为对表(能力表 / 必需键 / REQUIRE_EXECUTION_LANE 三面),不进 --sandbox 词表。"
37
+ }
38
+ ],
39
+ "retired": [
40
+ {
41
+ "choice": "kata",
42
+ "why": "compose 路径对 kata 零接线:选了它 .env 里不会出现任何 REMOTE_EXEC,core 落 StubExecutionEnv ⇒ 起来的 worker 一只手都没有。有 KVM 的机器上它还曾是默认值,所以这是最贵的一个死词。",
43
+ "goto": "要 VM 级隔离走 kube 路径:sema-up.sh --profile=full-cluster(runtimeClass kata-qemu 在场即自动用 kata,不在场退 runc);留在单机 compose 上就选 host 或 e2b-cloud。"
44
+ },
45
+ {
46
+ "choice": "runc",
47
+ "why": "同 kata:compose 路径对它零接线,选了等于没选车道(无手)。",
48
+ "goto": "单机 compose 上「容器隔离」的真形就是 host(server 容器自己);要独立沙箱容器走 kube 路径 --profile=full-cluster。"
49
+ },
50
+ {
51
+ "choice": "e2b-self",
52
+ "why": "自托管 E2B 端点从未接线:它只在 preflight 文案与 machines.yaml 模板里出现过,没有任何一处把它翻成 REMOTE_EXEC。",
53
+ "goto": "先用 e2b-cloud(填 E2B_API_KEY);自托管端点的接线尚未排期。"
54
+ }
55
+ ]
56
+ }
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """lanes.py — `lanes.json`(执行车道表)的唯一读者。
3
+
4
+ ## 为什么是一个文件而不是 sema-up.sh 里的几行 bash
5
+
6
+ 车道这件事此前被五份产物各写一遍(`sema-up.sh` 一行 `setk REMOTE_EXEC e2b`、三份 kube 清单的
7
+ 硬编码 `k8s`、根/review compose 的零车道白名单),于是「有 KVM 的机器默认选 kata ⇒ 零接线 ⇒
8
+ 起来的 worker 没有手」这件事没有任何一处会红。收成一张表之后,**表是唯一属主**:脚本里不许再
9
+ 出现任何一个车道词字面量,选项串、默认值、退役词指路、落 .env 的键值**全部**从这里渲。
10
+
11
+ ## 子命令(全部以非零退出表示「拒」,理由写 stderr)
12
+
13
+ choices 打印 compose 路径的在役词(每行一个;--help / README 的词表渲它)
14
+ default <users> 打印该租户形下的缺省 choice
15
+ options <users> 每行一个 `ask` 选项串 `value:label[:infeasible=原因|建议]`
16
+ resolve <users> <choice> 校验并打印该车道要落 .env 的 `KEY=VALUE` 行(仅有值的键);
17
+ 必需但由运维自备的键名打印成 `#REQUIRED <NAME>` 注释行(「选了却没给」的早拒读它)
18
+ choice-of <REMOTE_EXEC 值> 部署事实 → `--sandbox` 词的反查(决策 = 显式旗 ∨ 部署事实 ∨ 表缺省):上一次落下的 `REMOTE_EXEC=<值>`
19
+ 是哪条 compose 车道;表里没有这个落盘值 / 反查不唯一 ⇒ 拒
20
+
21
+ ## 拒的三种形(全部 fail-closed,没有一条静默降级)
22
+
23
+ ① 退役词(`retired`):非零 + why + 指路;
24
+ ② 不在表里的词:非零 + 列出在役词表;
25
+ ③ 该租户形不许选的词(`users` 不含它):非零 + 列出该租户形可选的词。
26
+ """
27
+ import json
28
+ import os
29
+ import sys
30
+
31
+ HERE = os.path.dirname(os.path.abspath(__file__))
32
+ TABLE_PATH = os.path.join(HERE, "lanes.json")
33
+
34
+
35
+ def die(msg):
36
+ sys.stderr.write("[sema-up] ⛔ %s\n" % msg)
37
+ raise SystemExit(1)
38
+
39
+
40
+ def table():
41
+ with open(TABLE_PATH, encoding="utf-8") as fh:
42
+ return json.load(fh)
43
+
44
+
45
+ def compose_rows(t):
46
+ return [r for r in t["lanes"] if r["path"] == "compose"]
47
+
48
+
49
+ def offered(t, users):
50
+ """该租户形下**可选**的 compose 行(顺序 = 表的声明序)。"""
51
+ return [r for r in compose_rows(t) if users in r["users"]]
52
+
53
+
54
+ def find(t, choice):
55
+ for r in compose_rows(t):
56
+ if r["choice"] == choice:
57
+ return r
58
+ return None
59
+
60
+
61
+ def retired_of(t, choice):
62
+ for r in t["retired"]:
63
+ if r["choice"] == choice:
64
+ return r
65
+ return None
66
+
67
+
68
+ def cmd_choices():
69
+ return "\n".join(r["choice"] for r in compose_rows(table()))
70
+
71
+
72
+ def cmd_default(users):
73
+ t = table()
74
+ rows = offered(t, users)
75
+ if not rows:
76
+ die("users=%s 在车道表里没有任何可选车道 —— 表坏了" % users)
77
+ return rows[0]["choice"]
78
+
79
+
80
+ def cmd_options(users):
81
+ """`ask` 的选项串。该租户形不许选的车道**仍然列出**,但标 infeasible ——
82
+ 运维要看见「这条路存在、但对你这个形不行、该怎么办」,而不是一个凭空少了一项的菜单
83
+ (`ask` 自己会在选中 infeasible 项时 fail-loud 退出,见 sema-up.sh 的 ask())。"""
84
+ t = table()
85
+ lines = []
86
+ for r in compose_rows(t):
87
+ label = r["note"].split("。")[0]
88
+ if users in r["users"]:
89
+ lines.append("%s:%s" % (r["choice"], label))
90
+ else:
91
+ lines.append(
92
+ "%s:%s:infeasible=users=%s 不许选它(隔离性 %s)|%s"
93
+ % (
94
+ r["choice"],
95
+ label,
96
+ users,
97
+ r["isolation"],
98
+ "改选 %s,或走 kube 路径 --profile=full-cluster"
99
+ % " / ".join(x["choice"] for x in offered(t, users)),
100
+ )
101
+ )
102
+ return "\n".join(lines)
103
+
104
+
105
+ def cmd_choice_of(remote_exec):
106
+ """部署事实 → `--sandbox` 词的**反查**(sema-up 的决策规则:决策 = 显式旗 ∨ 部署事实 ∨ 表缺省)。
107
+
108
+ `.env` / shell 里已有的 `REMOTE_EXEC=<值>` 是上一次落下的哪条 compose 车道 —— 表是唯一属主,脚本不抄词。
109
+ 表里没有这个落盘值 ⇒ 拒并列在役词(不猜、不把它讲成「冲突」);两行落同一个值 ⇒ 反查不唯一,表坏了
110
+ (`test/deploy-lanes-contract.test.ts` ⑥ 同时咬住「compose 行的 REMOTE_EXEC 两两不同」)。"""
111
+ t = table()
112
+ rows = [r for r in compose_rows(t) if r["env"].get("REMOTE_EXEC") == remote_exec]
113
+ if len(rows) == 1:
114
+ return rows[0]["choice"]
115
+ if len(rows) > 1:
116
+ die("车道表里有 %d 条 compose 车道都落 REMOTE_EXEC=%s —— 反查不唯一,表坏了" % (len(rows), remote_exec))
117
+ die(
118
+ "部署事实 REMOTE_EXEC=%s 不是这条部署路径上任何一条车道的落盘值 —— 在役:%s\n"
119
+ " ↳ 下一步:清掉 .env / shell 里的 REMOTE_EXEC 后重跑(不带 --sandbox 即取表缺省),或带 --sandbox 选一条在役车道;"
120
+ "k8s 车道走 kube 路径 --profile=full-cluster"
121
+ % (
122
+ remote_exec,
123
+ " / ".join("%s(REMOTE_EXEC=%s)" % (r["choice"], r["env"]["REMOTE_EXEC"]) for r in compose_rows(t)),
124
+ )
125
+ )
126
+
127
+
128
+ def cmd_resolve(users, choice):
129
+ t = table()
130
+ gone = retired_of(t, choice)
131
+ if gone is not None:
132
+ die(
133
+ "--sandbox=%s 已退役:%s\n ↳ 下一步:%s"
134
+ % (choice, gone["why"], gone["goto"])
135
+ )
136
+ row = find(t, choice)
137
+ if row is None:
138
+ die(
139
+ "--sandbox=%s 不是这条部署路径上的车道 —— 可选:%s\n"
140
+ " ↳ kube 路径(--profile=full-cluster)**不选词**:runtime 按 runtimeClass 自动定"
141
+ % (choice, " / ".join(r["choice"] for r in compose_rows(t)))
142
+ )
143
+ if users not in row["users"]:
144
+ die(
145
+ "--sandbox=%s 不能用在 --users=%s 上(该车道隔离性 %s)—— 改选:%s,或走 kube 路径 "
146
+ "--profile=full-cluster(k8s 车道,每任务一只 pod)"
147
+ % (
148
+ choice,
149
+ users,
150
+ row["isolation"],
151
+ " / ".join(r["choice"] for r in offered(t, users)),
152
+ )
153
+ )
154
+ out = []
155
+ for k, v in row["env"].items():
156
+ out.append("%s=%s" % (k, v) if v is not None else "#REQUIRED %s" % k)
157
+ return "\n".join(out)
158
+
159
+
160
+ def main(argv):
161
+ if len(argv) < 2:
162
+ die("用法: lanes.py choices|default|options|resolve|choice-of …")
163
+ verb = argv[1]
164
+ if verb == "choices" and len(argv) == 2:
165
+ print(cmd_choices())
166
+ elif verb == "default" and len(argv) == 3:
167
+ print(cmd_default(argv[2]))
168
+ elif verb == "options" and len(argv) == 3:
169
+ print(cmd_options(argv[2]))
170
+ elif verb == "choice-of" and len(argv) == 3:
171
+ print(cmd_choice_of(argv[2]))
172
+ elif verb == "resolve" and len(argv) == 4:
173
+ print(cmd_resolve(argv[2], argv[3]))
174
+ else:
175
+ die("用法: lanes.py choices | default <users> | options <users> | resolve <users> <choice> | choice-of <REMOTE_EXEC 值>")
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main(sys.argv)
@@ -101,7 +101,13 @@ def main(path):
101
101
  emit("DP_S3", dp.get("s3", "deploy"))
102
102
  emit("DP_BACKUP", dp.get("backup", "true"))
103
103
  sb = sections.get("sandbox", {})
104
- emit("SANDBOX_LANE", sb.get("lane"))
104
+ # S-599:`sandbox.lane` 是**死词**,已删。machines.yaml 只走 kube 路径,而 kube-up.sh 的 runtime
105
+ # 是按 runtimeClass `kata-qemu` 在不在自动定的(kube-up.sh:94-96),从来不读 SANDBOX_LANE ——
106
+ # 发射它只会让运维以为自己选到了什么。**响亮拒**而不是静默忽略:一个设了却没有任何效果的旋钮,
107
+ # 正是本批要消灭的那类病(留着它,下一个人还会照模板填)。
108
+ if sb.get("lane") is not None:
109
+ die("machines.yaml 的 sandbox.lane 已退役(kube 路径的 runtime 按 runtimeClass kata-qemu "
110
+ "在不在自动定,不读该键)——删掉那一行即可;compose 路径的车道见 sema-up.sh --help 的 --sandbox")
105
111
  emit("E2B_ENDPOINT", sb.get("e2b_endpoint"))
106
112
  emit("E2B_API_KEY", sb.get("e2b_api_key"))
107
113
  rg = sections.get("registry", {})
@@ -34,8 +34,11 @@ data_plane:
34
34
  backup: true # PG 定时备份到 S3(data-center/full 默认 true)
35
35
 
36
36
  sandbox:
37
- lane: kata # kata(默认,需 KVM,预检不过不给)| e2b-cloud | e2b-self | runc
38
- # e2b_endpoint: … # lane=e2b-self 时必填(校验连通+模板可用)
37
+ # ⚠️ 这里**不选沙箱词**:machines.yaml 走的是 kube 路径,runtime 由 kube-up.sh 按 runtimeClass
38
+ # `kata-qemu` 在不在自动定(在 ⇒ kata VM,不在 ⇒ runc 容器),执行车道恒 REMOTE_EXEC=k8s。
39
+ # 旧模板里的 `lane:` 是死词(从来没有任何一处读它),已退役——还写着它的 yaml 会被 fail-loud 拒。
40
+ # 单机 compose 路径的车道选择见 sema-up.sh --help 的 --sandbox。
41
+ # e2b_endpoint: … # 自托管 E2B 端点(尚未接线,登记为 v2 候选)
39
42
  # e2b_api_key: …
40
43
 
41
44
  registry:
@@ -97,12 +97,15 @@ if [ -n "$BEST_MNT" ] && [ "$BEST_MNT" != "/" ] && [ "$BEST_GB" -gt "${DISK_GB:-
97
97
  add WARN disk-alt "$BEST_MNT ${BEST_GB}G avail" "存在比 / 更大的数据盘(多盘机)" "--data-root=$BEST_MNT/sema-data(sema-up 未显式指定时会自动选它)"
98
98
  fi
99
99
 
100
- # ── KVM(kata lane 前提;无=不给选 kata,建议替代,不算 BLOCK——lane 可换) ───────────────────
100
+ # ── KVM(**kube 路径**的 kata runtimeClass 前提;compose 路径已无需要 KVM 的车道,S-599)────────
101
+ # 有 KVM ⇒ 走 full-cluster 时 kata-deploy 能装上,沙箱得 VM 级隔离;没有 ⇒ kube 路径退 runc 容器
102
+ # 隔离(仍是真隔离,只是弱于 VM)。两种都不是 BLOCK:单机 compose 路径的车道(host / e2b-cloud)
103
+ # 与 KVM 无关。
101
104
  if [ -e /dev/kvm ] && grep -qE 'vmx|svm' /proc/cpuinfo 2>/dev/null; then
102
- add OK kvm "available" "kata lane 可用(默认建议)" ""
105
+ add OK kvm "available" "kube 路径可用 kata runtimeClass(VM 级沙箱隔离)" ""
103
106
  else
104
- add WARN kvm "unavailable" "无虚拟化支持,kata lane 不可部署" \
105
- "①云上开嵌套虚拟化/换 bare metal ②e2b-cloud(填 key 即用)或 e2b-self ③runc(容器隔离,弱于 VM,不建议生产)"
107
+ add WARN kvm "unavailable" "无虚拟化支持:走 kube 路径时沙箱退 runc 容器隔离(弱于 VM)" \
108
+ "①云上开嵌套虚拟化/换 bare metal ②单机 compose 路径选 --sandbox=e2b-cloud(E2B 云沙箱,每任务一只 microVM)"
106
109
  fi
107
110
 
108
111
  # ── 端口(按 --ports/profile 决定检查面) ──────────────────────────────────────────────────────
@@ -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 }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Model, type PathRuleBases, type RuleBehavior } from "@sema-agent/core";
2
+ import { type RemoteExecProvider } from "./execution-lane-caps.js";
2
3
  import { type Autonomy } from "./runtime-governance.js";
3
4
  import type { ServiceConfig, ServiceConfigFlat, ServiceConfigGroups } from "./config-types.js";
4
5
  /** [ref] A1: the config TYPE face lives in the leaf module config-types.ts; re-exported here so
@@ -215,6 +216,21 @@ export declare const configLkgEnabled: () => boolean;
215
216
  export declare const hostBackgroundShellEnabled: () => boolean;
216
217
  /** The `host` exec lane's spool-file stdio form; false ⇒ the pre-1.226 pipe fallback (read per exec). */
217
218
  export declare const hostExecSpoolEnabled: () => boolean;
219
+ /**
220
+ * 每条执行车道**必需的 env 键名**([ref] 的 fail-closed 拒启门按它点名缺件;见 `parseOrchestrationDomain`
221
+ * 里那处 `REMOTE_EXEC` 校验)。
222
+ *
223
+ * 键集 = `RemoteExecProvider`(判别联合派生,`execution-lane-caps.ts`):配置面加一条 provider 臂而
224
+ * 这里不加行 = **编译红**(codex R1-F2)。此前是 `Record<string, …>`,漏一行只表现为该词在 boot 期被判
225
+ * 「不是执行车道」—— fail-closed 但把「新车道」讲成「拼错了」,运维照着报文永远查不到成因。
226
+ *
227
+ * 🔴 **S-599:提到模块顶层导出**(此前是 `parseOrchestrationDomain` 的函数内局部)。零语义变,动机是
228
+ * 部署面需要**同一张表**:`deploy/sema-up/lib/lanes.json` 的每行必须登记它那条车道的必需键名,根 /
229
+ * review compose 的 environment 白名单必须把**全部**车道的必需键透传出去 —— 那两处若各手抄一份名单,
230
+ * 加一条车道就会静默分叉(选得到、起不来,而 config 的拒启报文里那个键在 compose 里根本不存在)。
231
+ * 门 = `test/deploy-lanes-contract.test.ts`,它读的是本表而不是任何副本。
232
+ */
233
+ export declare const REQUIRED_ENVS_BY_LANE: Record<RemoteExecProvider, readonly string[]>;
218
234
  /** [ref]① 1M dual-window (core 1.289 `Model.autoCompactTokens`): an EXPLICIT positive value wins (env
219
235
  * `MODEL_AUTO_COMPACT_TOKENS` / center roster `autoCompactTokens`); else the **model-id table** applies —
220
236
  * sonnet-5-family id ∧ contextWindow>=1e6 ⇒ 967000 (the faithful shape of CC's single-entry per-model
package/dist/config.js CHANGED
@@ -450,6 +450,15 @@ export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
450
450
  export const configLkgEnabled = () => boolEnvWithLegacyNegated("CONFIG_LKG_ENABLED", "CONFIG_LKG_DISABLED", true);
451
451
  export const hostBackgroundShellEnabled = () => boolEnvWithLegacyNegated("HOST_BG_ENABLED", "HOST_BG_DISABLED", true);
452
452
  export const hostExecSpoolEnabled = () => boolEnvWithLegacyNegated("HOST_EXEC_SPOOL_ENABLED", "HOST_EXEC_SPOOL_DISABLED", true);
453
+ export const REQUIRED_ENVS_BY_LANE = {
454
+ e2b: ["E2B_API_KEY"],
455
+ k8s: ["K8S_SANDBOX_IMAGE"],
456
+ ssh: ["SSH_HOST", "SSH_USER", "SSH_PRIVATE_KEY"],
457
+ adb: ["ADB_SERIAL"],
458
+ host: [],
459
+ "local-docker": ["DOCKER_IMAGE"],
460
+ device: [],
461
+ };
453
462
  function parseReadDenyPatternsEnv(raw) {
454
463
  let arr;
455
464
  try {
@@ -1618,15 +1627,6 @@ function parseOrchestrationDomain(ctx) {
1618
1627
  {
1619
1628
  const lane = process.env.REMOTE_EXEC;
1620
1629
  if (lane !== undefined && lane !== "") {
1621
- const REQUIRED_ENVS_BY_LANE = {
1622
- e2b: ["E2B_API_KEY"],
1623
- k8s: ["K8S_SANDBOX_IMAGE"],
1624
- ssh: ["SSH_HOST", "SSH_USER", "SSH_PRIVATE_KEY"],
1625
- adb: ["ADB_SERIAL"],
1626
- host: [],
1627
- "local-docker": ["DOCKER_IMAGE"],
1628
- device: [],
1629
- };
1630
1630
  const required = Object.entries(REQUIRED_ENVS_BY_LANE).find(([word]) => word === lane)?.[1];
1631
1631
  if (required === undefined) {
1632
1632
  throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid: ${Object.keys(REQUIRED_ENVS_BY_LANE).join(" | ")}. ` +
@@ -4,6 +4,7 @@ import { resolveModelApiKey } from "../../key-resolver.js";
4
4
  import { isSealedKeyPoison } from "../../sealed-key.js";
5
5
  import { cwdHonored, deviceCwdHonored } from "../../task-cwd.js";
6
6
  import { taskApproverPostureHonored, taskWriteFaceRefusal } from "../../hosted-posture.js";
7
+ import { autonomyOverrides } from "../../runtime-governance.js";
7
8
  import { DEVICE_PROTOCOL_VERSION, DEVICE_WS_PATH } from "../../device-ws-protocol.js";
8
9
  import { a2aInjectionHonored } from "../../task-a2a.js";
9
10
  import { mcpInjectionHonored } from "../../task-mcp.js";
@@ -136,7 +137,7 @@ export async function handleCapabilities(req, res, match, ctx) {
136
137
  interactiveTools: true,
137
138
  projectContext: cwdHonored(deps.config),
138
139
  callerCwd: cwdHonored(deps.config) || deviceCwdHonored(deps.config),
139
- taskWriteFaceOpen: taskWriteFaceRefusal(deps.config) === undefined,
140
+ taskWriteFaceOpen: taskWriteFaceRefusal(deps.config) === undefined && autonomyOverrides(deps.config.autonomy).writeFace !== "roots",
140
141
  taskApproverPosture: taskApproverPostureHonored(deps.config),
141
142
  executionLane: projectExecutionLaneCapability(deps.config),
142
143
  mcpInjection: mcpInjectionHonored(deps.config),
@@ -13,7 +13,7 @@ import { clearTurnActivity, readTurnActivityMs, recordTurnActivity } from "../..
13
13
  import { mintCancelledResult } from "../../run-cancel-context.js";
14
14
  import { recordResourceWindow } from "../../resource-window.js";
15
15
  import { redactSecrets } from "../../trace/redact.js";
16
- import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestFaceEventData, toolRosterDeltaEventData, toolProgressEventData, toolDisclosureEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage, textEndEventData, liveIdentityFields } from "../../trace/project.js";
16
+ import { contextUsageEventData, turnEndEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestFaceEventData, toolRosterDeltaEventData, toolProgressEventData, toolDisclosureEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage, textEndEventData, liveIdentityFields } from "../../trace/project.js";
17
17
  import { cascadeConfig, runMeta } from "../run-meta.js";
18
18
  import { scopedIdempotencyKey } from "../idempotency.js";
19
19
  import { sendJson, sendError, sseHeaders } from "../send.js";
@@ -527,7 +527,7 @@ export async function handleTasks(req, res, match, ctx) {
527
527
  ?
528
528
  textEnd === null || textEnd === undefined ? null : { type: "text_end", ...textEnd }
529
529
  : ev.type === "turn_end"
530
- ? { type: "turn_end", ...(e.usage !== undefined ? { usage: e.usage } : {}), ...(e.usageMissing !== undefined ? { usageMissing: e.usageMissing } : {}), ...(e.stopReason !== undefined ? { stopReason: e.stopReason } : {}), ...ident }
530
+ ? { type: "turn_end", ...turnEndEventData(ev), ...ident }
531
531
  : ev.type === "message_committed"
532
532
  ? { type: "message_committed", entryId: e.entryId, role: e.role, ...(e.toolCallId !== undefined ? { toolCallId: e.toolCallId } : {}), ...ident }
533
533
  : { type: "context_usage", ...contextUsageEventData(ev), ...ident };
@@ -105,6 +105,7 @@ const RESULT_KEY_TREATMENT = {
105
105
  runId: "verbatim",
106
106
  sessionId: "verbatim",
107
107
  model: "verbatim",
108
+ responseModel: "verbatim",
108
109
  effectiveReasoning: "verbatim",
109
110
  result: "redacted",
110
111
  salvagedOutput: "redacted",
@@ -138,7 +138,7 @@ type TaskSpecWired = "objective" | "principal" | "images" | "sessionId" | "requi
138
138
  type TaskSpecUnwired = "actor" | "roles" | "lspManager" | "tracer" | "readFace" | "readDenyPatterns" | "interactiveQuestionFallback" | "alwaysLoadTools" | "restoreGatedTools" | "deferSelfResolve" | "basePolicyForResumeEdit" | "lspDiagnostics" | "streamingToolExecution" | "toolResultThresholdChars";
139
139
  type _GuardTaskSpec = AssertAllKeysHandled<Exclude<keyof TaskSpec, TaskSpecWired | TaskSpecUnwired>>;
140
140
  type _GuardTaskSpecReverse = AssertAllKeysHandled<Exclude<TaskSpecWired | TaskSpecUnwired, keyof TaskSpec>>;
141
- type TaskResultForwarded = "taskId" | "runId" | "sessionId" | "terminal" | "model" | "effectiveReasoning" | "result" | "salvagedOutput" | "remoteEnvFailures" | "apiFailure" | "retryAfterMs" | "degraded" | "structuredOutput" | "rewindNotes" | "haltedOnUserRejection" | "haltedByUser" | "strandedHumanAnswers" | "pendingApproval" | "approvalWaitedMs" | "effectiveReadFace" | "effectiveReadDenyPatterns" | "effectiveWriteFace" | "effectiveMemoryScopes" | "stats" | "editedFiles";
141
+ type TaskResultForwarded = "taskId" | "runId" | "sessionId" | "terminal" | "model" | "effectiveReasoning" | "result" | "salvagedOutput" | "remoteEnvFailures" | "apiFailure" | "retryAfterMs" | "degraded" | "structuredOutput" | "rewindNotes" | "haltedOnUserRejection" | "haltedByUser" | "strandedHumanAnswers" | "pendingApproval" | "approvalWaitedMs" | "effectiveReadFace" | "effectiveReadDenyPatterns" | "effectiveWriteFace" | "effectiveMemoryScopes" | "stats" | "editedFiles" | "responseModel";
142
142
  type TaskResultStripped = never;
143
143
  type _GuardTaskResult = AssertAllKeysHandled<Exclude<keyof TaskResult, TaskResultForwarded | TaskResultStripped>>;
144
144
  type _GuardTaskResultReverse = AssertAllKeysHandled<Exclude<TaskResultForwarded | TaskResultStripped, keyof TaskResult>>;
@@ -904,11 +904,22 @@ export declare function contextUsageEventData(ev: {
904
904
  * 而不是"未知";这正是那种"回放后看起来一切正常"的丢失。
905
905
  * 两者**在 live 腿看得见、durable 腿回放后永久消失** —— 与 [ref] 的 `label`、[ref] 的 workflowRunId 同一族
906
906
  * (白名单没跟上 core 新增字段)。补 builder 的同时也就把它纳入了 `wire-whitelist-exhaustiveness` 的编译期差集门。
907
+ *
908
+ * 🔴 **本构造器是 `turn_end` 帧载荷的唯一铸点**(core 7.29.0 提货批,车LY):三条腿同调 —— live SSE
909
+ * (`http/routes/tasks.ts` 的 `/v1/tasks/stream` 臂,身份键另由 `liveIdentityFields` 补四键)、durable 账本
910
+ * (`ledger-sink.ts`)、resume 腿(`resume-legs.ts`)。live 臂此前是一份**手抄**的键集,于是 core 7.29.0 加的
911
+ * `responseModel` 在 durable 面由这里补上以后,live 面仍会被那份手抄静默剥掉 —— 同一帧两条腿两套键集。
912
+ *
913
+ * `responseModel`(core 7.29.0 #1029):**供应商自己**报的、回答这一 turn 的模型名,**只在**它 ≠ 请求所带 id
914
+ * 时在场。缺席 = 「报了同名」或「没报」,**一个读法**(请求 id 读 `TaskResult.model`)。值是供应商铸的名字
915
+ * (与 `apiFailure.requestId` 同类:不是模型写的自由文本)⇒ 逐字;非串 / 空串 ⇒ 缺席(core 的读律
916
+ * `providerReportedModel` 对空串答「没报」,本仓不铸一个看起来合法的空名)。
907
917
  */
908
918
  export declare function turnEndEventData(ev: {
909
919
  usage?: unknown;
910
920
  usageMissing?: true;
911
921
  stopReason?: string;
922
+ responseModel?: unknown;
912
923
  eventId?: unknown;
913
924
  parentToolCallId?: unknown;
914
925
  }): Record<string, unknown>;
@@ -717,6 +717,7 @@ export function turnEndEventData(ev) {
717
717
  ...(ev.usage ? { usage: ev.usage } : {}),
718
718
  ...(ev.usageMissing === true ? { usageMissing: true } : {}),
719
719
  ...(typeof ev.stopReason === "string" ? { stopReason: ev.stopReason } : {}),
720
+ ...(typeof ev.responseModel === "string" && ev.responseModel !== "" ? { responseModel: ev.responseModel } : {}),
720
721
  ...identityFields(ev),
721
722
  };
722
723
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.95.1",
3
+ "version": "7.96.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",
@@ -18,7 +18,8 @@
18
18
  "types": "./dist/device-protocol.d.ts",
19
19
  "default": "./dist/device-protocol.js"
20
20
  },
21
- "./package.json": "./package.json"
21
+ "./package.json": "./package.json",
22
+ "./deploy/sema-up/lanes.json": "./deploy/sema-up/lib/lanes.json"
22
23
  },
23
24
  "files": [
24
25
  "dist",
@@ -59,7 +60,7 @@
59
60
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
60
61
  },
61
62
  "dependencies": {
62
- "@sema-agent/core": "7.28.1",
63
+ "@sema-agent/core": "7.29.0",
63
64
  "@sema-agent/settings-schema": "3.0.0",
64
65
  "e2b": "^2.28.0",
65
66
  "libsodium-wrappers": "^0.8.4",