@sema-agent/server 7.96.0 → 7.98.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 (89) hide show
  1. package/MIGRATION.md +5 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/USAGE.md +10 -1
  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 +173 -0
  8. package/deploy/sema-up/sema-up.sh +113 -94
  9. package/deploy/sema-up/smoke.sh +8 -1
  10. package/dist/approval.d.ts +28 -0
  11. package/dist/approval.js +24 -0
  12. package/dist/boot/budget-tracing.d.ts +1 -0
  13. package/dist/boot/budget-tracing.js +3 -2
  14. package/dist/boot/config-center.js +4 -7
  15. package/dist/boot/coordinators.js +2 -2
  16. package/dist/boot/deferred-sandbox-path-env.d.ts +5 -2
  17. package/dist/boot/deferred-sandbox-path-env.js +3 -1
  18. package/dist/boot/memory-boundary.d.ts +17 -2
  19. package/dist/boot/memory-boundary.js +11 -0
  20. package/dist/boot/reapers.d.ts +3 -1
  21. package/dist/boot/reapers.js +2 -2
  22. package/dist/boot/resolve-spec.js +15 -10
  23. package/dist/boot/runner-deps.d.ts +2 -1
  24. package/dist/boot/session-faces.d.ts +4 -1
  25. package/dist/boot/session-faces.js +5 -5
  26. package/dist/boot/stage-04-stores.d.ts +1 -0
  27. package/dist/boot/stage-04-stores.js +2 -2
  28. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  29. package/dist/boot/stage-06-runners.d.ts +1 -0
  30. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  31. package/dist/boot/stage-07-capability-layer.js +10 -14
  32. package/dist/boot/stage-08-reapers.d.ts +2 -3
  33. package/dist/boot/stage-08-reapers.js +2 -2
  34. package/dist/boot/stage-09-leader.d.ts +1 -0
  35. package/dist/boot/stage-09-leader.js +2 -2
  36. package/dist/boot/stage-10-http-server.d.ts +1 -0
  37. package/dist/boot/stores.js +5 -11
  38. package/dist/brain.js +28 -18
  39. package/dist/capabilities/execution-lane.d.ts +5 -7
  40. package/dist/capabilities/execution-lane.js +2 -1
  41. package/dist/capabilities/memory-notice.d.ts +30 -12
  42. package/dist/capabilities/memory-notice.js +8 -1
  43. package/dist/capabilities/skills.d.ts +14 -0
  44. package/dist/capabilities/skills.js +4 -0
  45. package/dist/config-catalog.js +5 -4
  46. package/dist/config-center/apply-effective.js +7 -1
  47. package/dist/config-center/facade.d.ts +2 -1
  48. package/dist/config-invariants.d.ts +2 -2
  49. package/dist/config-invariants.js +9 -1
  50. package/dist/config-types.d.ts +20 -3
  51. package/dist/config.d.ts +9 -0
  52. package/dist/config.js +46 -8
  53. package/dist/env-facts.js +2 -4
  54. package/dist/execution-lane-caps.d.ts +36 -6
  55. package/dist/execution-lane-caps.js +4 -0
  56. package/dist/file-history-capture.d.ts +65 -0
  57. package/dist/file-history-capture.js +21 -0
  58. package/dist/hooks/hook-llm.d.ts +1 -1
  59. package/dist/hooks/hook-llm.js +3 -1
  60. package/dist/hosted-posture.d.ts +1 -1
  61. package/dist/http/resume-legs.d.ts +14 -4
  62. package/dist/http/resume-legs.js +26 -17
  63. package/dist/http/route-ctx.d.ts +35 -9
  64. package/dist/http/routes/approvals-assistant.d.ts +1 -1
  65. package/dist/http/routes/approvals-assistant.js +16 -6
  66. package/dist/http/routes/capabilities.js +2 -0
  67. package/dist/http/routes/runs.js +2 -2
  68. package/dist/http/routes/session-sync.js +6 -1
  69. package/dist/http/server.d.ts +4 -1
  70. package/dist/http/server.js +1 -1
  71. package/dist/http/verify-rounds.d.ts +1 -1
  72. package/dist/memory-scope.d.ts +68 -13
  73. package/dist/memory-scope.js +35 -5
  74. package/dist/model-provider.d.ts +13 -0
  75. package/dist/model-provider.js +3 -0
  76. package/dist/model-route-endpoint.d.ts +82 -0
  77. package/dist/model-route-endpoint.js +55 -0
  78. package/dist/observability/fail-open.d.ts +4 -0
  79. package/dist/observability/fail-open.js +4 -0
  80. package/dist/project-identity.d.ts +29 -0
  81. package/dist/project-identity.js +120 -0
  82. package/dist/run-local.d.ts +14 -4
  83. package/dist/run-local.js +12 -10
  84. package/dist/session-sync.d.ts +4 -1
  85. package/dist/session-sync.js +2 -2
  86. package/dist/task-cwd.d.ts +9 -3
  87. package/dist/task-cwd.js +3 -1
  88. package/dist/trace/redact.d.ts +1 -1
  89. package/package.json +1 -1
package/MIGRATION.md CHANGED
@@ -95,3 +95,8 @@ file/local 存储形(未配 SQL 后端)的部署不受本节任何条目影响
95
95
  - **内网坐标默认值清理(1.180.0,npm 包卫生)**:`MODEL_GATEWAY_BASEURL` 缺省从内部网关 IP 改为
96
96
  `http://127.0.0.1:8000/v1` 占位——**依赖旧缺省的部署必须显式配置**;配了 `OA_ISSUE_TOKEN` 的部署
97
97
  现在必须同时给 `OA_ISSUE_BASEURL` 或 `GIT_API_BASEURL`(不再有烤死的内网主机兜底,缺失=启动即错)。
98
+ - **模型网关端点去出厂缺省(7.98.0,S-623;上一条那个占位本身也删了)**:`MODEL_GATEWAY_BASEURL` **没有出厂缺省**。配置能派发到的
99
+ 每一条 openai 兼容路由都必须解析到显式端点(目录条目自己的 `baseUrl`,或本键),否则拒启(码
100
+ `config.model_route_endpoint_missing`,拒因点名模型并给出本机写法 `MODEL_GATEWAY_BASEURL=http://127.0.0.1:8000/v1`);
101
+ 只设 `MODEL_GATEWAY_FALLBACK_URLS` 不设本键同拒。**你要做什么**:凡隐式依赖本机 8000 网关的部署,显式写上那一行;
102
+ anthropic 单路由部署不受影响。没设网关时 `MODEL_API_KEY` 不装载(它只与网关配对)。
package/README.md CHANGED
@@ -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
@@ -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`)。
@@ -441,7 +447,9 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
441
447
  —— 没有任何东西把 B 的路径映到 A。要么把服务(或 `run-local`)跑在放着文件的那台机器上,要么走容器车道
442
448
  (`e2b`/`k8s`/`local-docker`),那里的工作区是沙箱自己的,`cwd` 被忽略。
443
449
  - **读面(S-426 起)**:`GET /v1/capabilities` 的 `executionLane` 把本部署的车道广告成
444
- `{provider:<REMOTE_EXEC 闭集词>, toolsOnThisHost:boolean}`(`REMOTE_EXEC` 未设 ⇒ `provider:"host"`)。
450
+ `{provider:<执行车道八词>, toolsOnThisHost:boolean}`。S-602 起 `REMOTE_EXEC` 未设 ⇒
451
+ `provider:"in-process"`、`toolsOnThisHost:false`(本部署没有执行车道 = 无手;改前折报 `"host"`/`true`)。
452
+ 要本机有手,显式设 `REMOTE_EXEC=host`(sema-up 产物已显式写车道)。
445
453
  壳/客户端判「工具跑不跑在本机」**读这一位**,不要再从自己这一侧的 `REMOTE_EXEC` 猜 —— 连远端引擎时
446
454
  那个 env 说的是**壳自己这台**的事。⚠️ 它是**文件系统同一性**位,**不是**权限位:`cwd` 兑不兑现仍由
447
455
  `projectContext` / `callerCwd` 回答(那两位的判据多一条单用户约束,多租户 host 部署上两者会分叉)。
@@ -473,6 +481,7 @@ SEND_USER_FILE_SANDBOX_PUT_ENDPOINT=… # 可选:沙箱直传 PUT 的端
473
481
  - **词表优先级**:内网端点 `MINIO_ENDPOINT` 优先,`S3_ENDPOINT` 是外接 S3 兼容位(内网/公网同址场景可只给它,同时充当公网面);公网端点 `S3_PUBLIC_ENDPOINT` 优先,缺席回落 `S3_ENDPOINT`。空串一律按未设处理。
474
482
  - **🔴 云形(`DB_BACKEND=mysql|pg`)快照 blob 强制对象存储(1.295+,clay 拍)**:缺 MinIO 三键 ⇒ **boot 拒启**——单行 SQL blob 写会撞 TiDB `txn-entry-size-limit`(默认 6MiB,真库实测矮墙)/ mysql 协议 `max_allowed_packet`,大字节归对象存储(与 D-1 附件面同裁定)。单机/测试台显式逃生:`SNAPSHOT_BLOB_ALLOW_SQL_BYTES=true`(SQL 店此时带 per-blob 帽,tidb 默认 6MiB,超限 PUT 413 `blob_too_large_for_sql`;帽可用 `SNAPSHOT_BLOB_SQL_MAX_BYTES` 按部署真实限值覆写,两方言生效;pg 默认无帽)。
475
483
  - **per-file rewind 历史的边界上限**(与 workspace 浏览面同一个整树快照纪元退役批留下的保留条款):`FILE_HISTORY_RETENTION_KEEP` —— 每个 scope(会话)存活的**最新** boundary 数,**每次 boundary 提交后自剪**(最旧者先亡,刚提交者恒存活;失引用的版本行级联删,blob 字节走异步 grace 窗 GC;v1 基线永存)。未设=core 缺省 **100**(由 core 的 `resolveFileHistoryRetention` 兑现,server 不复制那个数);`unbounded` 是**唯一**的「不剪」拼法(整店自剪关闭,GC 归部署自排 `reap`);`0`/负数/小数/非数 ⇒ **boot 拒启**(码 `config.retention_policy_invalid`,与 core 内置后端同码同判——**省略这个键不等于不剪**,它选的是缺省 100)。三条车道(TiDB / PG / local 文件店)同旋钮同判。⚠️ 升级即生效:存量超 100 boundary 的 scope 在下一次提交时被剪到 100;要保全量先写 `unbounded`。被剪 entry 的键**已花掉**(再次 snapshot 同 entry 典型拒,不复铸)。排序=DB 分配的 per-scope 发布序号 `file_history_boundary.publish_seq`(不是墙钟):**自 7.50.x 有存量 `file_history_boundary` 的部署升级本版会在 boot 期被 `assertFileHistorySchema` 拒启并指明 `DROP TABLE file_history_boundary`**(本仓无 ALTER;丢的是 per-file rewind 便利态,会话与其他持久面不动)。
484
+ - **文件历史捕获总开关**(S-589):`FILE_HISTORY_CAPTURE`(缺省 `true`;布尔词表 true/false/1/0/yes/no/on/off,未知词 ⇒ boot 拒启;TiDB / PG / local 三条车道同义)。`false` ⇒ 不把历史店交给引擎:改动文件的首次快照**一个都不捕获**;交换是同时没有 rewind(`resumeAt`+`restoreFiles` / `rewindFilesTo` 的 run 以 `rewind.store_unconfigured` 失败,文件不动)与 2c session-sync(整族 `501 capability.snapshot_store_required`)。`GET /v1/capabilities` 的 `fileHistoryCapture` 报 `"off"`,`rewindFiles` / `restoreFiles` / `rewindFilesTo` / `sessionSync` 同源翻 `false`(缺省部署报 `"on-always"` = 捕获中且无法按会话关闭)。⚠️ **关掉 ≠ 删掉**:翻旋钮不批量删除存量历史,存量也不再被读用;它照旧随 `DELETE /v1/sessions/:id` 与留存车道清理(擦除面不受本旋钮管;留存车道只在 SQL 后端,local 车道上删会话走 `reap` —— 边界删光、v1 基线按 core 契约留存,既有残余)—— 要清存量走会话删除或留存旋钮。关着时 `FILE_HISTORY_RETENTION_KEEP` 没有效果(不再有边界提交,自剪不会触发),值仍照常校验。翻旋钮后每个进程首个带手的 run 会有一条引擎的 `runner_config_advisory` warn(「file history is not live for this run」),是预期。
476
485
  - **🔴 公桶策略必须 GetObject-only**:`mc anonymous set download` 会**连带打开 ListBucket**——匿名 `GET /<bucket>/?list-type=2` 能枚举全部不可猜 key,能力链接设计即告失效。正确姿势=`mc anonymous set-json`,policy 只含 `Action:["s3:GetObject"]` on `arn:aws:s3:::<bucket>/*`(验证:对象 GET 200、桶 LIST 403)。
477
486
  - **两条轨**:ttl=0(默认)→ 匿名 GET 公桶下 `uuidv7/<name>` 不可猜 key,链接永久、可回收(删对象);0<ttl≤7 天 → 私桶 + SigV4 限时签名链接(7 天是 SigV4 物理上限,更久用 ttl=0)。
478
487
  - **执行 lane 与租户门**:e2b/k8s 沙箱 lane 任意租户可用(沙箱文件系统=租户边界,沙箱内 `curl -T` 直传、字节不中转、凭据不进沙箱);host/ssh lane 仅单用户部署(`REQUIRE_PRINCIPAL` 未开)时挂载。
@@ -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,173 @@
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 **与 bash `source`)都安全**——
14
+ # · 写(`envf_encode`):值全由安全裸词字符组成 ⇒ 原样裸写;否则(含空白 / shell 元字符 / `$` /
15
+ # 引号……但可表示)⇒ **整值单引号编码形**。为什么单引号在两个读者下都安全:compose-go dotenv 把
16
+ # `'…'` 读成字面量(不做 `$` 展开、闭引号后不再同行续解析 ⇒ SL 类「同物理行、闭引号的二次赋值」不
17
+ # 成立);bash `source` 里单引号内的内容一律字面(`$()` / 反引号 / `;` 全不执行)。值里有换行 / 回车 /
18
+ # 单引号,或值**以 `\` 结尾** ⇒ 无法表示 ⇒ **写之前**响亮拒(`envf_representable`),点名键、不回显值。
19
+ # 以 `\` 结尾为什么也写不出(7.98.0 合并复审 F3,k3s8 docker compose 2.40.3 实测):compose-go 的单引号串里 `\'`
20
+ # 是**转义引号**,于是 `'…\'` 的闭引号被吃掉,compose 一路读到下一个 `'`(`unterminated quoted value`,或把后面几行
21
+ # 吞进这个值、后续键静默落缺省、rc=0)。这是「两个读者都读成字面量」的精确边界:串中间的 `\` 两边都是字面。
22
+ # · 读(`envf_get`,在 sema-up.sh 里,用这里的 `ENVF_WORD` + 规范行判据):决策键回读只认「规范
23
+ # 行」——至多一行、行首顶格、无 `export`、键旁无空格、值是「安全裸词 | 单引号包住的任意非单引号串(不以 `\`
24
+ # 结尾,理由同上)| 双引号包住的安全裸词」;否则响亮拒并点名行号。**读口的单引号分支收 encode 写出的整个内部字符集**
25
+ # (否则写得进读不出、往返断,车MH codex 复审 [medium]);双引号分支只收安全裸词(运维手写 `"e2b"`
26
+ # 这类;带空白/特殊字符的双引号值正是 SL 的载体,不认)。
27
+ #
28
+ # 🔴 安全裸词是**正向白名单**(不是「排除危险字符」的负向类):`;` `&` `|` `<` `>` `(` `)` 这些 shell
29
+ # 命令分隔符/重定向若留在裸词里,任何仍 `source` 这份 .env 的读者(如 deploy/sema-up/smoke.sh)会把
30
+ # `x;id` 执行掉。正向白名单只放「在 dotenv 与 bash 赋值右半都惰性」的字符,其余一律进单引号编码形
31
+ # (单引号内即便被 source 也不执行)—— 于是执行边界由**序列化**关死,不依赖逐个数清谁还在 source。
32
+ # 决策键的合法值(true/false/host/e2b/k8s)、端口(数字)、十六进制口令、URL(`https://h:port/p`)、
33
+ # 镜像引用(`ghcr.io/x/y:1.0`)全在白名单内 ⇒ 仍裸写,.env 升级零 churn。
34
+ #
35
+ # 收编(S-618):7.96.0「写口只拒换行 / 回车」的判据、`envf_get` 的物理行假设,连同曾经的「另立
36
+ # 一件做渲染对账」都收进来——写口判据 = 本文件的 `envf_representable`;物理行假设的兜底 = 生成 `.env`
37
+ # 之后、`compose up` 之前那一道 `docker compose config` 渲染对账(sema-up.sh `reconcile_render`,
38
+ # 以 compose 自己为唯一属主)。不再有第二套文法。
39
+ ENVF_WORD='[A-Za-z0-9_./:+=@-]'
40
+ # 字面词字符集(读口的裸值 / 双引号内层):不含空白 / 引号 / `#` / `$` / 反引号 / 反斜杠 —— compose-go 对这样的
41
+ # 值不做展开、不当注释、不起引号,读到的就是字面量。**ENVF_WORD ⊂ ENVF_LITERAL**:写口产出的裸值恒在读口文法内
42
+ # (往返成立);读口多认的那部分(`%` `;` `!` `,` `*` …)是旧写口(≤7.97.0 原样落盘)留下的合法字面值 —— 它们对
43
+ # compose 无歧义,重跑必须读得回(车MH codex r2 [medium]:收紧成写口白名单会把 v7.97.0 存下的 `p%ss` 这类口令拒掉,
44
+ # 升级即断)。本脚本族从不 source `.env`,所以这些字符在读侧不构成执行面;写侧仍只裸写 ENVF_WORD,其余一律单引号。
45
+ ENVF_LITERAL='[^[:space:]"'"'"'#$`\\]'
46
+
47
+ # envf_representable <value> —— 值能否安全序列化进 `.env`(裸词或整值单引号形)。
48
+ # 不能 ⇒ 非零 + 把原因(**不含键、不含值**)写进 `ENVF_REJECT`。
49
+ envf_representable() {
50
+ case "$1" in
51
+ *$'\n'*) ENVF_REJECT='值里有换行'; return 1 ;;
52
+ *$'\r'*) ENVF_REJECT='值里有回车'; return 1 ;;
53
+ *\'*) ENVF_REJECT='值里有单引号(整值单引号编码形里不能再出现单引号)'; return 1 ;;
54
+ *\\) ENVF_REJECT='值以反斜杠结尾(compose 把单引号编码形结尾的反斜杠与闭引号读成一个转义引号,闭引号失效)'; return 1 ;;
55
+ *) ENVF_REJECT=''; return 0 ;;
56
+ esac
57
+ }
58
+
59
+ # envf_encode <value> —— 序列化出 `.env` 一行赋值的**右半**(值部分)。调用方须先过
60
+ # `envf_representable`。全安全裸词 ⇒ 原样(compose 与 bash source 都惰性,.env 升级零 churn);
61
+ # 否则 ⇒ 整值单引号(两个读者下都读成字面量,不续解析、不执行)。
62
+ envf_encode() {
63
+ if printf '%s' "$1" | grep -qE "^${ENVF_WORD}*\$"; then
64
+ printf '%s' "$1"
65
+ else
66
+ printf "'%s'" "$1"
67
+ fi
68
+ }
69
+
70
+ # envf_canonical_line <key> <line> —— 该**整行**是否是 <key> 的规范赋值行:
71
+ # 行首顶格 `KEY=` + (空 | 字面词 | 一对单引号包住的、不以 `\` 结尾的非单引号串 = encode 的整个产物 |
72
+ # 一对双引号包住的字面词 = 运维手写形)。0 = 是。闭引号后再跟任何东西(SL 的二次赋值)、裸值带空白 / `$` / `#`
73
+ # / 引号、单引号串以 `\` 结尾(compose 读成转义引号)⇒ 不是规范形。
74
+ envf_canonical_line() {
75
+ printf '%s\n' "$2" | grep -qE "^$1=(${ENVF_LITERAL}*|'([^']*[^'\\\\])?'|\"${ENVF_LITERAL}*\")\$"
76
+ }
77
+
78
+ # envf_decode <rhs> —— 从规范行的右半剥掉一对同型引号(空 = 未设)。
79
+ envf_decode() {
80
+ local v="$1"
81
+ case "$v" in \"*\") v="${v#\"}"; v="${v%\"}" ;; \'*\') v="${v#\'}"; v="${v%\'}" ;; esac
82
+ printf '%s' "$v"
83
+ }
84
+
85
+ # envf_assign_line_re <key> —— 「任一 dotenv 方言下**可能**给 <key> 赋值的行」的匹配式:
86
+ # 行首空白? + `export `? + 键 + 空白? + `=` 或 `:`。决策键的「至多一行」判定用它。
87
+ envf_assign_line_re() { printf '^[[:space:]]*(export[[:space:]]+)?%s[[:space:]]*[=:]' "$1"; }
88
+
89
+ # ════════════════════════════════════════════════════════════════════════════════════════════════
90
+ # 读写原语(全家族唯一一份)。拒因走 stderr、带脚本名前缀;**不回显值**(可能是凭据),只点名键与行号。
91
+ # ════════════════════════════════════════════════════════════════════════════════════════════════
92
+ ENVF_TAG="${ENVF_TAG:-$(basename "$0" .sh)}"
93
+ envf_log() { printf '[%s] %s\n' "$ENVF_TAG" "$*" >&2; }
94
+
95
+ # envf_read <key> —— 严格读口的本体。设 ENVF_FOUND(0/1:有没有给 <key> 赋值的行)与 ENVF_VAL(解码后的值)。
96
+ # ① 凡是在任一 dotenv 方言下**可能**给 <key> 赋值的行(envf_assign_line_re)至多一行;② 那一行必须是规范形
97
+ # (envf_canonical_line)。违反 ⇒ 响亮拒并点名行号,返回 1。不进命令替换也能用(状态在两个全局里)。
98
+ envf_read() {
99
+ ENVF_FOUND=0 ENVF_VAL=""
100
+ [ -f "$ENVF" ] || return 0
101
+ local k="$1" hits n nums line
102
+ hits="$(grep -nE "$(envf_assign_line_re "$k")" "$ENVF" || true)"
103
+ [ -n "$hits" ] || return 0
104
+ n="$(printf '%s\n' "$hits" | wc -l | tr -d '[:space:]')"
105
+ nums="$(printf '%s\n' "$hits" | cut -d: -f1 | paste -sd, -)"
106
+ if [ "$n" -gt 1 ]; then
107
+ envf_log "⛔ $ENVF 里可能给 $k 赋值的行有 $n 行(第 $nums 行)—— 一个键只许一行"
108
+ envf_log " ↳ 下一步:只留一行规范形 $k=<值>,其余删掉"
109
+ envf_log " ↳ 为什么不挑一行用:compose 读 .env 的规则(export 前缀、键旁空格、重复键末次生效)与本脚本不同,挑哪一行都可能不是容器真正拿到的那一行"
110
+ return 1
111
+ fi
112
+ line="${hits#*:}"
113
+ if ! envf_canonical_line "$k" "$line"; then
114
+ envf_log "⛔ $ENVF 第 $nums 行给 $k 赋值,但不是本脚本认得的规范形"
115
+ envf_log " ↳ 下一步:改成 $k=<值>(行首顶格、不带 export、键旁无空格、值是一个安全词或整值单引号、行尾无注释),或删掉这一行"
116
+ envf_log " ↳ 为什么不猜:这一行 compose 怎么读(注释、空白、展开、闭引号后的二次赋值)与本脚本怎么读一旦不一致,容器拿到的就不是这里读到的值"
117
+ return 1
118
+ fi
119
+ ENVF_FOUND=1
120
+ ENVF_VAL="$(envf_decode "${line#*=}")"
121
+ }
122
+ # envf_get <key> —— 打印值(空 = 未设)。调用形恒为 `v="$(envf_get K)" || exit 1`。
123
+ envf_get() { envf_read "$1" || return 1; printf '%s' "$ENVF_VAL"; }
124
+ # envf_has <key> —— 有非空值 ⇒ 0。读到非规范行 ⇒ **退整个脚本**(不是「当它没有」)。
125
+ envf_has() { envf_read "$1" || exit 1; [ -n "$ENVF_VAL" ]; }
126
+ # envf_load <key>… —— `. "$ENVF"` 的数据版替身:逐键回读到同名 shell 变量。**只在 .env 里有非空值时才覆盖**
127
+ # (缺键保留 shell 现值:端口缺省 / summary 的 `:-` 兜底都靠这条)。printf -v 落值,不 eval。
128
+ envf_load() {
129
+ local _k
130
+ for _k in "$@"; do
131
+ envf_read "$_k" || exit 1
132
+ if [ -n "$ENVF_VAL" ]; then printf -v "$_k" '%s' "$ENVF_VAL"; fi
133
+ done
134
+ return 0
135
+ }
136
+
137
+ # envf_guard <key> <value> —— 值无法表示 ⇒ 写之前响亮拒(点名键、不回显值),退整个脚本。
138
+ envf_guard() {
139
+ envf_representable "$2" && return 0
140
+ envf_log "⛔ 要写进 $ENVF 的 $1 的值无法安全写成一条 .env 赋值($ENVF_REJECT)—— .env 一行一条赋值"
141
+ envf_log " ↳ 下一步:去掉该值里的换行 / 回车 / 单引号(查 $1 对应的命令行参数或 -f 配置项)后重跑;拒因不回显值"
142
+ exit 1
143
+ }
144
+ # envf_put —— `.env` 的**唯一**追加口:一次写入恰好一个物理行,值按文法序列化。
145
+ envf_put() { envf_guard "$1" "$2"; printf '%s=%s\n' "$1" "$(envf_encode "$2")" >> "$ENVF"; }
146
+ # setk <key> <value> —— 键不在才写(凭据只生成一次、重入不轮换)。「在不在」由严格读口判:旧文件里该键
147
+ # 那一行若不是规范形(比如同物理行闭引号带出的二次赋值),这里就响亮拒 —— 不再「见行在就跳过」把毒行原样留着。
148
+ setk() { envf_read "$1" || exit 1; [ "$ENVF_FOUND" = 1 ] || envf_put "$1" "$2"; }
149
+ # setk_new —— 同 setk,新写入时返回 0(summary 用来判断「密码只显示一次」)。
150
+ setk_new() { envf_read "$1" || exit 1; if [ "$ENVF_FOUND" = 1 ]; then return 1; fi; envf_put "$1" "$2"; return 0; }
151
+ # resetk <key> <value> —— 每次重写。先判后删(拒了就一行都不动);删的是**任一方言下可能给该键赋值的全部行**
152
+ # (与读口同一只匹配式),再追加一行规范形 —— 不留 `export K=…` 这类旧行在后面末次生效。
153
+ resetk() {
154
+ envf_guard "$1" "$2"
155
+ { grep -vE "$(envf_assign_line_re "$1")" "$ENVF" || true; } > "$ENVF.tmp"
156
+ mv "$ENVF.tmp" "$ENVF"; chmod 600 "$ENVF"
157
+ envf_put "$1" "$2"
158
+ }
159
+
160
+ # envf_invalidate_unless <锚键> <期望锚值> <派生键>… —— 一组派生键只对它被派生时的锚值有效(OAuth 接线之于它
161
+ # 被创建时的 git 目标:REGISTRY_GITEA_BASE_URL 是锚,client id / secret 是派生)。`.env` 里登记的锚值非空且
162
+ # ≠ 期望值 ⇒ 这组键(含锚)全部清成空值并清空同名 shell 变量,设 ENVF_INVALIDATED=1;否则一概不动。调用方据此
163
+ # 重建(建成后用 resetk 落新值)或如实退化 —— 不把上一目标的接线静默带进这一次(车MH codex r2 [high])。
164
+ envf_invalidate_unless() {
165
+ local anchor="$1" want="$2" k
166
+ shift 2
167
+ ENVF_INVALIDATED=0
168
+ envf_read "$anchor" || exit 1
169
+ if [ -z "$ENVF_VAL" ] || [ "$ENVF_VAL" = "$want" ]; then return 0; fi
170
+ ENVF_INVALIDATED=1
171
+ for k in "$anchor" "$@"; do resetk "$k" ""; printf -v "$k" '%s' ""; done
172
+ return 0
173
+ }