fix(cli): 非法 OS_TENANCY_POSTURE 在 serve 最开头被显式拒绝,不再伪装成 AuthPlugin 加载失败 (#5359) - #5381
Conversation
…xplicitly (#5359) `resolveTenancyPosture()` refuses an unrecognized `OS_TENANCY_POSTURE` and its message says "Refusing to boot". The refusal was never the problem; how it travelled was. serve had no dedicated read — it let the throw escape from whichever call site reached it first, and that site sat inside the broad AuthPlugin `try`, whose catch only warns. So an env-var typo announced itself as `AuthPlugin failed to load`, boot continued degraded through the whole capability slate (persisting a generated dev crypto key on the way), and the resolver's sentence only reached the operator much later, bare, from a generic printError. Resolve the posture once at the top of `run()` — after dotenv-flow's load so a `.env`-sourced value is seen, outside every `try` so nothing can demote it — and refuse an invalid value with an ADR-0093 D5-shaped FATAL (fix list generated from `TENANCY_POSTURES`, so it cannot go stale) plus `process.exit(1)`. The refusal is returned as a verdict rather than thrown, which is what makes it undemotable. Both later readers in serve (the organizations-plugin gate, the banner's `Tenancy:` row) reuse the resolved value; the banner in particular was the last line of defence against an invalid posture, i.e. a diagnostic surface made load-bearing for a safety property. One correction to the issue's static trace: the port was never bound before the refusal. The escaping throw aborted kernel bootstrap Phase 1 (ObjectQL's SchemaRegistry constructor), while the listening socket only opens in Phase 4, so "never listened" held before this change too and is not claimed as its evidence. What moved is that the refusal is now first, sole, correctly attributed, prescriptive, and side-effect free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
范围外发现(已另开单,未在本 PR 修)#5382 —— 同一形状、不同命令: 之所以不在本 PR 里顺手修:本单的文件面被限定为 值得单独一提的是它比 Generated by Claude Code Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 30971902650 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…ve-posture-early-exit
The merge queue took PR #5381 out on `Test timed out in 5000ms` at serve-tenancy-posture-gate.test.ts (queue run 30971902650, Test Core 3/3) — a timeout, not a failed assertion. Ten of the eleven cases in that file only inspect the refusal string and run in 0-3ms. The eleventh imports and runs the real `serve` command in-process: the whole serve module graph plus a port-availability probe. It measures 2535ms on an idle runner; on the queue's full-suite shard, sharing with the serve e2e tests (that shard reported import 94.8s / tests 282s), it exceeded the 5s default. The PR shards are lighter, so PR CI stayed green throughout. Same posture as the existing `}, 60_000)` cases in this package (utils/sqlite-occupancy.test.ts, utils/schema-migrate.deferred-ddl.integration .test.ts) and as #4856's package-level testTimeout. Superficially the #4796 5000ms signature but a different cause: that family was the spec template suite, already fixed by #4856. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w
|
队列分诊闭环(cli 车道 PM):按清单判第 1 类(失败测试在本 PR 包内)。成因非断言回归,是那条顺序断言用例在进程内真实装载 serve 模块 —— 空载 runner 实测 2535ms,队列满载分片(import 94.8s)越过 5s 默认超时。修复: 已知残余:该用例天然是文件内最慢者,随分片负载伸缩 —— 若日后再逼近上限,耐久修法是放弃进程内启动,但那会牺牲顺序断言的证明力,不做单方交易,届时再议。 Generated by Claude Code |
Fixes #5359
修了什么
resolveTenancyPosture()(@objectstack/types)对无法识别的OS_TENANCY_POSTURE抛错,文案自称「Refusing to boot rather than silently falling back to a posture with no organization wall」。拒绝本身一直是对的,错的是这个拒绝怎么传出去。serve过去没有单独解析 posture,而是让抛错从「第一处读到它的地方」自然逃逸 —— 而那个位置恰好落在 AuthPlugin 那个很宽的try里,它的 catch 只打印一句黄字就继续。于是一个 env 拼写错误的第一现场是:把环境变量拼错报成了插件加载问题。启动随后带伤继续:整个 capability slate 照常装载,本地 crypto 密钥被生成并持久化到磁盘,直到下一处没有被
try包住的读取才中止,最终由通用printError把解析器那句话裸着打出来。退出码对,别的都不对。怎么修的
serve.run()现在在一个固定位置解析一次 posture,非法值走 ADR-0093 D5 同款形状的显式拒绝:几处刻意的设计:
dotenvFlow.config()之后 —— 该变量常来自提交进仓库的.env*,闸门放在载入之前会把它读成 unset、放行,然后把非法值原样交回那条会被吞掉的路径;同时放在本方法所有try之外,任何 catch 都无法降级它。resolveTenancyPostureOrRefusal()返回 verdict 而非抛出 —— throw 正是当初被宽 catch 变成一句 warning 的那种东西。这是本次修复的核心属性。TENANCY_POSTURES生成,不是第二份字面量,新增一个 posture 不会让建议悄悄过期。cause保留解析器原话,不转述:vocabulary 与措辞归@objectstack/types,serve 不维护会跟它打架的第二份。Tenancy:行)。横幅那处尤其值得说:它此前是非法 posture 的最后一道防线,等于让一个诊断输出承担安全属性。packages/types/src/env.ts未改动 —— 解析器的语义和文案是对的。issue 的静态追踪认为「进程在报错前已经 listen 过」,并据此把
printServerReady认定为最终抛错点。实测不成立。 逃逸的抛错来自更早的位置:而监听套接字是在
packages/core/src/kernel.ts的 Phase 4(kernel:listening钩子,第 403 行)才打开的,Phase 1 的插件 init 抛错会先让bootstrap()中止。用一个每 10ms 轮询 TCP 端口的探针实测:OS_TENANCY_POSTURE=bogusOS_TENANCY_POSTURE=bogusOS_TENANCY_POSTURE=single(对照组证明探针本身是有效的,不是「探针测不到」。)
所以「端口从未绑定」修复前后都成立,本 PR 没有把它当作修复生效的证据 —— 一条因为「什么都没产生」而通过的断言什么也证明不了。本次真正改变的是:拒绝成为第一条也是唯一一条输出、归因正确、带处方,且启动不再留下任何副作用(修复前那次「被拒绝」的启动会在
$OS_HOME留下持久化的 dev crypto key;修复后该目录为空)。issue 里那条更实际的担忧(「这条路径依赖 banner 一定会执行来兜底」)方向是对的,只是兜底者认错了人 —— 实际兜底的是 ObjectQL registry 的构造,而那同样是顺带的、非刻意的闸门。本 PR 把这个拒绝交给一个刻意的闸门拥有。
测试
新增
packages/cli/src/commands/serve-tenancy-posture-gate.test.ts(11 条)。此前packages/cli零测试钉住这个行为。反向核验先定方向后执行:预测「只回退闸门的放置位置、保留 helper,应当只有那条顺序断言翻红,10 条纯文案断言不受影响」。实测完全符合:
EEXIT: 1是 oclif 从「Nothing to serve」那条路径退出的错误 —— 正好说明启动已经走过了配置载入,也就是 issue 描述的那个形态。恢复修复后 11/11 通过。命令与结果(均在容器共享 verify 锁下、
--maxWorkers=2):真机核验(
examples/app-crm,os serve):非法值下 FATAL 是唯一输出、无AuthPlugin failed to load、无配置载入、$OS_HOME为空;single正常启动且横幅Tenancy: single;isolated仍正确落到 ADR-0093 D5 那道闸门。影响面
packages/cli/src/commands/serve.ts+ 新测试 + changeset。os dev/os start都是把serve作为子进程拉起、且自身不解析 posture,因此闸门覆盖三个入口。Generated by Claude Code