@sema-agent/server 7.98.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 (50) hide show
  1. package/MIGRATION.md +6 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +12 -9
  5. package/deploy/sema-up/lib/envf.sh +110 -42
  6. package/deploy/sema-up/sema-up.sh +76 -34
  7. package/dist/boot/config-center.js +167 -22
  8. package/dist/boot/org-memory.d.ts +2 -1
  9. package/dist/boot/org-memory.js +9 -5
  10. package/dist/boot/runtime-caps.js +4 -4
  11. package/dist/capabilities/center-prompts.d.ts +3 -0
  12. package/dist/capabilities/center-prompts.js +1 -1
  13. package/dist/center-credential.d.ts +112 -0
  14. package/dist/center-credential.js +131 -0
  15. package/dist/config-catalog.js +3 -2
  16. package/dist/config-center/center-request.d.ts +60 -0
  17. package/dist/config-center/center-request.js +71 -0
  18. package/dist/config-center/http-client.d.ts +17 -9
  19. package/dist/config-center/http-client.js +27 -36
  20. package/dist/config-center/skills-mcp.d.ts +4 -1
  21. package/dist/config-center/skills-mcp.js +2 -2
  22. package/dist/config-invariants.d.ts +2 -2
  23. package/dist/config-invariants.js +0 -8
  24. package/dist/config-lkg.d.ts +47 -8
  25. package/dist/config-lkg.js +107 -20
  26. package/dist/config-provider.d.ts +8 -7
  27. package/dist/config-provider.js +5 -5
  28. package/dist/config-types.d.ts +8 -4
  29. package/dist/config.js +11 -13
  30. package/dist/fleet-client.d.ts +8 -8
  31. package/dist/fleet-client.js +7 -7
  32. package/dist/fleet-lease.d.ts +11 -11
  33. package/dist/fleet-lease.js +17 -13
  34. package/dist/hosted-posture.d.ts +49 -93
  35. package/dist/hosted-posture.js +1 -25
  36. package/dist/observability/secret-env-scrub.d.ts +2 -26
  37. package/dist/observability/secret-env-scrub.js +2 -2
  38. package/dist/plugins/remote-env-host.d.ts +3 -1
  39. package/dist/plugins/remote-env-host.js +3 -2
  40. package/dist/project-memory.d.ts +28 -1
  41. package/dist/project-memory.js +22 -22
  42. package/dist/run-local.js +0 -2
  43. package/dist/runtime-caps-resolver.d.ts +8 -8
  44. package/dist/runtime-caps-resolver.js +16 -14
  45. package/dist/sealed-key.js +3 -7
  46. package/dist/server-secret-env.d.ts +41 -0
  47. package/dist/server-secret-env.js +18 -0
  48. package/dist/tool-approval.d.ts +48 -1
  49. package/dist/tool-approval.js +10 -3
  50. 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 变更
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
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` 里的
package/USAGE.md CHANGED
@@ -492,6 +492,9 @@ SEND_USER_FILE_SANDBOX_PUT_ENDPOINT=… # 可选:沙箱直传 PUT 的端
492
492
  # 新名(orchestrator 现注入);旧名 CONFIG_CENTER_* 在场即 boot 拒启,只认 SEMA_REGISTRY_*
493
493
  SEMA_REGISTRY_URL=http://<config-center-host>:3100 # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
494
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 + 凭证身份」分箱。
495
498
  SEMA_REGISTRY_DRY_RUN=true # 安全灰度:只 LOG 中心配置 vs env 推导的差异,不 apply
496
499
  SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worker=<名> 取该 worker 的 roster(reconciler 按 worker 注);不设=全局 roster(向后兼容)
497
500
  FLEET_ADVERTISE_ADDRESS=http://<本机可达IP>:8090 # 可选:设了才启 fleet 上报腿(announce/heartbeat+usage 批报到中心)。
@@ -782,20 +785,20 @@ TASK_WRITE_FACE_ESCALATION=allow # 词表 allow|refuse;坏词启动期响亮
782
785
  | `allow` | 任意 | **放行**(部署允许任务层开写面) |
783
786
  | `refuse` | 任意 | **422 拒**(运维显式表态**无条件**恒赢,不挂任何客户端派生腿) |
784
787
  | 未设 | 否(单用户 turnkey) | **放行** —— 与 7.94 及以前逐字节相同 |
785
- | 未设 | **是** | **422 拒**,拒因点名 `TASK_WRITE_FACE_ESCALATION=allow` 与判成托管的证据 |
788
+ | 未设 | **是**(`REQUIRE_PRINCIPAL=true`) | **422 拒**,拒因点名 `TASK_WRITE_FACE_ESCALATION=allow` 与判成托管的证据 |
786
789
 
787
790
  - 🔴 **`REMOTE_EXEC=device` 车道上本门不适用**(任何 `REQUIRE_PRINCIPAL` 值):那条车道的路径落在
788
791
  **发起者自己的设备**上,归属由 `device_session` 绑定行 + 准入链的 owner 谓词验过 —— host 闸防的
789
792
  跨租户宿主穿越在那儿**结构性不存在**(成文裁定,与 `settings.env` / `cwd` 两键在该车道上的既有臂同源),
790
793
  也叠加「device 车道不额外加一道审批」这条既定纪律。⚠️ 运维的**显式** `refuse` 在 device 上照样恒赢(旋钮不挂车道派生腿)。
791
- - **「托管形」怎么判**(判据单点 `src/hosted-posture.ts`,两类三条证据,任一成立即托管):
792
- ① `REQUIRE_PRINCIPAL=true`(定义式);② `PRINCIPAL_JWT_PUBKEYS` / `_ISS` / `_AUD` 任一在场;
793
- ③ `AUTH_BRIDGE_ISSUER` **显式**在场。②③ 的共同标准是**运维点名了一个外部身份签发方** ——
794
- 一台机器一个可信主人的形不需要向任何人验签。
795
- 🔴 **「受控于控制面」不算证据**:`SEMA_REGISTRY_*`(含 worker 名)与 auth-bridge 那条
796
- `?? configCenter.baseUrl` 回退**都不读** —— fleet 管理轴 ≠ 租户轴(一台 `sema run-local` 从组织下发面
797
- 取配置是正当用法)。其余被排除的候选(`OPERATOR_PRINCIPALS`、`SERVICE_AUTH_TOKENS` 条目数、
798
- `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`。
799
802
  - **同族的 `approverPosture` 是「忽略 + 留声」,不是拒**:提交体的 `approverPosture` 同样是一句宿主姿态
800
803
  声明(引擎据此把 bypass 指令渲给模型 —— 那段文字指示模型改用 Bash 改文件而不是用文件工具),
801
804
  托管形上它被**忽略**并留一条 `task_approver_posture_ignored`(与 `settings.hooks` / `cwd` /
@@ -10,50 +10,74 @@
10
10
  # has_env / `. "$ENVF"` / `printf '%s=%s' >> "$ENVF"` / `grep … "$ENVF"` —— 第二套写读规则迟早分叉
11
11
  # (test/deploy-lanes-contract.test.ts「S-618 续」两道机器钉)。
12
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 的载体,不认)。
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
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。
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
+ # 点名键、不回显值,「下一步」按拒因给。写口产物恒在读口文法内(安全裸词 ⊂ 裸值形,单引号编码形 ⊂ 单引号形)⇒ 往返成立。
34
37
  #
35
38
  # 收编(S-618):7.96.0「写口只拒换行 / 回车」的判据、`envf_get` 的物理行假设,连同曾经的「另立
36
39
  # 一件做渲染对账」都收进来——写口判据 = 本文件的 `envf_representable`;物理行假设的兜底 = 生成 `.env`
37
40
  # 之后、`compose up` 之前那一道 `docker compose config` 渲染对账(sema-up.sh `reconcile_render`,
38
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。
39
46
  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:]"'"'"'#$`\\]'
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
+ }
46
62
 
47
- # envf_representable <value> —— 值能否安全序列化进 `.env`(裸词或整值单引号形)。
48
- # 不能 ⇒ 非零 + 把原因(**不含键、不含值**)写进 `ENVF_REJECT`。
63
+ # envf_representable <value> —— 值能否写成写口的窄形(安全裸词或整值单引号)。
64
+ # 不能 ⇒ 非零 + 拒因写进 `ENVF_REJECT`、这一拒因的出路写进 `ENVF_REJECT_NEXT`(两者都**不含键、不含值**)。
49
65
  envf_representable() {
50
66
  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 ;;
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 ;;
56
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
57
81
  }
58
82
 
59
83
  # envf_encode <value> —— 序列化出 `.env` 一行赋值的**右半**(值部分)。调用方须先过
@@ -67,15 +91,59 @@ envf_encode() {
67
91
  fi
68
92
  }
69
93
 
70
- # envf_canonical_line <key> <line> —— 该**整行**是否是 <key> 的规范赋值行:
71
- # 行首顶格 `KEY=` + (空 | 字面词 | 一对单引号包住的、不以 `\` 结尾的非单引号串 = encode 的整个产物 |
72
- # 一对双引号包住的字面词 = 运维手写形)。0 = 是。闭引号后再跟任何东西(SL 的二次赋值)、裸值带空白 / `$` / `#`
73
- # / 引号、单引号串以 `\` 结尾(compose 读成转义引号)⇒ 不是规范形。
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 = 是。
74
140
  envf_canonical_line() {
75
- printf '%s\n' "$2" | grep -qE "^$1=(${ENVF_LITERAL}*|'([^']*[^'\\\\])?'|\"${ENVF_LITERAL}*\")\$"
141
+ case "$2" in "$1="*) ;; *) return 1 ;; esac
142
+ envf_rhs_literal "${2#"$1="}"
76
143
  }
77
144
 
78
- # envf_decode <rhs> —— 从规范行的右半剥掉一对同型引号(空 = 未设)。
145
+ # envf_decode <rhs> —— 规范行右半的字面值:引号形剥掉一对同型引号,裸值原样(空 = 未设)。只对过了 envf_rhs_literal
146
+ # 的右半调用 —— 那时这就是 compose 读到的值(裸值不会以引号开头,所以不会被误剥)。
79
147
  envf_decode() {
80
148
  local v="$1"
81
149
  case "$v" in \"*\") v="${v#\"}"; v="${v%\"}" ;; \'*\') v="${v#\'}"; v="${v%\'}" ;; esac
@@ -112,7 +180,7 @@ envf_read() {
112
180
  line="${hits#*:}"
113
181
  if ! envf_canonical_line "$k" "$line"; then
114
182
  envf_log "⛔ $ENVF 第 $nums 行给 $k 赋值,但不是本脚本认得的规范形"
115
- envf_log " ↳ 下一步:改成 $k=<值>(行首顶格、不带 export、键旁无空格、值是一个安全词或整值单引号、行尾无注释),或删掉这一行"
183
+ envf_log " ↳ 下一步:改成 $k=<值>(行首顶格、不带 export、键旁无空格、行尾无注释;值是 compose 按字面读的一形:裸写 = 不以空白或引号开头、不以空白结尾、不含插值(\$ 后跟 \$ / { / 字母或 _)、不含「空格+#」;整值单引号 = 里面没有单引号、不以奇数个反斜杠结尾;整值双引号 = 里面没有双引号 / 反斜杠 / 插值),或删掉这一行"
116
184
  envf_log " ↳ 为什么不猜:这一行 compose 怎么读(注释、空白、展开、闭引号后的二次赋值)与本脚本怎么读一旦不一致,容器拿到的就不是这里读到的值"
117
185
  return 1
118
186
  fi
@@ -137,8 +205,8 @@ envf_load() {
137
205
  # envf_guard <key> <value> —— 值无法表示 ⇒ 写之前响亮拒(点名键、不回显值),退整个脚本。
138
206
  envf_guard() {
139
207
  envf_representable "$2" && return 0
140
- envf_log "⛔ 要写进 $ENVF 的 $1 的值无法安全写成一条 .env 赋值($ENVF_REJECT)—— .env 一行一条赋值"
141
- envf_log " ↳ 下一步:去掉该值里的换行 / 回车 / 单引号(查 $1 对应的命令行参数或 -f 配置项)后重跑;拒因不回显值"
208
+ envf_log "⛔ 要写进 $ENVF 的 $1 的值无法安全写成一条 .env 赋值($ENVF_REJECT)"
209
+ envf_log " ↳ 下一步:$ENVF_REJECT_NEXT —— 查 $1 对应的命令行参数或 -f 配置项后重跑;拒因不回显值"
142
210
  exit 1
143
211
  }
144
212
  # envf_put —— `.env` 的**唯一**追加口:一次写入恰好一个物理行,值按文法序列化。
@@ -15,7 +15,7 @@
15
15
  set -euo pipefail
16
16
 
17
17
  HERE="$(cd "$(dirname "$0")" && pwd)"
18
- # `.env` 文法(写口 encode / 读口 ENVF_WORD 与规范行判据)的唯一定义 —— 写读两半从这一处取判据。
18
+ # `.env` 文法(写口 envf_representable + envf_encode / 读口 envf_canonical_line)的唯一定义 —— 写读两半从这一处取判据。
19
19
  . "$HERE/lib/envf.sh"
20
20
  # 执行车道表的唯一读者(S-599)。定义在**最前面**:`--help` 的词表也从表渲(手抄的词表会在加一条
21
21
  # 车道时静默分叉),而 `-h` 在参数循环里就被处理,那时后面的函数还没定义。
@@ -169,11 +169,20 @@ log() { printf '[sema-up] %s\n' "$*" >&2; }
169
169
 
170
170
  # compose 入口解析:优先 docker compose(插件),缺了退 docker-compose(独立版)——
171
171
  # homebrew 裸 docker CLI 不带插件是真场景;都没有=报错带下一步
172
- dc() {
173
- if docker compose version >/dev/null 2>&1; then docker compose "$@"
174
- elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@"
172
+ # dc_argv 设 DC_ARGV(选中的 compose 入口);dc 调它 —— 渲染对账给运维的复现命令也从 DC_ARGV 渲,不另写一份。
173
+ dc_argv() {
174
+ if docker compose version >/dev/null 2>&1; then DC_ARGV=(docker compose)
175
+ elif command -v docker-compose >/dev/null 2>&1; then DC_ARGV=(docker-compose)
175
176
  else log "⛔ 没有可用的 compose(docker compose 插件/docker-compose 都缺)。下一步:装 Docker Desktop/OrbStack,或 brew install docker-compose"; exit 1; fi
176
177
  }
178
+ dc() { dc_argv; "${DC_ARGV[@]}" "$@"; }
179
+ # compose 的全局参数(-p / --env-file / -f)只在这里拼一次(相对 `$HERE/stack`):up / config 走 compose()、teardown 的
180
+ # down、渲染对账给运维的复现命令,都从同一张 COMPOSE_GLOBALS 取(S-646 ④:复现命令曾是手抄的第二份,漏了
181
+ # -f compose.dataroot.yaml 与 profile;teardown 是第三份)。数据根由调用方先定好 DATA_ROOT。
182
+ compose_globals() {
183
+ COMPOSE_GLOBALS=(--project-name "$PROJECT" --env-file "$ENVF" -f compose.yaml)
184
+ if [ -n "$DATA_ROOT" ]; then COMPOSE_GLOBALS+=(-f compose.dataroot.yaml); fi
185
+ }
177
186
 
178
187
  # ── 跨平台探针(Linux 目标机为主;macOS 开发机跑 docker 档也是真场景——npx 分发)────────────
179
188
  ram_mb() { if [ -r /proc/meminfo ]; then awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo
@@ -237,22 +246,23 @@ if $TEARDOWN; then
237
246
  cd "$HERE/stack"
238
247
  if [ -f "$ENVF" ]; then
239
248
  # 读 .env 一律经 lib 的严格读口(S-618 续):裸 grep|cut 会连编码形的单引号一起读出来。
240
- OV=""; envf_has DATA_ROOT_DIR && OV="-f compose.dataroot.yaml"
249
+ # 数据根以 .env 登记的为准(卸载的是首装那一份,不看本次 --data-root);全局参数与 up 同一张表(compose_globals)。
250
+ DATA_ROOT="$(envf_get DATA_ROOT_DIR)" || exit 1
241
251
  # 全 profile down 会对未启用服务也做 config 校验——.env 缺 SEMA_*_IMAGE(该面没开过)时
242
252
  # 整个 down 失败(真踩:假「已卸载」)。补占位符只为过校验;shell env 优先级高于 --env-file
243
253
  envf_has SEMA_SERVER_IMAGE || export SEMA_SERVER_IMAGE="teardown-placeholder"
244
254
  envf_has SEMA_REGISTRY_IMAGE || export SEMA_REGISTRY_IMAGE="teardown-placeholder"
245
- if ! dc --project-name "$PROJECT" --env-file "$ENVF" -f compose.yaml $OV \
255
+ compose_globals
256
+ if ! dc "${COMPOSE_GLOBALS[@]}" \
246
257
  --profile data --profile pg --profile s3 --profile agent --profile registry --profile git \
247
258
  down $($TEARDOWN_VOLUMES && echo -v); then
248
259
  log "⛔ 卸载未完全成功。下一步:docker ps -a --filter label=com.docker.compose.project=$PROJECT 手查残留"
249
260
  exit 1
250
261
  fi
251
262
  log "已卸载 project=$PROJECT $($TEARDOWN_VOLUMES && echo '(含数据卷)' || echo '(数据卷保留:pgdata/miniodata/registrydata/gitdata)')"
252
- if $TEARDOWN_VOLUMES && [ -n "$OV" ]; then
253
- DR="$(envf_get DATA_ROOT_DIR)" || exit 1
254
- # ${DR} 必须带花括号:后面紧跟中文破折号,bash 3.2(macOS 系统壳)会把多字节并进变量名(真踩)
255
- log "⚠ 数据根在大盘 bind 目录 ${DR}——卷对象已删,盘上文件还在;确认不要了再手动 rm -rf"
263
+ if $TEARDOWN_VOLUMES && [ -n "$DATA_ROOT" ]; then
264
+ # ${DATA_ROOT} 必须带花括号:后面紧跟中文破折号,bash 3.2(macOS 系统壳)会把多字节并进变量名(真踩)
265
+ log "⚠ 数据根在大盘 bind 目录 ${DATA_ROOT}——卷对象已删,盘上文件还在;确认不要了再手动 rm -rf"
256
266
  fi
257
267
  else log "没有找到 $ENVF,无可卸载"; fi
258
268
  exit 0
@@ -566,8 +576,8 @@ PG_TUNE_FLAGS="-c max_connections=100 -c shared_buffers=${SB}MB -c effective_cac
566
576
  genpw() { openssl rand -hex 24; }
567
577
  touch "$ENVF"; chmod 600 "$ENVF"
568
578
  # 写口 = lib/envf.sh 的 envf_put(setk / setk_new / resetk 都落到它):值按同一文法序列化 —— 安全裸词原样,
569
- # 含空白 / 特殊字符的走整值单引号编码形(compose 读成字面量、闭引号后不续解析;被 source 也不执行);换行 /
570
- # 回车 / 单引号 ⇒ 写之前响亮拒,点名键、不回显值。setk 判「键在不在」也走严格读口:旧文件里该键那一行不是
579
+ # 含空白 / 特殊字符 / 反斜杠的走整值单引号编码形(compose 读成字面量、闭引号后不续解析;被 source 也不执行);换行 /
580
+ # 回车 / 单引号 / 以奇数个反斜杠结尾 ⇒ 写之前响亮拒,点名键、不回显值,「下一步」按拒因给。setk 判「键在不在」也走严格读口:旧文件里该键那一行不是
571
581
  # 规范形(比如同物理行闭引号带出的二次赋值)⇒ 响亮拒,不再「见行在就跳过」把毒行原样留着(test srv796 RT1)。
572
582
  setk SEMA_PROJECT "$PROJECT"
573
583
  setk PG_PASSWORD "$(genpw)"
@@ -683,7 +693,9 @@ fi
683
693
  envf_load SERVER_PORT PG_PORT MINIO_PORT MINIO_CONSOLE_PORT REGISTRY_PORT GIT_HTTP_PORT GIT_SSH_PORT
684
694
 
685
695
  command -v docker >/dev/null || { log "⛔ 未装 docker。下一步:curl -fsSL https://get.docker.com | sh(cn region 用镜像站,如 https://mirrors.aliyun.com/docker-ce)"; exit 1; }
686
- compose() { ( cd "$HERE/stack" && dc --project-name "$PROJECT" --env-file "$ENVF" -f compose.yaml ${DATA_ROOT:+-f compose.dataroot.yaml} "$@" ); }
696
+ compose() { compose_globals; ( cd "$HERE/stack" && dc "${COMPOSE_GLOBALS[@]}" "$@" ); }
697
+ # shq —— 单引号引用一个词(复现命令要能原样粘贴:路径里可能有空格)。
698
+ shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
687
699
 
688
700
  # ── D:渲染对账(S-618 的类闭环;生成 .env 之后、compose up 之前)──────────────────────────────────
689
701
  # .env 的写口 / 读口都按物理行判,认不出 compose dotenv 的跨行引号值等边角;唯一能确知「容器真正拿到
@@ -692,22 +704,23 @@ compose() { ( cd "$HERE/stack" && dc --project-name "$PROJECT" --env-file "$ENVF
692
704
  # REQUIRE_EXECUTION_LANE,与本次决策逐值对比,不一致 ⇒ 拒并点名渲染值与决策值。**以 compose 为唯一属主**。
693
705
  # docker 不可达在上面 `command -v docker` 那道已 fail-loud 拦下;config 本身失败(YAML/插值坏)也拒,
694
706
  # 不新铸降级臂:未经对账就不 up。只有 agent 面有这三个决策键,别的档跳过。
695
- reconcile_render() {
696
- case ",$COMPOSE_PROFILES," in *,agent,*) : ;; *) return 0 ;; esac
697
- local exp_rp="$REQUIRE_PRINCIPAL_WANT" exp_re="$LANE_REMOTE_EXEC" exp_rl rendered
698
- exp_rl="$(printf '%s\n' "$LANE_ENV" | sed -n 's/^REQUIRE_EXECUTION_LANE=//p')"
699
- rendered="$(COMPOSE_PROFILES="$COMPOSE_PROFILES" compose config --format json 2>&1)" || {
700
- local rc=$?
701
- # 7.98.0 合并复审 F3:compose 的报文**不回显** —— 它解析 .env 失败时会原样引用那一行(凭据就在行里)。只报退出码 + 出路。
702
- log "⛔ 渲染对账:docker compose config 失败(退出码 $rc),无法确认容器会拿到什么 —— 拒绝在未对账的情况下 up"
703
- log " ↳ 下一步:在本机亲跑 docker compose --project-name $PROJECT --env-file $ENVF -f $HERE/stack/compose.yaml config 看报文(本脚本不转印它:.env 解析失败的报文会原样引用那一行,可能带凭据);常见成因是手改过的 .env 行"
704
- exit 1
705
- }
706
- RECON_JSON="$rendered" python3 - "$exp_rp" "$exp_re" "$exp_rl" <<'PY' || {
707
- import json, os, sys
707
+ #
708
+ # 拒因分两种、各给各的出路(S-656;7.98.0 曾把 stderr 并进 stdout 当 JSON 解析 ⇒ compose 的任何警告都让它 traceback,
709
+ # 拒因还冒充「不一致」):
710
+ # · 渲染结果解析失败 —— compose 退出码非零,或退出 0 但 stdout 不是可解析的 compose JSON:没拿到渲染值,谈不上比对;
711
+ # · 渲染值与决策值不一致 —— 拿到了、逐值比对不上。
712
+ # JSON 只从 stdout 取;stderr 只数行数作诊断,**不转印**(compose 解析 `.env` 失败时报文会原样引用那一行,凭据就在行里;
713
+ # 插值警告里的变量名也可能是凭据的一段)。
714
+ RECON_PY='
715
+ import json, sys
708
716
  want = {"REQUIRE_PRINCIPAL": sys.argv[1], "REMOTE_EXEC": sys.argv[2], "REQUIRE_EXECUTION_LANE": sys.argv[3]}
709
- d = json.loads(os.environ["RECON_JSON"])
710
- env = ((d.get("services") or {}).get("server") or {}).get("environment") or {}
717
+ try:
718
+ d = json.loads(sys.stdin.read())
719
+ env = d["services"]["server"].get("environment") or {}
720
+ if not isinstance(env, dict):
721
+ raise TypeError("environment")
722
+ except Exception:
723
+ raise SystemExit(4) # 渲染结果解析失败:不是 compose JSON / 没有 server 服务
711
724
  bad = []
712
725
  for k, w in want.items():
713
726
  got = env.get(k)
@@ -716,13 +729,42 @@ for k, w in want.items():
716
729
  bad.append("%s:渲染=%r 决策=%r" % (k, got, w))
717
730
  if bad:
718
731
  sys.stderr.write("[sema-up] ↳ 明细:" + " | ".join(bad) + "\n")
719
- raise SystemExit(3)
720
- PY
721
- log "⛔ 渲染对账失败:compose 渲染出的容器决策值与本次决策不一致 —— 以 compose 为唯一属主,拒绝 up"
722
- log " ↳ 下一步:检查 .env 里 REQUIRE_PRINCIPAL / REMOTE_EXEC / REQUIRE_EXECUTION_LANE,以及 shell / -f 里是否**导出**了同名变量(compose 插值 shell 优先于 --env-file,导出为空会盖掉 .env)"
732
+ raise SystemExit(3) # 渲染值与决策值不一致
733
+ '
734
+ reconcile_render() {
735
+ case ",$COMPOSE_PROFILES," in *,agent,*) : ;; *) return 0 ;; esac
736
+ local exp_rp="$REQUIRE_PRINCIPAL_WANT" exp_re="$LANE_REMOTE_EXEC" exp_rl rendered errf nerr rc=0 prc=0 repro a
737
+ exp_rl="$(printf '%s\n' "$LANE_ENV" | sed -n 's/^REQUIRE_EXECUTION_LANE=//p')"
738
+ # 复现命令:与这次 config 同一个 compose 入口、同一张全局参数表、同一组 profile(从源渲,不手抄)。
739
+ dc_argv; compose_globals
740
+ repro="(cd $(shq "$HERE/stack") && COMPOSE_PROFILES=$(shq "$COMPOSE_PROFILES") ${DC_ARGV[*]}"
741
+ for a in "${COMPOSE_GLOBALS[@]}"; do repro="$repro $(shq "$a")"; done
742
+ repro="$repro 'config' '--format' 'json')"
743
+ errf="$(umask 077 && mktemp "$WORK/.recon-stderr.XXXXXX")" || { log "⛔ 渲染对账:在 $WORK 建不了临时文件,无法对账 —— 拒绝在未对账的情况下 up"; exit 1; }
744
+ rendered="$(COMPOSE_PROFILES="$COMPOSE_PROFILES" compose config --format json 2>"$errf")" || rc=$?
745
+ nerr="$(wc -l < "$errf" | tr -d '[:space:]')"; rm -f "$errf"
746
+ if [ "$rc" -ne 0 ]; then
747
+ log "⛔ 渲染对账:渲染结果解析失败 —— docker compose config 失败(退出码 $rc;它在 stderr 输出了 $nerr 行,本脚本不转印),无法确认容器会拿到什么 —— 拒绝在未对账的情况下 up"
748
+ log " ↳ 下一步:在本机亲跑 $repro 看 compose 的报文(本脚本不转印它:.env 解析失败的报文会原样引用那一行,可能带凭据);常见成因是手改过的 .env 行"
723
749
  exit 1
724
- }
725
- log "渲染对账通过:REQUIRE_PRINCIPAL=$exp_rp REMOTE_EXEC=$exp_re REQUIRE_EXECUTION_LANE=$exp_rl(compose 渲染逐值一致)"
750
+ fi
751
+ printf '%s' "$rendered" | python3 -c "$RECON_PY" "$exp_rp" "$exp_re" "$exp_rl" || prc=$?
752
+ case "$prc" in
753
+ 0) : ;;
754
+ 3)
755
+ log "⛔ 渲染对账:渲染值与决策值不一致 —— compose 渲染出的容器决策值与本次决策对不上;以 compose 为唯一属主,拒绝 up"
756
+ log " ↳ 下一步:检查 $ENVF 里 REQUIRE_PRINCIPAL / REMOTE_EXEC / REQUIRE_EXECUTION_LANE,以及 shell / -f 里是否**导出**了同名变量(compose 插值 shell 优先于 --env-file,导出为空会盖掉 .env);亲跑 $repro 可看容器 environment"
757
+ exit 1 ;;
758
+ 4)
759
+ log "⛔ 渲染对账:渲染结果解析失败 —— docker compose config 退出 0,但 stdout 不是可解析的 compose JSON(或其中没有 server 服务;compose 另在 stderr 输出了 $nerr 行,本脚本不转印),无法确认容器会拿到什么 —— 拒绝在未对账的情况下 up"
760
+ log " ↳ 下一步:亲跑 $repro 看 stdout 是不是一份 JSON;不是 ⇒ 这个 compose 入口不支持 --format json 或往 stdout 多打了东西(${DC_ARGV[*]}),换 Docker Compose v2(docker compose 插件)后重跑"
761
+ exit 1 ;;
762
+ *)
763
+ log "⛔ 渲染对账:对账脚本自身失败(python3 退出码 $prc),无法确认容器会拿到什么 —— 拒绝在未对账的情况下 up"
764
+ log " ↳ 下一步:确认本机 python3 可用(python3 -c 'import json'),然后重跑"
765
+ exit 1 ;;
766
+ esac
767
+ log "渲染对账通过:REQUIRE_PRINCIPAL=$exp_rp REMOTE_EXEC=$exp_re REQUIRE_EXECUTION_LANE=$exp_rl(compose 渲染逐值一致)$([ "$nerr" -gt 0 ] && printf ';compose 另在 stderr 输出了 %s 行(多为插值警告;本脚本不转印,up 时 compose 会自己打出来)' "$nerr")"
726
768
  }
727
769
 
728
770
  # ── 外接端点连接检测(装配前 fail-loud;只验兼容可用,不校验证书——内网自签是常态)────────────