@sema-agent/server 7.97.0 → 7.99.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 (70) hide show
  1. package/MIGRATION.md +11 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/USAGE.md +18 -9
  5. package/deploy/sema-up/cluster-up.sh +8 -4
  6. package/deploy/sema-up/kube-up.sh +40 -15
  7. package/deploy/sema-up/lib/envf.sh +241 -0
  8. package/deploy/sema-up/sema-up.sh +162 -101
  9. package/deploy/sema-up/smoke.sh +8 -1
  10. package/dist/boot/config-center.js +169 -24
  11. package/dist/boot/org-memory.d.ts +2 -1
  12. package/dist/boot/org-memory.js +9 -5
  13. package/dist/boot/resolve-spec.js +9 -4
  14. package/dist/boot/runtime-caps.js +4 -4
  15. package/dist/brain.js +28 -18
  16. package/dist/capabilities/center-prompts.d.ts +3 -0
  17. package/dist/capabilities/center-prompts.js +1 -1
  18. package/dist/center-credential.d.ts +112 -0
  19. package/dist/center-credential.js +131 -0
  20. package/dist/config-catalog.js +7 -6
  21. package/dist/config-center/apply-effective.js +7 -1
  22. package/dist/config-center/center-request.d.ts +60 -0
  23. package/dist/config-center/center-request.js +71 -0
  24. package/dist/config-center/facade.d.ts +2 -1
  25. package/dist/config-center/http-client.d.ts +17 -9
  26. package/dist/config-center/http-client.js +27 -36
  27. package/dist/config-center/skills-mcp.d.ts +4 -1
  28. package/dist/config-center/skills-mcp.js +2 -2
  29. package/dist/config-invariants.d.ts +2 -2
  30. package/dist/config-invariants.js +9 -9
  31. package/dist/config-lkg.d.ts +47 -8
  32. package/dist/config-lkg.js +107 -20
  33. package/dist/config-provider.d.ts +8 -7
  34. package/dist/config-provider.js +5 -5
  35. package/dist/config-types.d.ts +20 -6
  36. package/dist/config.d.ts +9 -0
  37. package/dist/config.js +53 -18
  38. package/dist/fleet-client.d.ts +8 -8
  39. package/dist/fleet-client.js +7 -7
  40. package/dist/fleet-lease.d.ts +11 -11
  41. package/dist/fleet-lease.js +17 -13
  42. package/dist/hooks/hook-llm.d.ts +1 -1
  43. package/dist/hooks/hook-llm.js +3 -1
  44. package/dist/hosted-posture.d.ts +49 -93
  45. package/dist/hosted-posture.js +1 -25
  46. package/dist/memory-scope.d.ts +62 -11
  47. package/dist/memory-scope.js +32 -3
  48. package/dist/model-provider.d.ts +13 -0
  49. package/dist/model-provider.js +3 -0
  50. package/dist/model-route-endpoint.d.ts +82 -0
  51. package/dist/model-route-endpoint.js +55 -0
  52. package/dist/observability/secret-env-scrub.d.ts +2 -26
  53. package/dist/observability/secret-env-scrub.js +2 -2
  54. package/dist/plugins/remote-env-host.d.ts +3 -1
  55. package/dist/plugins/remote-env-host.js +3 -2
  56. package/dist/project-identity.d.ts +29 -0
  57. package/dist/project-identity.js +120 -0
  58. package/dist/project-memory.d.ts +28 -1
  59. package/dist/project-memory.js +22 -22
  60. package/dist/run-local.d.ts +14 -4
  61. package/dist/run-local.js +8 -7
  62. package/dist/runtime-caps-resolver.d.ts +8 -8
  63. package/dist/runtime-caps-resolver.js +16 -14
  64. package/dist/sealed-key.js +3 -7
  65. package/dist/server-secret-env.d.ts +41 -0
  66. package/dist/server-secret-env.js +18 -0
  67. package/dist/tool-approval.d.ts +48 -1
  68. package/dist/tool-approval.js +10 -3
  69. package/dist/trace/redact.d.ts +1 -1
  70. package/package.json +4 -3
package/MIGRATION.md CHANGED
@@ -7,6 +7,12 @@
7
7
  > ⚠️ 完整清单在仓库根 `CHANGELOG.md`——它**不随 npm tarball 出包**(本文件随包)。看完整迁移窗的
8
8
  > 权威姿势是源码 tag diff:`git diff v<旧>..v<新>`(每版都推 `v<版本>` tag);npm 包页也镜像 CHANGELOG。
9
9
 
10
+ ## 7.99.0 —— 三条 BREAKING(行为面 / 类型面 / 运行时下限)
11
+
12
+ - **显式签发方不再算托管证据(7.99.0,S-671 / B-10;按 clay 裁定「显式签发方不算托管证据」)**:`PRINCIPAL_JWT_*` / `AUTH_BRIDGE_ISSUER` 在场而 `REQUIRE_PRINCIPAL` 未设的部署,7.95–7.98 拒启,7.99.0 起服;托管 ⇔ 运维显式声明 `REQUIRE_PRINCIPAL=true`。**谁受伤**:靠这条推断顶着没设 `REQUIRE_PRINCIPAL` 的多租机器,升级后不再被拦(召回缺口如实登记)。**迁移**:多租机器自己设 `REQUIRE_PRINCIPAL=true`(+ `OPERATOR_PRINCIPALS`),见 `docs/DEPLOY-PREREQS.md` 托管形前置。
13
+ - **`ServiceConfig.configCenter.token` 删除,换成 `credential`(7.99.0,S-651 波 A-1,类型面)**:凭证改为一只每次请求取值的活对象;新旋钮 `SEMA_REGISTRY_TOKEN_FILE`(与 `SEMA_REGISTRY_TOKEN` 二选一,都设 ⇒ 拒启)。手铸 `ServiceConfig` 的消费方 tsc 即红。**盘上数据**:旧 LKG 文件(`config-lkg-<worker>.json`,formatVersion 1)按凭证身份分箱后一次性迁移 —— 此刻凭证是该 worker 的拉取令牌且新箱无文件 ⇒ 迁进新箱、旧文件改名 `.migrated-v1`;其它形响亮拒一行(点名旧路径与出路);迁移代码到期删除(S-683,10-31)。旧 prompt 活动状态目录暂不迁移(S-685)。
14
+ - **Node 运行时下限改成实测值 `^20.19.0 || >=22.12.0`(7.99.0)**:此前 `>=20` 是假话 —— 7.98.0 在 Node 20.0–20.18 与 22.0–22.11 上本就起不来(依赖 e2b 的 CJS 入口撞 chalk@5 纯 ESM,S-682);开了 engine-strict 的安装在范围外会失败。**迁移**:Node 升到 20.19+ 或 22.12+。
15
+
10
16
  ## SQL 存储面 BREAKING(3.0.0 之后的三个窗)
11
17
 
12
18
  **常设口径**:本仓**不出 `ALTER TABLE` 增量迁移**(成文裁定:预生产期零存量用户窗口,schema 变更
@@ -95,3 +101,8 @@ file/local 存储形(未配 SQL 后端)的部署不受本节任何条目影响
95
101
  - **内网坐标默认值清理(1.180.0,npm 包卫生)**:`MODEL_GATEWAY_BASEURL` 缺省从内部网关 IP 改为
96
102
  `http://127.0.0.1:8000/v1` 占位——**依赖旧缺省的部署必须显式配置**;配了 `OA_ISSUE_TOKEN` 的部署
97
103
  现在必须同时给 `OA_ISSUE_BASEURL` 或 `GIT_API_BASEURL`(不再有烤死的内网主机兜底,缺失=启动即错)。
104
+ - **模型网关端点去出厂缺省(7.98.0,S-623;上一条那个占位本身也删了)**:`MODEL_GATEWAY_BASEURL` **没有出厂缺省**。配置能派发到的
105
+ 每一条 openai 兼容路由都必须解析到显式端点(目录条目自己的 `baseUrl`,或本键),否则拒启(码
106
+ `config.model_route_endpoint_missing`,拒因点名模型并给出本机写法 `MODEL_GATEWAY_BASEURL=http://127.0.0.1:8000/v1`);
107
+ 只设 `MODEL_GATEWAY_FALLBACK_URLS` 不设本键同拒。**你要做什么**:凡隐式依赖本机 8000 网关的部署,显式写上那一行;
108
+ anthropic 单路由部署不受影响。没设网关时 `MODEL_API_KEY` 不装载(它只与网关配对)。
package/README.md CHANGED
@@ -78,7 +78,7 @@ model gateways, and cloud agent execution behind an HTTP/SSE contract.
78
78
 
79
79
  ## Quick start
80
80
 
81
- Requirements: Node ≥ 20 (npm path) and an OpenAI-compatible model gateway.
81
+ Requirements: Node 20.19+ or 22.12+ (npm path; `engines.node` = `^20.19.0 || >=22.12.0`) and an OpenAI-compatible model gateway.
82
82
 
83
83
  > **A note on the `glob@11` deprecation warning at install time.** `npm install` prints a deprecation
84
84
  > warning for `glob@11.1.0`, pulled in transitively by `e2b` (the E2B sandbox SDK). It is **install-time
@@ -155,9 +155,9 @@ The server is configured entirely through environment variables. The most import
155
155
  |----------|---------|--------------|
156
156
  | `PORT` | `8090` | HTTP listen port |
157
157
  | `BIND_HOST` (alias `HOST`) | see note | Listen address. An explicit `BIND_HOST` **always wins**. Default: `127.0.0.1` when the write face is unauthenticated (`ALLOW_UNAUTHED_WRITES=true` **and** no service token configured), otherwise all interfaces — deployments with a token are unaffected. Since 3.15.0 the `HOST` alias is **not** fully equivalent: shells commonly set `HOST` to the machine name without the operator knowing, so when the narrowing condition above holds it wins over an inherited `HOST` (logged as `bind_host_from_HOST_env_overridden`). Set `BIND_HOST` explicitly, or configure a service token, to expose the write face. |
158
- | `MODEL_GATEWAY_BASEURL` | `http://127.0.0.1:8000/v1` | OpenAI-compatible gateway base URL (without `/chat/completions`) |
158
+ | `MODEL_GATEWAY_BASEURL` | **no default** | OpenAI-compatible gateway base URL (without `/chat/completions`). **No factory default** (S-623): every route that rides the openai-compatible lane must resolve to an explicit endpoint — the model's own catalog `baseUrl`, or this knob — else the server refuses to boot, naming the models and printing the explicit local spelling (`MODEL_GATEWAY_BASEURL=http://127.0.0.1:8000/v1`). An Anthropic-only deployment may leave it unset. Setting `MODEL_GATEWAY_FALLBACK_URLS` without it is refused too |
159
159
  | `MODEL_ID` | **required** | Default model id — **no factory default since 3.0.0**. Unset ⇒ the server refuses to boot with a message naming the knob (the old baked-in default was an internal-only model name, so every external deployment failed later and further from the cause: a gateway `400` plus a cascade of title-hook warnings). Set it to whatever model name your gateway serves, or supply the catalog via the config-center control plane |
160
- | `MODEL_API_KEY` | — | Gateway API key (optional) |
160
+ | `MODEL_API_KEY` | — | Gateway API key (optional). Pairs only with `MODEL_GATEWAY_BASEURL`: with no gateway it is not loaded and sent nowhere |
161
161
  | `SERVICE_AUTH_TOKEN` | — | Callers must send `Authorization: Bearer <token>` |
162
162
  | `DB_BACKEND` | `local`* | `mysql` (any MySQL-protocol DB: MySQL/TiDB/MariaDB) / `pg` (PostgreSQL) / `local` (file-backed, no DB) / `memory` (explicit in-memory: nothing survives a restart, durable-runs faces 501). *Bare boot (no DB env at all) defaults to `local` so a single-user machine keeps its runs across restarts; any SQL signal (`SESSION_BACKEND` or `MYSQL_/PG_HOST`) keeps the `mysql` engine default, and `REQUIRE_PRINCIPAL=true` bare boots stay `memory`. (`TIDB_HOST`/`_PORT`/`_USER`/`_PASSWORD`/`_DATABASE`/`_POOL_SIZE` are retired names, not an alias — since 5.0.0 setting any of them refuses to boot, pointing at the `MYSQL_*` replacement; TiDB itself connects fine through `MYSQL_*`, since it speaks the MySQL protocol.) (the local file store has no tenant isolation — a warning says so). A DEFAULT-derived `local` that cannot create its data root degrades to memory with a warning + the `store_backend_degraded` gauge; an EXPLICIT `DB_BACKEND=local` fails loud instead. Setting `mysql`/`pg` explicitly also switches sessions to durable |
163
163
  | `SESSION_BACKEND` | `memory`* | `memory` / `mysql` (durable session center) / `auto`. *Defaults to durable when `DB_BACKEND` is explicitly `mysql`/`pg`. (`tidb` is a RETIRED public name — setting it refuses to boot; since 7.57.0 `GET /v1/config/catalog` also echoes the public word `mysql` instead of the internal `tidb` label) |
package/README.zh-CN.md CHANGED
@@ -72,7 +72,7 @@
72
72
 
73
73
  ## 快速开始
74
74
 
75
- 环境要求:Node ≥ 20(npm 路径)+ 一个 OpenAI 兼容模型网关。
75
+ 环境要求:Node 20.19+ 或 22.12+(npm 路径;`engines.node` = `^20.19.0 || >=22.12.0`)+ 一个 OpenAI 兼容模型网关。
76
76
 
77
77
  > **关于安装时那条 `glob@11` 弃用警告。** `npm install` 会为 `glob@11.1.0` 打一条 deprecated 警告,它由
78
78
  > `e2b`(E2B 沙箱 SDK)间接引入。这是**安装期噪声,在这里没有任何运行时曝露面**:`glob` 在 `e2b` 里的
@@ -135,9 +135,9 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
135
135
  |------|------|------|
136
136
  | `PORT` | `8090` | HTTP 监听端口 |
137
137
  | `BIND_HOST`(别名 `HOST`) | 见说明 | 监听地址。显式 `BIND_HOST` **恒生效**。缺省:写面无鉴权时(`ALLOW_UNAUTHED_WRITES=true` **且**未配任何 service token)= `127.0.0.1`,否则全接口——配了 token 的部署不受影响。自 3.15.0 起 `HOST` 别名**不再完全等效**:shell 常把 `HOST` 设成机器名而 operator 并不知情,于是上述收窄条件成立时,收窄压过继承来的 `HOST`(告警事件 `bind_host_from_HOST_env_overridden`)。要暴露写面,显式设 `BIND_HOST`,或配一个 service token。 |
138
- | `MODEL_GATEWAY_BASEURL` | `http://127.0.0.1:8000/v1` | OpenAI 兼容网关地址(不带 `/chat/completions`) |
138
+ | `MODEL_GATEWAY_BASEURL` | **无缺省** | OpenAI 兼容网关地址(不带 `/chat/completions`)。**无出厂缺省**(S-623):每一条落 openai 兼容腿的路由都必须解析到显式端点 —— 目录条目自己的 `baseUrl`,或本键 —— 否则拒启,拒因点名模型并给出本机写法(`MODEL_GATEWAY_BASEURL=http://127.0.0.1:8000/v1`)。anthropic 单路由部署可不设。只设 `MODEL_GATEWAY_FALLBACK_URLS` 不设本键同拒 |
139
139
  | `MODEL_ID` | **必填** | 缺省模型 id ——**3.0.0 起无出厂缺省**。未设 = 启动即失败并指路该旋钮(旧的烤死缺省是内网模型名,外部部署必炸且炸在离根因最远处:网关 `400` + 标题 hook 连环告警)。填你的网关真正提供的模型名,或改由配置控制面下发目录 |
140
- | `MODEL_API_KEY` | — | 网关 key(可选) |
140
+ | `MODEL_API_KEY` | — | 网关 key(可选)。只与 `MODEL_GATEWAY_BASEURL` 配对:没设网关时不装载、不发往任何路由 |
141
141
  | `SERVICE_AUTH_TOKEN` | — | 调用方需带 `Authorization: Bearer <token>` |
142
142
  | `DB_BACKEND` | `local`* | SQL 引擎:`mysql`(任何 MySQL 协议库:MySQL/TiDB/MariaDB —— TiDB 走这个值,**没有** `tidb` 别名,写它启动即拒)/ `pg`(PostgreSQL)/ `local`(免 DB 文件持久化)/ `memory`(显式纯内存)。显式设置 `mysql`/`pg` 时 session 自动转 durable。*单租户裸 boot 缺省 `local`;`REQUIRE_PRINCIPAL=true` 的多租户裸 boot 缺省 `memory`(local 与多租户互斥) |
143
143
  | `SESSION_BACKEND` | `memory`* | `memory` / `mysql`(durable 会话中心)/ `auto`。*显式 `DB_BACKEND=mysql/pg` 时默认转 durable。(`tidb` 是**退役公名**,设了拒启;7.57.0 起 `GET /v1/config/catalog` 的回显也归一成公名 `mysql`,不再吐内部标签 `tidb`) |
package/USAGE.md CHANGED
@@ -56,6 +56,12 @@ MODEL_GATEWAY_BASEURL=http://gw-a:8000/v1 MODEL_GATEWAY_FALLBACK_URLS=http://gw
56
56
  ANTHROPIC_API_KEY=sk-ant-… # 可选:ANTHROPIC_BASE_URL / ANTHROPIC_VERSION / ANTHROPIC_CACHE_BREAKPOINTS=true
57
57
  ```
58
58
  - 两个变量都不设 = 和以前**逐字节一致**(单网关)。
59
+ - 🔴 **`MODEL_GATEWAY_BASEURL` 没有出厂缺省**(S-623,BREAKING):每一条落 openai 兼容腿的路由(主模型 / cheap 槽 /
60
+ 目录条目 / 降级目标 / titler 所选模型)都必须解析到显式端点 —— 条目自己的 `baseUrl`,或本键 —— 否则拒启,拒因
61
+ (码 `config.model_route_endpoint_missing`)点名解析不到的模型并给出本机写法 `MODEL_GATEWAY_BASEURL=http://127.0.0.1:8000/v1`。
62
+ 只设 `MODEL_GATEWAY_FALLBACK_URLS` 不设主网关同拒(`config.gateway_fallback_without_primary`)。只走 Anthropic 路由的部署
63
+ 不设网关合法;此时 `MODEL_API_KEY`(网关的凭据)不装载、不发往任何路由(启动通知 `model_gateway_key_unpaired`)。
64
+ 中心 / config.d 下发的候选目录里有解析不到的条目 ⇒ 候选整批拒、上一代继续服务。
59
65
  - **failover ≠ Anthropic↔vLLM**:failover 给所有 brain 发**同一个 model**(同协议同 id 的冗余);云↔本地是**按 `model.provider` 路由**的选择,不是故障转移(两者 model id/参数不同,不能透明互切)。设 `MODEL_PROVIDER=anthropic` + `MODEL_ID=claude-…` 让整个服务走 Anthropic。
60
66
  - **缺省推断**:`MODEL_PROVIDER` **未设**、但显式配了 Anthropic 协议 base URL(`ANTHROPIC_BASE_URL`)**和**对应凭证(`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`)时,缺省自动判 `anthropic` 并打一行启动 warn(`model_provider_inferred`)——纯净机直连 Anthropic 兼容上游不再需要显式第七个键。显式 `MODEL_PROVIDER` 恒赢;只给 base URL 或只给凭证不推断;两者皆无 = `gateway`,与以前逐字节一致。
61
67
  - 启动日志的 `brain` 字段会回显当前组合(`failover` / `anthropicRoute` / `anthropicCacheBreakpoints`)。
@@ -486,6 +492,9 @@ SEND_USER_FILE_SANDBOX_PUT_ENDPOINT=… # 可选:沙箱直传 PUT 的端
486
492
  # 新名(orchestrator 现注入);旧名 CONFIG_CENTER_* 在场即 boot 拒启,只认 SEMA_REGISTRY_*
487
493
  SEMA_REGISTRY_URL=http://<config-center-host>:3100 # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
488
494
  SEMA_REGISTRY_TOKEN=<SERVICE_PULL_TOKEN 的值> # 取自配置控制面主机 .env;只读拉取令牌
495
+ # SEMA_REGISTRY_TOKEN_FILE=/path/to/token # 或:凭证文件(与上一行二选一,都设=拒启)。每条中心请求都重读 ⇒ 轮换 / 换账号改写文件即可,
496
+ # 不重启;文件不可读 = error 级 center_credential_unreadable,不当作无凭证。
497
+ # 用户 JWT 形(CLI 登录)⇒ skill 正文 / prompt 产物走 /api/v1/me/…;盘上 LKG 与 prompt 状态按「worker + 凭证身份」分箱。
489
498
  SEMA_REGISTRY_DRY_RUN=true # 安全灰度:只 LOG 中心配置 vs env 推导的差异,不 apply
490
499
  SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worker=<名> 取该 worker 的 roster(reconciler 按 worker 注);不设=全局 roster(向后兼容)
491
500
  FLEET_ADVERTISE_ADDRESS=http://<本机可达IP>:8090 # 可选:设了才启 fleet 上报腿(announce/heartbeat+usage 批报到中心)。
@@ -776,20 +785,20 @@ TASK_WRITE_FACE_ESCALATION=allow # 词表 allow|refuse;坏词启动期响亮
776
785
  | `allow` | 任意 | **放行**(部署允许任务层开写面) |
777
786
  | `refuse` | 任意 | **422 拒**(运维显式表态**无条件**恒赢,不挂任何客户端派生腿) |
778
787
  | 未设 | 否(单用户 turnkey) | **放行** —— 与 7.94 及以前逐字节相同 |
779
- | 未设 | **是** | **422 拒**,拒因点名 `TASK_WRITE_FACE_ESCALATION=allow` 与判成托管的证据 |
788
+ | 未设 | **是**(`REQUIRE_PRINCIPAL=true`) | **422 拒**,拒因点名 `TASK_WRITE_FACE_ESCALATION=allow` 与判成托管的证据 |
780
789
 
781
790
  - 🔴 **`REMOTE_EXEC=device` 车道上本门不适用**(任何 `REQUIRE_PRINCIPAL` 值):那条车道的路径落在
782
791
  **发起者自己的设备**上,归属由 `device_session` 绑定行 + 准入链的 owner 谓词验过 —— host 闸防的
783
792
  跨租户宿主穿越在那儿**结构性不存在**(成文裁定,与 `settings.env` / `cwd` 两键在该车道上的既有臂同源),
784
793
  也叠加「device 车道不额外加一道审批」这条既定纪律。⚠️ 运维的**显式** `refuse` 在 device 上照样恒赢(旋钮不挂车道派生腿)。
785
- - **「托管形」怎么判**(判据单点 `src/hosted-posture.ts`,两类三条证据,任一成立即托管):
786
- ① `REQUIRE_PRINCIPAL=true`(定义式);② `PRINCIPAL_JWT_PUBKEYS` / `_ISS` / `_AUD` 任一在场;
787
- ③ `AUTH_BRIDGE_ISSUER` **显式**在场。②③ 的共同标准是**运维点名了一个外部身份签发方** ——
788
- 一台机器一个可信主人的形不需要向任何人验签。
789
- 🔴 **「受控于控制面」不算证据**:`SEMA_REGISTRY_*`(含 worker 名)与 auth-bridge 那条
790
- `?? configCenter.baseUrl` 回退**都不读** —— fleet 管理轴 ≠ 租户轴(一台 `sema run-local` 从组织下发面
791
- 取配置是正当用法)。其余被排除的候选(`OPERATOR_PRINCIPALS`、`SERVICE_AUTH_TOKENS` 条目数、
792
- `DB_BACKEND`、`BIND_HOST`)逐条写在 `docs/DEPLOY-PREREQS.md`。
794
+ - **「托管形」怎么判**(判据单点 `src/hosted-posture.ts`;7.99.0 起 / S-671):
795
+ **托管 ⇔ `REQUIRE_PRINCIPAL=true`**,只认运维自己的声明,零推断。
796
+ 🔴 **签发方配置不算证据**:`PRINCIPAL_JWT_PUBKEYS` / `_ISS` / `_AUD` 与 `AUTH_BRIDGE_ISSUER` 在不在场都不改变判定
797
+ (7.95.0–7.98.x 曾把它们当证据,并对「配了签发方却没设 `REQUIRE_PRINCIPAL`」的机器拒启;那条拒启随之删除)。
798
+ auth-bridge 那条 `?? configCenter.baseUrl` 回退、`SEMA_REGISTRY_*`(含 worker 名)同样不读。
799
+ ⚠️ 反面是**全量召回缺口**:忘设 `REQUIRE_PRINCIPAL` 的团队机器按单用户运行、本门放行 —— 多租机器必须自己设它,
800
+ 后果与被排除的候选(`OPERATOR_PRINCIPALS`、`SERVICE_AUTH_TOKENS` 条目数、`DB_BACKEND`、`BIND_HOST`)逐条写在
801
+ `docs/DEPLOY-PREREQS.md`。
793
802
  - **同族的 `approverPosture` 是「忽略 + 留声」,不是拒**:提交体的 `approverPosture` 同样是一句宿主姿态
794
803
  声明(引擎据此把 bypass 指令渲给模型 —— 那段文字指示模型改用 Bash 改文件而不是用文件工具),
795
804
  托管形上它被**忽略**并留一条 `task_approver_posture_ignored`(与 `settings.hooks` / `cwd` /
@@ -13,6 +13,8 @@
13
13
  # ALLOW_SMALL_CLUSTER=true 放行 <3 机(dev/CI 单节点验流程;生产必须 >=3 奇数)
14
14
  set -euo pipefail
15
15
  HERE="$(cd "$(dirname "$0")" && pwd)"
16
+ # `.env` 的文法与全部读写原语只在 lib/envf.sh 定义(S-618 续):本脚本不再有自己的 setk / `. "$ENVF"`。
17
+ . "$HERE/lib/envf.sh"
16
18
  log() { printf '[cluster-up] %s\n' "$*" >&2; }
17
19
  die() { log "⛔ $*"; exit 1; }
18
20
 
@@ -297,14 +299,16 @@ fi
297
299
  log "7/8 sema-stack 渲染+apply(engine=${STACK_ENGINE:-render})"
298
300
  : "${MODEL_GATEWAY_BASEURL:?模型面必需(--gateway)}"; : "${MODEL_ID:?模型面必需(--model)}"
299
301
  ENVF="$WORK/.env"; touch "$ENVF"; chmod 600 "$ENVF"
300
- setk() { grep -q "^$1=" "$ENVF" || printf '%s=%s\n' "$1" "$2" >> "$ENVF"; }
301
302
  setk MINIO_PASSWORD "$(openssl rand -hex 24)"
302
303
  setk SERVICE_AUTH_TOKEN "$(openssl rand -hex 24)"
303
- # openssl 失败会被 $() 吞成空串写进 .env(set -e 不管参数位替换)——断言长度,fail-loud
304
+ # openssl 失败会被 $() 吞成空串写进 .env(set -e 不管参数位替换)——断言长度,fail-loud(经严格读口读,不 grep 原文)
304
305
  for k in MINIO_PASSWORD SERVICE_AUTH_TOKEN; do
305
- grep -q "^$k=.\{32,\}$" "$ENVF" || die "$k 生成失败(openssl rand?)——检查 $ENVF"
306
+ _v="$(envf_get "$k")" || exit 1
307
+ [ "${#_v}" -ge 32 ] || die "$k 生成失败(openssl rand?)——检查 $ENVF"
306
308
  done
307
- . "$ENVF"
309
+ # A(S-618 续):`.env` 不 source —— 只回读本脚本自己落盘的两只凭据;输入键(MODEL_API_KEY / 网关 / 镜像……)
310
+ # 以本次传入的环境为准,不再被 .env 旧值覆盖。
311
+ envf_load MINIO_PASSWORD SERVICE_AUTH_TOKEN
308
312
  [ -n "${MODEL_API_KEY:-}" ] || die "需要 MODEL_API_KEY(-f 配置或 --model-key)"
309
313
  # PG 实例数/反亲和/副本数按机器数缩放(<3 机=dev:preferred,required 会永久 Pending)
310
314
  PG_INSTANCES=$(( MACHINE_COUNT >= 3 ? 3 : MACHINE_COUNT ))
@@ -14,6 +14,9 @@
14
14
  # [NAMESPACE=sema][STORAGE_CLASS=集群默认][CNPG_VERSION]
15
15
  set -euo pipefail
16
16
  HERE="$(cd "$(dirname "$0")" && pwd)"
17
+ # `.env` 的文法与全部读写原语(envf_get / envf_has / envf_load / envf_put / setk / setk_new / resetk)只在这一处
18
+ # 定义,sema-up.sh / cluster-up.sh / smoke.sh 同源(S-618 续):本脚本不再有自己的 setk / has_env / `. "$ENVF"`。
19
+ . "$HERE/lib/envf.sh"
17
20
  log() { printf '[kube-up] %s\n' "$*" >&2; }
18
21
  die() { log "⛔ $*"; exit 1; }
19
22
 
@@ -139,9 +142,6 @@ $KUBECTL rollout status deployment/cnpg-controller-manager -n cnpg-system --time
139
142
  # ── 4. 凭据(.env 幂等,同 sema-up.sh 纪律)───────────────────────────────────────
140
143
  mkdir -p "$WORK"; chmod 700 "$WORK" 2>/dev/null || true
141
144
  touch "$ENVF"; chmod 600 "$ENVF"
142
- setk() { grep -q "^$1=" "$ENVF" || printf '%s=%s\n' "$1" "$2" >> "$ENVF"; }
143
- setk_new() { if grep -q "^$1=" "$ENVF"; then return 1; else printf '%s=%s\n' "$1" "$2" >> "$ENVF"; return 0; fi; }
144
- has_env() { grep -q "^$1=..*" "$ENVF"; }
145
145
  setk MINIO_PASSWORD "$(openssl rand -hex 24)"
146
146
  setk SERVICE_AUTH_TOKEN "$(openssl rand -hex 24)"
147
147
  NEW_REG_PW=false NEW_GIT_PW=false
@@ -154,11 +154,16 @@ if [ "$GIT_MODE" = "provision" ]; then
154
154
  setk GIT_ADMIN_USER "${GIT_ADMIN_USER:-sema}"
155
155
  setk_new GIT_ADMIN_PASSWORD "$(openssl rand -hex 24)" && NEW_GIT_PW=true
156
156
  fi
157
- # openssl 失败会被 $() 吞成空串写进 .env(set -e 不管参数位替换)——断言长度,fail-loud
157
+ # openssl 失败会被 $() 吞成空串写进 .env(set -e 不管参数位替换)——断言长度,fail-loud(经严格读口读,不 grep 原文)
158
158
  for k in MINIO_PASSWORD SERVICE_AUTH_TOKEN; do
159
- grep -q "^$k=.\{32,\}$" "$ENVF" || die "$k 生成失败(openssl rand?)——检查 $ENVF"
159
+ _v="$(envf_get "$k")" || exit 1
160
+ [ "${#_v}" -ge 32 ] || die "$k 生成失败(openssl rand?)——检查 $ENVF"
160
161
  done
161
- . "$ENVF"
162
+ # A(S-618 续):`.env` 不 source —— 只回读**本脚本自己落盘**的键(凭据 / 已登记的 OAuth 接线)到同名 shell 变量。
163
+ # 输入键(镜像 / 网关 / 模型 id / MODEL_API_KEY / GIT_URL / GIT_TOKEN)以本次传入的环境为准,不再被 .env 旧值覆盖
164
+ # (旧 source 会拿上一次的 GIT_TOKEN 盖掉这次传进来的新 token —— sema-up.sh connect 段早就踩过、改成了 resetk)。
165
+ envf_load MINIO_PASSWORD SERVICE_AUTH_TOKEN REGISTRY_ADMIN_PASSWORD REGISTRY_SESSION_SECRET REGISTRY_PULL_TOKEN \
166
+ GIT_ADMIN_USER GIT_ADMIN_PASSWORD REGISTRY_GITEA_BASE_URL REGISTRY_GITEA_OAUTH_ID REGISTRY_GITEA_OAUTH_SECRET
162
167
  [ -n "${MODEL_API_KEY:-}" ] || die "需要 MODEL_API_KEY(--model-key 或 -f 配置)"
163
168
 
164
169
  # 节点 IP(NodePort 入口;kind 场景宿主上不可达,summary 会给 port-forward 替代)
@@ -217,11 +222,30 @@ helm_apply() { # helm_apply <giteaBaseUrl> <oauthId> <oauthSecret>
217
222
  || die "helm upgrade --install sema-stack 失败。下一步:helm -n $NAMESPACE status sema-stack;kubectl -n $NAMESPACE get events --sort-by=.lastTimestamp | tail"
218
223
  }
219
224
  log "2/6 helm 装配 sema-stack(-n $NAMESPACE,pg×$PG_INSTANCES server×$SERVER_REPLICAS registry=$REGISTRY_ENABLED gitea=$([ "$GIT_MODE" = provision ] && echo true || echo false))"
225
+ # git 目标的解析 + OAuth 接线的锚校验,都排在读 OA_* 之前。
226
+ # ① connect 的目标:**显式输入优先**;这次没给就沿用上次登记的连接(经严格读口;本脚本支持单独重跑,v7.97.0 靠
227
+ # source 恢复这两个值 —— 车MH codex r2 [medium])。目标变了却没给新 token ⇒ 拒,不把上一目标的 token 发给新服务。
228
+ # ② OAuth 接线只对它被创建时的 git 目标有效(锚 = REGISTRY_GITEA_BASE_URL):目标变了 ⇒ 作废、下面按新目标重建
229
+ # (建不成 = registry 走表单登录),不把旧身份源静默注入 helm(车MH codex r2 [high];sema-up.sh 两条路径同一条规则)。
230
+ if [ "$GIT_MODE" = "connect" ]; then
231
+ _saved_url="$(envf_get GIT_URL)" || exit 1
232
+ _saved_tok="$(envf_get GIT_TOKEN)" || exit 1
233
+ [ -n "${GIT_URL:-}" ] || GIT_URL="$_saved_url"
234
+ if [ -z "${GIT_TOKEN:-}" ]; then
235
+ if [ -n "$_saved_url" ] && [ "$GIT_URL" = "$_saved_url" ]; then GIT_TOKEN="$_saved_tok"
236
+ elif [ -n "$_saved_tok" ]; then die "git=connect 的目标与上次登记的不同,但这次没给 GIT_TOKEN —— 不把上一目标的 token 发给新服务。下一步:带 --git-token(GIT_TOKEN)重跑"
237
+ fi
238
+ fi
239
+ : "${GIT_URL:?git=connect 需要 GIT_URL}"; : "${GIT_TOKEN:?git=connect 需要 GIT_TOKEN}"
240
+ envf_invalidate_unless REGISTRY_GITEA_BASE_URL "$GIT_URL" REGISTRY_GITEA_OAUTH_ID REGISTRY_GITEA_OAUTH_SECRET
241
+ elif [ "$GIT_MODE" = "provision" ]; then
242
+ envf_invalidate_unless REGISTRY_GITEA_BASE_URL "http://$NODE_IP:30330" REGISTRY_GITEA_OAUTH_ID REGISTRY_GITEA_OAUTH_SECRET
243
+ fi
244
+ if [ "${ENVF_INVALIDATED:-0}" = 1 ]; then log "git 面:上一次登记的 OAuth 接线属于另一个 git 目标 —— 已作废,本次按新目标重建"; fi
220
245
  OA_URL="${REGISTRY_GITEA_BASE_URL:-}"; OA_ID="${REGISTRY_GITEA_OAUTH_ID:-}"; OA_SECRET="${REGISTRY_GITEA_OAUTH_SECRET:-}"
221
246
  # git=connect:先验连通(fail-loud),Gitea 顺手自动建 OAuth app
222
247
  GIT_MANUAL=""
223
248
  if [ "$GIT_MODE" = "connect" ]; then
224
- : "${GIT_URL:?git=connect 需要 GIT_URL}"; : "${GIT_TOKEN:?git=connect 需要 GIT_TOKEN}"
225
249
  if printf '%s' "$GIT_URL" | grep -q 'github.com'; then
226
250
  C=$(curl -m10 -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $GIT_TOKEN" https://api.github.com/user)
227
251
  [ "$C" = "200" ] || die "GitHub token 验证失败(HTTP $C)。下一步:检查 token 有效性/scope"
@@ -242,12 +266,13 @@ except Exception: print("")')
242
266
  try: print(json.load(sys.stdin).get("client_secret",""))
243
267
  except Exception: print("")')
244
268
  if [ -n "$OA_ID" ] && [ -n "$OA_SECRET" ]; then
245
- setk REGISTRY_GITEA_BASE_URL "$GIT_URL"; setk REGISTRY_GITEA_OAUTH_ID "$OA_ID"; setk REGISTRY_GITEA_OAUTH_SECRET "$OA_SECRET"
269
+ resetk REGISTRY_GITEA_BASE_URL "$GIT_URL"; resetk REGISTRY_GITEA_OAUTH_ID "$OA_ID"; resetk REGISTRY_GITEA_OAUTH_SECRET "$OA_SECRET"
246
270
  OA_URL="$GIT_URL"
247
271
  else GIT_MANUAL="OAuth app 自动创建失败(token 可能缺 write:user)——手动建后把 id/secret 写进 $ENVF 重跑"; fi
248
272
  fi
249
273
  fi
250
- setk GIT_URL "$GIT_URL"; setk GIT_TOKEN "$GIT_TOKEN"
274
+ # resetk 不是 setk:connect 目标是外部系统,用户随时可换(与 sema-up.sh connect 段同一条规则);值经写口编码。
275
+ resetk GIT_URL "$GIT_URL"; resetk GIT_TOKEN "$GIT_TOKEN"
251
276
  fi
252
277
  helm_apply "$OA_URL" "$OA_ID" "$OA_SECRET"
253
278
 
@@ -279,13 +304,13 @@ $KUBECTL rollout status deployment/sema-server -n "$NAMESPACE" --timeout=600s >/
279
304
  if [ "$GIT_MODE" = "provision" ]; then
280
305
  log "4/6 Gitea 引导"
281
306
  $KUBECTL rollout status deployment/sema-gitea -n "$NAMESPACE" --timeout=300s >/dev/null || die "Gitea 未就绪。下一步:kubectl -n $NAMESPACE logs deploy/sema-gitea"
282
- . "$ENVF"
307
+ # (旧 `. "$ENVF"` 删:GIT_ADMIN_USER / GIT_ADMIN_PASSWORD 在第 4 步已经经 envf_load 回读)
283
308
  gexec() { $KUBECTL exec deploy/sema-gitea -n "$NAMESPACE" -- su git -c "$*"; }
284
309
  if ! gexec "gitea admin user list" 2>/dev/null | awk '{print $2}' | grep -qx "$GIT_ADMIN_USER"; then
285
310
  gexec "gitea admin user create --admin --username '$GIT_ADMIN_USER' --password '$GIT_ADMIN_PASSWORD' --email '${GIT_ADMIN_USER}@sema.local' --must-change-password=false" >/dev/null \
286
311
  || die "Gitea 管理员创建失败。下一步:kubectl -n $NAMESPACE logs deploy/sema-gitea"
287
312
  fi
288
- if ! has_env GIT_ADMIN_TOKEN; then
313
+ if ! envf_has GIT_ADMIN_TOKEN; then
289
314
  TOK_NAME="sema-up-$(openssl rand -hex 3)"
290
315
  GTOK=$($KUBECTL exec deploy/sema-gitea -n "$NAMESPACE" -- \
291
316
  curl -m10 -s -u "$GIT_ADMIN_USER:$GIT_ADMIN_PASSWORD" -X POST "http://127.0.0.1:3000/api/v1/users/$GIT_ADMIN_USER/tokens" \
@@ -296,8 +321,8 @@ except Exception: print("")')
296
321
  [ -n "$GTOK" ] || die "Gitea access token 生成失败。下一步:kubectl -n $NAMESPACE logs deploy/sema-gitea"
297
322
  setk GIT_ADMIN_TOKEN "$GTOK"
298
323
  fi
299
- . "$ENVF"
300
- if [ "$REGISTRY_ENABLED" = "true" ] && ! has_env REGISTRY_GITEA_OAUTH_ID; then
324
+ envf_load GIT_ADMIN_TOKEN # A:回读刚生成 / 已存的 token,不 source .env
325
+ if [ "$REGISTRY_ENABLED" = "true" ] && ! envf_has REGISTRY_GITEA_OAUTH_ID; then
301
326
  REDIRECT="http://$NODE_IP:30300/api/auth/oauth/callback"
302
327
  RESP=$($KUBECTL exec deploy/sema-gitea -n "$NAMESPACE" -- \
303
328
  curl -m10 -s -H "Authorization: token $GIT_ADMIN_TOKEN" -X POST "http://127.0.0.1:3000/api/v1/user/applications/oauth2" \
@@ -309,7 +334,7 @@ except Exception: print("")')
309
334
  try: print(json.load(sys.stdin).get("client_secret",""))
310
335
  except Exception: print("")')
311
336
  if [ -n "$OA_ID" ] && [ -n "$OA_SECRET" ]; then
312
- setk REGISTRY_GITEA_BASE_URL "http://$NODE_IP:30330"; setk REGISTRY_GITEA_OAUTH_ID "$OA_ID"; setk REGISTRY_GITEA_OAUTH_SECRET "$OA_SECRET"
337
+ resetk REGISTRY_GITEA_BASE_URL "http://$NODE_IP:30330"; resetk REGISTRY_GITEA_OAUTH_ID "$OA_ID"; resetk REGISTRY_GITEA_OAUTH_SECRET "$OA_SECRET"
313
338
  log "Gitea→registry OAuth 已接,helm 二遍升级注入"
314
339
  helm_apply "http://$NODE_IP:30330" "$OA_ID" "$OA_SECRET"
315
340
  else
@@ -350,7 +375,7 @@ log "smoke ✓ health=$H auth=$A registry=$R gitea=$G"
350
375
 
351
376
  # ── summary(一屏;凭据一次性显示)────────────────────────────────────────────────
352
377
  log "6/6 完成"
353
- . "$ENVF"
378
+ # (旧 `. "$ENVF"` 删:汇总用到的凭据在第 4 步已经回读,GIT_URL 就是本次传入的值)
354
379
  {
355
380
  echo
356
381
  echo "══════════════ kube-up 完成($NODES 节点,mode=$KUBE_MODE)══════════════"
@@ -0,0 +1,241 @@
1
+ # shellcheck shell=bash
2
+ # lib/envf.sh — sema-up 家族 `.env` 的文法**与全部读写原语**,定义在这一处。
3
+ #
4
+ # 立场(S-618,一次收完 test 实测(srv796)报的一类):`.env` 是**数据**,永不 `source`。值里的
5
+ # `$(...)` / 反引号 / `;` / 空白在这里是字符串,不是代码;读值一律经数据解析(envf_get / envf_has /
6
+ # envf_load),写值一律经 envf_put(setk / setk_new / resetk 都落到它),绝不把 `.env` 交给 shell 执行。
7
+ #
8
+ # 使用者 = deploy/sema-up 下所有碰 `.env` 的脚本(sema-up.sh / kube-up.sh / cluster-up.sh / smoke.sh):
9
+ # 各自 `. "$HERE/lib/envf.sh"`,调用前设好 `ENVF`(`.env` 路径)。脚本里**不许**再有自己的 setk /
10
+ # has_env / `. "$ENVF"` / `printf '%s=%s' >> "$ENVF"` / `grep … "$ENVF"` —— 第二套写读规则迟早分叉
11
+ # (test/deploy-lanes-contract.test.ts「S-618 续」两道机器钉)。
12
+ #
13
+ # **一条文法,两半共用。** 文法的依据是 compose-go dotenv 的**真实**读法(车MV / S-646,k3s8 docker compose 2.40.3
14
+ # 逐形实跑的语料,金样钉在 test/deploy-lanes-contract.test.ts「S-656 + S-646」):一行 `KEY=<右半>` 的右半有三种形,
15
+ # 每种形里只有少数几个字符会让 compose **改写**读到的值 —— 不碰这几个字符,compose 读到的就是字面量:
16
+ # · 裸值 `KEY=v`:插值起点(`$` 后跟 `$` / `{` / 字母或 `_`,见 envf_has_interp)、「空格 + `#`」(行内注释;只认 ASCII 空格,`\t#` 是字面)、首尾空白(修剪 —— 开头按
17
+ # compose-go 自己的 isSpace = ASCII 空白 + U+0085 + U+00A0,结尾按 Go `unicode.IsSpace`,多出全角空格等 16 个)、
18
+ # 首字符是 `'` / `"`(转去引号形)。**`\` 在裸值里是字面量**(`k\x` ⇒ `k\x`,`abc\` ⇒ `abc\`,不续行);
19
+ # 空白、`;`、反引号、引号(不在首位)也都是字面量。
20
+ # · 单引号 `KEY='c'`:只有 `\'` 是转义(转义引号)。`\\` `\x` 原样。于是 c 里不能有 `'`,且 c 不能以**奇数个** `\`
21
+ # 结尾(最后那个 `\` 会把闭引号吃成转义引号:`unterminated quoted value`,或把后面几行吞进这个值、后续键静默落缺省)。
22
+ # 以偶数个 `\` 结尾照常闭合(`'abc\\'` ⇒ `abc\\`)。
23
+ # · 双引号 `KEY="c"`:`\` 走一张转义表(`\n` `\\` `\"` `\$` `\0NNN` …)、插值起点同裸值。c 里不碰 `"` `\`、没有插值起点
24
+ # 就是字面量。
25
+ # (刻意不认带 `\` 的双引号值:认它就得在这里抄 compose 的转义表。)
26
+ # 任何形之后同一行不许再有东西(闭引号后的二次赋值 = SL;行尾注释):规范行的右半整段就是这一形。
27
+ #
28
+ # · 读(`envf_canonical_line` + `envf_decode`,读口 envf_read 的判据):右半是上面三形之一**且不碰改写字符** ⇒ 认,
29
+ # 按字面解(裸值原样、引号形剥一对引号)—— 这正是 compose 读到的值;否则响亮拒并点名行号(不猜 compose 会改成什么)。
30
+ # 所以 ≤7.97.0 写口原样落盘的值(`p%ss;x!`、`x; touch …`、`k\x`、以 `\` 结尾……)只要 compose 按字面读,重跑就读得回。
31
+ # · 写(`envf_representable` + `envf_encode`):只写**更窄**的一形 —— 两个读者(compose 与 bash `source`)都读成同一字面量
32
+ # 的形。本脚本族 7.98.0 起已不 source `.env`,但运维自己的脚本、没升级的旧版 sema-up / kube-up 仍可能 source 它,写口
33
+ # 不替它们开执行面:安全裸词(ENVF_WORD)原样裸写;其余一律整值单引号(单引号里两个读者都字面:`$()` / 反引号 / `;`
34
+ # / 空白 / `\` 都不执行不展开)。**确实不可表示**的只有:换行 / 回车(一行一条赋值)、单引号本身(整值单引号里放不下;
35
+ # 裸写则 bash 语法错)、以奇数个 `\` 结尾(单引号形 compose 闭引号失效;裸写则 bash 当续行吞下一行)⇒ **写之前**响亮拒,
36
+ # 点名键、不回显值,「下一步」按拒因给。写口产物恒在读口文法内(安全裸词 ⊂ 裸值形,单引号编码形 ⊂ 单引号形)⇒ 往返成立。
37
+ #
38
+ # 收编(S-618):7.96.0「写口只拒换行 / 回车」的判据、`envf_get` 的物理行假设,连同曾经的「另立
39
+ # 一件做渲染对账」都收进来——写口判据 = 本文件的 `envf_representable`;物理行假设的兜底 = 生成 `.env`
40
+ # 之后、`compose up` 之前那一道 `docker compose config` 渲染对账(sema-up.sh `reconcile_render`,
41
+ # 以 compose 自己为唯一属主)。不再有第二套文法。
42
+ #
43
+ # 🔴 安全裸词是**正向白名单**(不是「排除危险字符」的负向类):`;` `&` `|` `<` `>` `(` `)` 这些 shell 命令分隔符 / 重定向
44
+ # 若留在写口的裸词里,任何 `source` 这份 .env 的读者会把 `x;id` 执行掉。决策键的合法值(true/false/host/e2b/k8s)、
45
+ # 端口(数字)、十六进制口令、URL(`https://h:port/p`)、镜像引用(`ghcr.io/x/y:1.0`)全在白名单内 ⇒ 仍裸写,.env 升级零 churn。
46
+ ENVF_WORD='[A-Za-z0-9_./:+=@-]'
47
+ # compose 修剪裸值首尾时认的空白,逐字列出(UTF-8 字节形),不用 `[[:space:]]`:那个类随区域变(C 区域只认 ASCII,UTF-8
48
+ # 区域可能多认全角空格)⇒ 同一份 .env 换个 LANG 判定就变。k3s8 compose 2.40.3 实测(车MV 语料):开头只修剪 compose-go 的
49
+ # isSpace(ASCII 空白 + U+0085 + U+00A0;开头的全角空格 / U+2000 是字面);结尾修剪 Go `unicode.IsSpace` 全集。7.98.0 的
50
+ # 读口没列非 ASCII 这几个 ⇒ `KEY=a<全角空格>` 读口读成 `a<全角空格>`、compose 读成 `a`(静默分叉,车MV 金样实测)。
51
+ ENVF_SPACE_LEAD=(' ' $'\t' $'\v' $'\f' $'\r' $'\xc2\x85' $'\xc2\xa0')
52
+ ENVF_SPACE_TRAIL=("${ENVF_SPACE_LEAD[@]}" $'\xe1\x9a\x80' $'\xe2\x80\x80' $'\xe2\x80\x81' $'\xe2\x80\x82' $'\xe2\x80\x83' \
53
+ $'\xe2\x80\x84' $'\xe2\x80\x85' $'\xe2\x80\x86' $'\xe2\x80\x87' $'\xe2\x80\x88' $'\xe2\x80\x89' $'\xe2\x80\x8a' \
54
+ $'\xe2\x80\xa8' $'\xe2\x80\xa9' $'\xe2\x80\xaf' $'\xe2\x81\x9f' $'\xe3\x80\x80')
55
+
56
+ # envf_odd_trailing_bs <s> —— s 以**奇数个** `\` 结尾 ⇒ 0。读口(单引号形)与写口(representable)同用这一只。
57
+ envf_odd_trailing_bs() {
58
+ local s="$1" n=0
59
+ while [ "${s%\\}" != "$s" ]; do s="${s%\\}"; n=$((n + 1)); done
60
+ [ $((n % 2)) -eq 1 ]
61
+ }
62
+
63
+ # envf_representable <value> —— 值能否写成写口的窄形(安全裸词或整值单引号)。
64
+ # 不能 ⇒ 非零 + 拒因写进 `ENVF_REJECT`、这一拒因的出路写进 `ENVF_REJECT_NEXT`(两者都**不含键、不含值**)。
65
+ envf_representable() {
66
+ case "$1" in
67
+ *$'\n'*) ENVF_REJECT='值里有换行'; ENVF_REJECT_NEXT='去掉值里的换行(.env 一行一条赋值,多行值写不进去)'; return 1 ;;
68
+ *$'\r'*) ENVF_REJECT='值里有回车'; ENVF_REJECT_NEXT='去掉值里的回车(.env 一行一条赋值)'; return 1 ;;
69
+ *\'*)
70
+ ENVF_REJECT='值里有单引号(整值单引号编码形里放不下单引号;裸写则任何 source 这份 .env 的 shell 会语法错)'
71
+ ENVF_REJECT_NEXT='换一个不含单引号的值(凭据可在签发方重新生成)'
72
+ return 1 ;;
73
+ esac
74
+ if envf_odd_trailing_bs "$1"; then
75
+ ENVF_REJECT='值以奇数个反斜杠结尾(单引号编码形里 compose 把最后的反斜杠与闭引号读成转义引号、闭引号失效;裸写则任何 source 这份 .env 的 shell 把它当续行)'
76
+ ENVF_REJECT_NEXT='换一个不以反斜杠结尾的值(结尾是偶数个反斜杠的值写得进;凭据可在签发方重新生成)'
77
+ return 1
78
+ fi
79
+ ENVF_REJECT='' ENVF_REJECT_NEXT=''
80
+ return 0
81
+ }
82
+
83
+ # envf_encode <value> —— 序列化出 `.env` 一行赋值的**右半**(值部分)。调用方须先过
84
+ # `envf_representable`。全安全裸词 ⇒ 原样(compose 与 bash source 都惰性,.env 升级零 churn);
85
+ # 否则 ⇒ 整值单引号(两个读者下都读成字面量,不续解析、不执行)。
86
+ envf_encode() {
87
+ if printf '%s' "$1" | grep -qE "^${ENVF_WORD}*\$"; then
88
+ printf '%s' "$1"
89
+ else
90
+ printf "'%s'" "$1"
91
+ fi
92
+ }
93
+
94
+ # envf_has_interp <s> —— s 里有 compose 插值的起点 ⇒ 0。compose-go 的 template 只认三种:`$$`(转义)、`${`(花括号;
95
+ # 里面不合法就整行报 Invalid template)、`$` + 变量名首字符。变量名首字符是 `[_a-z]` 在 Go `(?i)` 下的大小写折叠闭包 =
96
+ # ASCII 字母 + `_` + U+212A(开尔文符号,折叠到 k)+ U+017F(长 s,折叠到 s)—— 后两个是 codex 修复验证轮 [medium] 指出、
97
+ # k3s8 compose 2.40.3 22:14 实测确认的(`a$K` ⇒ `a`);`İ` `ı` `µ` `Å` 不在其列。其余的 `$`(后跟数字 / 标点 / 空白 /
98
+ # 其他非 ASCII / 串尾)是字面量(`p$9x` ⇒ `p$9x`,`p$` ⇒ `p$`,`a$é` ⇒ `a$é`;codex 一审 [medium])。
99
+ # 字母逐字列出、不写 `[a-z]` 区间:区间在 bash 3.2 的 UTF-8 区域按排序规则展开,会把 `é` 这类也算进去;两个折叠字符按
100
+ # UTF-8 字节串匹配,与区域无关。
101
+ ENVF_FOLD_KELVIN=$'\xe2\x84\xaa' ENVF_FOLD_LONG_S=$'\xc5\xbf'
102
+ envf_has_interp() {
103
+ case "$1" in
104
+ *'$$'* | *'${'* | *'$'[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_]*) return 0 ;;
105
+ *'$'"$ENVF_FOLD_KELVIN"* | *'$'"$ENVF_FOLD_LONG_S"*) return 0 ;;
106
+ esac
107
+ return 1
108
+ }
109
+
110
+ # envf_rhs_literal <rhs> —— 右半是否是 compose 按字面读的三形之一(见文件头)。0 = 是(`envf_decode` 解出的就是
111
+ # compose 读到的值)。纯 bash 模式匹配,不起子进程;bash 3.2 可用。
112
+ envf_rhs_literal() {
113
+ local v="$1" c u
114
+ case "$v" in
115
+ '') return 0 ;;
116
+ \'*\')
117
+ c="${v:1:${#v}-2}"
118
+ case "$c" in *\'*) return 1 ;; esac
119
+ envf_odd_trailing_bs "$c" && return 1
120
+ return 0 ;;
121
+ \"*\")
122
+ c="${v:1:${#v}-2}"
123
+ case "$c" in *\"* | *\\*) return 1 ;; esac
124
+ envf_has_interp "$c" && return 1
125
+ return 0 ;;
126
+ \'* | \"* | *" #"*) return 1 ;;
127
+ esac
128
+ envf_has_interp "$v" && return 1
129
+ for u in "${ENVF_SPACE_LEAD[@]}"; do
130
+ case "$v" in "$u"*) return 1 ;; esac
131
+ done
132
+ for u in "${ENVF_SPACE_TRAIL[@]}"; do
133
+ case "$v" in *"$u") return 1 ;; esac
134
+ done
135
+ return 0
136
+ }
137
+
138
+ # envf_canonical_line <key> <line> —— 该**整行**是否是 <key> 的规范赋值行:行首顶格 `KEY=`(无 export、键旁无空格)
139
+ # + 右半是 compose 按字面读的一形(envf_rhs_literal)。0 = 是。
140
+ envf_canonical_line() {
141
+ case "$2" in "$1="*) ;; *) return 1 ;; esac
142
+ envf_rhs_literal "${2#"$1="}"
143
+ }
144
+
145
+ # envf_decode <rhs> —— 规范行右半的字面值:引号形剥掉一对同型引号,裸值原样(空 = 未设)。只对过了 envf_rhs_literal
146
+ # 的右半调用 —— 那时这就是 compose 读到的值(裸值不会以引号开头,所以不会被误剥)。
147
+ envf_decode() {
148
+ local v="$1"
149
+ case "$v" in \"*\") v="${v#\"}"; v="${v%\"}" ;; \'*\') v="${v#\'}"; v="${v%\'}" ;; esac
150
+ printf '%s' "$v"
151
+ }
152
+
153
+ # envf_assign_line_re <key> —— 「任一 dotenv 方言下**可能**给 <key> 赋值的行」的匹配式:
154
+ # 行首空白? + `export `? + 键 + 空白? + `=` 或 `:`。决策键的「至多一行」判定用它。
155
+ envf_assign_line_re() { printf '^[[:space:]]*(export[[:space:]]+)?%s[[:space:]]*[=:]' "$1"; }
156
+
157
+ # ════════════════════════════════════════════════════════════════════════════════════════════════
158
+ # 读写原语(全家族唯一一份)。拒因走 stderr、带脚本名前缀;**不回显值**(可能是凭据),只点名键与行号。
159
+ # ════════════════════════════════════════════════════════════════════════════════════════════════
160
+ ENVF_TAG="${ENVF_TAG:-$(basename "$0" .sh)}"
161
+ envf_log() { printf '[%s] %s\n' "$ENVF_TAG" "$*" >&2; }
162
+
163
+ # envf_read <key> —— 严格读口的本体。设 ENVF_FOUND(0/1:有没有给 <key> 赋值的行)与 ENVF_VAL(解码后的值)。
164
+ # ① 凡是在任一 dotenv 方言下**可能**给 <key> 赋值的行(envf_assign_line_re)至多一行;② 那一行必须是规范形
165
+ # (envf_canonical_line)。违反 ⇒ 响亮拒并点名行号,返回 1。不进命令替换也能用(状态在两个全局里)。
166
+ envf_read() {
167
+ ENVF_FOUND=0 ENVF_VAL=""
168
+ [ -f "$ENVF" ] || return 0
169
+ local k="$1" hits n nums line
170
+ hits="$(grep -nE "$(envf_assign_line_re "$k")" "$ENVF" || true)"
171
+ [ -n "$hits" ] || return 0
172
+ n="$(printf '%s\n' "$hits" | wc -l | tr -d '[:space:]')"
173
+ nums="$(printf '%s\n' "$hits" | cut -d: -f1 | paste -sd, -)"
174
+ if [ "$n" -gt 1 ]; then
175
+ envf_log "⛔ $ENVF 里可能给 $k 赋值的行有 $n 行(第 $nums 行)—— 一个键只许一行"
176
+ envf_log " ↳ 下一步:只留一行规范形 $k=<值>,其余删掉"
177
+ envf_log " ↳ 为什么不挑一行用:compose 读 .env 的规则(export 前缀、键旁空格、重复键末次生效)与本脚本不同,挑哪一行都可能不是容器真正拿到的那一行"
178
+ return 1
179
+ fi
180
+ line="${hits#*:}"
181
+ if ! envf_canonical_line "$k" "$line"; then
182
+ envf_log "⛔ $ENVF 第 $nums 行给 $k 赋值,但不是本脚本认得的规范形"
183
+ envf_log " ↳ 下一步:改成 $k=<值>(行首顶格、不带 export、键旁无空格、行尾无注释;值是 compose 按字面读的一形:裸写 = 不以空白或引号开头、不以空白结尾、不含插值(\$ 后跟 \$ / { / 字母或 _)、不含「空格+#」;整值单引号 = 里面没有单引号、不以奇数个反斜杠结尾;整值双引号 = 里面没有双引号 / 反斜杠 / 插值),或删掉这一行"
184
+ envf_log " ↳ 为什么不猜:这一行 compose 怎么读(注释、空白、展开、闭引号后的二次赋值)与本脚本怎么读一旦不一致,容器拿到的就不是这里读到的值"
185
+ return 1
186
+ fi
187
+ ENVF_FOUND=1
188
+ ENVF_VAL="$(envf_decode "${line#*=}")"
189
+ }
190
+ # envf_get <key> —— 打印值(空 = 未设)。调用形恒为 `v="$(envf_get K)" || exit 1`。
191
+ envf_get() { envf_read "$1" || return 1; printf '%s' "$ENVF_VAL"; }
192
+ # envf_has <key> —— 有非空值 ⇒ 0。读到非规范行 ⇒ **退整个脚本**(不是「当它没有」)。
193
+ envf_has() { envf_read "$1" || exit 1; [ -n "$ENVF_VAL" ]; }
194
+ # envf_load <key>… —— `. "$ENVF"` 的数据版替身:逐键回读到同名 shell 变量。**只在 .env 里有非空值时才覆盖**
195
+ # (缺键保留 shell 现值:端口缺省 / summary 的 `:-` 兜底都靠这条)。printf -v 落值,不 eval。
196
+ envf_load() {
197
+ local _k
198
+ for _k in "$@"; do
199
+ envf_read "$_k" || exit 1
200
+ if [ -n "$ENVF_VAL" ]; then printf -v "$_k" '%s' "$ENVF_VAL"; fi
201
+ done
202
+ return 0
203
+ }
204
+
205
+ # envf_guard <key> <value> —— 值无法表示 ⇒ 写之前响亮拒(点名键、不回显值),退整个脚本。
206
+ envf_guard() {
207
+ envf_representable "$2" && return 0
208
+ envf_log "⛔ 要写进 $ENVF 的 $1 的值无法安全写成一条 .env 赋值($ENVF_REJECT)"
209
+ envf_log " ↳ 下一步:$ENVF_REJECT_NEXT —— 查 $1 对应的命令行参数或 -f 配置项后重跑;拒因不回显值"
210
+ exit 1
211
+ }
212
+ # envf_put —— `.env` 的**唯一**追加口:一次写入恰好一个物理行,值按文法序列化。
213
+ envf_put() { envf_guard "$1" "$2"; printf '%s=%s\n' "$1" "$(envf_encode "$2")" >> "$ENVF"; }
214
+ # setk <key> <value> —— 键不在才写(凭据只生成一次、重入不轮换)。「在不在」由严格读口判:旧文件里该键
215
+ # 那一行若不是规范形(比如同物理行闭引号带出的二次赋值),这里就响亮拒 —— 不再「见行在就跳过」把毒行原样留着。
216
+ setk() { envf_read "$1" || exit 1; [ "$ENVF_FOUND" = 1 ] || envf_put "$1" "$2"; }
217
+ # setk_new —— 同 setk,新写入时返回 0(summary 用来判断「密码只显示一次」)。
218
+ setk_new() { envf_read "$1" || exit 1; if [ "$ENVF_FOUND" = 1 ]; then return 1; fi; envf_put "$1" "$2"; return 0; }
219
+ # resetk <key> <value> —— 每次重写。先判后删(拒了就一行都不动);删的是**任一方言下可能给该键赋值的全部行**
220
+ # (与读口同一只匹配式),再追加一行规范形 —— 不留 `export K=…` 这类旧行在后面末次生效。
221
+ resetk() {
222
+ envf_guard "$1" "$2"
223
+ { grep -vE "$(envf_assign_line_re "$1")" "$ENVF" || true; } > "$ENVF.tmp"
224
+ mv "$ENVF.tmp" "$ENVF"; chmod 600 "$ENVF"
225
+ envf_put "$1" "$2"
226
+ }
227
+
228
+ # envf_invalidate_unless <锚键> <期望锚值> <派生键>… —— 一组派生键只对它被派生时的锚值有效(OAuth 接线之于它
229
+ # 被创建时的 git 目标:REGISTRY_GITEA_BASE_URL 是锚,client id / secret 是派生)。`.env` 里登记的锚值非空且
230
+ # ≠ 期望值 ⇒ 这组键(含锚)全部清成空值并清空同名 shell 变量,设 ENVF_INVALIDATED=1;否则一概不动。调用方据此
231
+ # 重建(建成后用 resetk 落新值)或如实退化 —— 不把上一目标的接线静默带进这一次(车MH codex r2 [high])。
232
+ envf_invalidate_unless() {
233
+ local anchor="$1" want="$2" k
234
+ shift 2
235
+ ENVF_INVALIDATED=0
236
+ envf_read "$anchor" || exit 1
237
+ if [ -z "$ENVF_VAL" ] || [ "$ENVF_VAL" = "$want" ]; then return 0; fi
238
+ ENVF_INVALIDATED=1
239
+ for k in "$anchor" "$@"; do resetk "$k" ""; printf -v "$k" '%s' ""; done
240
+ return 0
241
+ }