Skip to content

fix(cli): 非法 OS_TENANCY_POSTURE 在 serve 最开头被显式拒绝,不再伪装成 AuthPlugin 加载失败 (#5359) - #5381

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-5359-serve-posture-early-exit
Aug 5, 2026
Merged

fix(cli): 非法 OS_TENANCY_POSTURE 在 serve 最开头被显式拒绝,不再伪装成 AuthPlugin 加载失败 (#5359)#5381
baozhoutao merged 3 commits into
mainfrom
claude/issue-5359-serve-posture-early-exit

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

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 拼写错误的第一现场是:

  ⚠ AuthPlugin failed to load: Invalid OS_TENANCY_POSTURE="bogus". Expected one of: …

把环境变量拼错报成了插件加载问题。启动随后带伤继续:整个 capability slate 照常装载,本地 crypto 密钥被生成并持久化到磁盘,直到下一处没有被 try 包住的读取才中止,最终由通用 printError 把解析器那句话裸着打出来。退出码对,别的都不对。

怎么修的

serve.run() 现在在一个固定位置解析一次 posture,非法值走 ADR-0093 D5 同款形状的显式拒绝:

  ✖ FATAL: OS_TENANCY_POSTURE="bogus" is not a recognized tenancy posture.
    Refusing to boot. Falling back to a default would silently drop the organization wall a
    walled deployment asked for — a posture typo must never be the thing that removes it
    (ADR-0105 D1; same refusal contract as ADR-0093 D5).

    No config has been loaded, no plugin has been mounted, and the HTTP server was
    never started — this deployment has not served a single request.

    Fix one of:
      • set OS_TENANCY_POSTURE=single — one organization, no organization wall — the default
      • set OS_TENANCY_POSTURE=group — organization wall enforced by the open engine, one shared database
      • set OS_TENANCY_POSTURE=isolated — organization wall + the enterprise @objectstack/organizations runtime (…)
      • unset OS_TENANCY_POSTURE entirely — the posture then derives from OS_MULTI_ORG_ENABLED
        (true ⇒ isolated, anything else ⇒ single).

    Checked the process environment and every .env file dotenv-flow loaded for this mode,
    so a stale value in a committed `.env*` is as likely a source as the shell.

    cause: Invalid OS_TENANCY_POSTURE="bogus". Expected one of: single, group, isolated (…)

几处刻意的设计:

  • 位置是有承载的,两头都是。 放在 dotenvFlow.config() 之后 —— 该变量常来自提交进仓库的 .env*,闸门放在载入之前会把它读成 unset、放行,然后把非法值原样交回那条会被吞掉的路径;同时放在本方法所有 try 之外,任何 catch 都无法降级它。
  • 拒绝是返回值,不是 throw。 resolveTenancyPostureOrRefusal() 返回 verdict 而非抛出 —— throw 正是当初被宽 catch 变成一句 warning 的那种东西。这是本次修复的核心属性。
  • 修法清单由 TENANCY_POSTURES 生成,不是第二份字面量,新增一个 posture 不会让建议悄悄过期。
  • cause 保留解析器原话,不转述:vocabulary 与措辞归 @objectstack/types,serve 不维护会跟它打架的第二份。
  • 后续读取复用同一个值(组织插件装载判断、启动横幅的 Tenancy: 行)。横幅那处尤其值得说:它此前是非法 posture 的最后一道防线,等于让一个诊断输出承担安全属性。

packages/types/src/env.ts 未改动 —— 解析器的语义和文案是对的。

⚠️ 对 issue 前提的一处订正(请复核)

issue 的静态追踪认为「进程在报错前已经 listen 过」,并据此把 printServerReady 认定为最终抛错点。实测不成立。 逃逸的抛错来自更早的位置:

Error: Invalid OS_TENANCY_POSTURE="bogus". …
    at resolveTenancyPosture (packages/types/dist/index.mjs:58:13)
    at new SchemaRegistry (packages/objectql/dist/index.mjs:447:46)      ← packages/objectql/src/registry.ts:757
    at new _ObjectQL  →  ObjectQLPlugin.init
    at ObjectKernel.initPluginWithTimeout  →  ObjectKernel.bootstrap     ← Phase 1
    at async Runtime.start  →  at async Serve.run

而监听套接字是在 packages/core/src/kernel.tsPhase 4kernel:listening 钩子,第 403 行)才打开的,Phase 1 的插件 init 抛错会先让 bootstrap() 中止。用一个每 10ms 轮询 TCP 端口的探针实测:

exit code 端口曾被监听
修复前,OS_TENANCY_POSTURE=bogus 1 false
修复后,OS_TENANCY_POSTURE=bogus 1 false
对照组,OS_TENANCY_POSTURE=single (持续运行) true(3.7s 时)

(对照组证明探针本身是有效的,不是「探针测不到」。)

所以「端口从未绑定」修复前后都成立,本 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 条纯文案断言不受影响」。实测完全符合:

 ❯ src/commands/serve-tenancy-posture-gate.test.ts (11 tests | 1 failed)
     × refuses before the config file is even read, and writes nothing else
AssertionError: expected 'EEXIT: 1' to be '__PROCESS_EXIT__:1'
 Tests  1 failed | 10 passed (11)

EEXIT: 1 是 oclif 从「Nothing to serve」那条路径退出的错误 —— 正好说明启动已经走过了配置载入,也就是 issue 描述的那个形态。恢复修复后 11/11 通过。

命令与结果(均在容器共享 verify 锁下、--maxWorkers=2):

pnpm --filter @objectstack/cli typecheck        → tsc --noEmit,无输出(通过)
npx vitest run --maxWorkers=2                   → Test Files 71 passed,Tests 652 passed
npx vitest run src/commands/serve-tenancy-posture-gate.test.ts → 11 passed
npx eslint <改动文件>                            → 0 errors 0 warnings
node scripts/check-nul-bytes.mjs                → OK (5394 files)
node scripts/check-adr-anchors.mjs              → OK (29 anchored)

真机核验(examples/app-crmos serve):非法值下 FATAL 是唯一输出、无 AuthPlugin failed to load、无配置载入、$OS_HOME 为空;single 正常启动且横幅 Tenancy: singleisolated 仍正确落到 ADR-0093 D5 那道闸门。

影响面

packages/cli/src/commands/serve.ts + 新测试 + changeset。os dev / os start 都是把 serve 作为子进程拉起、且自身不解析 posture,因此闸门覆盖三个入口。


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 3:39am

Request Review

@github-actions github-actions Bot added the size/m label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli.

21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/validating-metadata.mdx (via packages/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/plugins/index.mdx (via @objectstack/cli)
  • content/docs/plugins/packages.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli)
  • content/docs/releases/v16.mdx (via @objectstack/cli)
  • content/docs/releases/v17.mdx (via @objectstack/cli)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

范围外发现(已另开单,未在本 PR 修)

#5382 —— os doctor 对非法 OS_TENANCY_POSTURE 退出码 0 并报告「Environment is functional」。

同一形状、不同命令:doctor.ts:688resolveTenancyPosture() 落在 config 分析那个宽 try 内,抛错被 doctor.ts:740 的 catch 吞成一句「Could not load config for analysis」——归因错(配置本身没问题),且只记 warning,所以 exit 0。

之所以不在本 PR 里顺手修:本单的文件面被限定为 packages/cli/src/commands/serve.ts,而 #5382 的修复点在 doctor.ts;两者可独立进行,#5382 也不依赖本 PR 合入。

值得单独一提的是它比 serve 那条更别扭的地方:运维正是在 serve 起不来之后去跑 os doctor 的,而它此刻回答「功能正常」。


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 30971902650 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/3) — 失败步骤: Run this shard's tests

    �[41m�[1m FAIL �[22m�[49m src/commands/serve-tenancy-posture-gate.test.ts�[2m > �[22mthe gate runs before serve does ANY boot work�[2m > �[22mrefuses before the config file is even read, and writes no
    

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 0 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 5, 2026
claude added 2 commits August 5, 2026 03:31
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
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

队列分诊闭环(cli 车道 PM):按清单判第 1 类(失败测试在本 PR 包内)。成因非断言回归,是那条顺序断言用例在进程内真实装载 serve 模块 —— 空载 runner 实测 2535ms,队列满载分片(import 94.8s)越过 5s 默认超时。修复:48bb20577 给该用例加显式 }, 60_000)(仓内既有姿态,同 sqlite-occupancy.test.ts:240),其余 10 条 0-3ms 用例不动。与 #4796 家族的 5000ms 签名形似但不同源(该族已由 #4856 修掉,本条为新测试首次入队,独立诊断)。新 head CI 23 项零失败,已重新挂队列。

已知残余:该用例天然是文件内最慢者,随分片负载伸缩 —— 若日后再逼近上限,耐久修法是放弃进程内启动,但那会牺牲顺序断言的证明力,不做单方交易,届时再议。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

非法 OS_TENANCY_POSTURE 的「拒绝启动」实际发生在端口已监听之后 —— 第一次抛错被 serve.ts 的 AuthPlugin 宽 catch 吞掉

2 participants