Skip to content

fix(cli): os serve refuses to boot when a plugins entry that may provide the crypto provider does not load - #31263

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-fayfxc
Nov 7, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-fayfxc

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Leehom · project thread

Refs #28284

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:os serve 启动时,若栈的 plugins 里某一项没能加载,且无法根据它的声明排除它是加密提供者(crypto-provider),则拒绝启动,并在报错里点名该项(plugins[i] ('<名称或包名>'))与加载失败原因。已加载出插件对象(或本身是对象)、但其 providesServices 未列出 crypto-provider(包括没有 providesServices)的项不受影响:照旧打印 ✗ Failed to load plugin 后继续启动。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
  • 风险与回滚:只改 @objectstack/cli(patch)。所有插件都能加载的启动不受影响。会被新拒绝的只有:包名无法导入的项,以及声明自己提供 crypto-provider 却加载失败的项。规则与一次性 secret 命令对同一 host 配置的规则一致。回滚即 revert 本 PR。
  • 先看:packages/cli/src/commands/serve.ts 的 refuseUnloadedCryptoProviderPlugins,以及 run() 里栈插件循环之后、createHostCryptoProvider 之前的那一次调用。
  • 两分钟验证:pnpm --filter @objectstack/cli exec vitest run --project unit src/commands/serve-host-crypto-provider.test.ts,预期全部通过。
  • 需要你做的:无,席位做 contract review 后落地。

What changes

  • os serve refuses to boot when a plugins entry that may provide the crypto provider does not load. The provider (crypto-provider) seals every secret field, datasource credential and encrypted setting, and the host chooses it once, so a boot without it does not choose another one. The check runs right after the stack's plugins are loaded and before the host's crypto provider is built, in every posture (plugins, and devPlugins under --dev). The refusal names each entry and the reason it did not load.
    • An entry that never materialized (a package specifier that does not resolve or import) declares nothing, so it is refused.
    • An entry that materialized (or is itself an object) and declares providesServices: ['crypto-provider'] (ADR-0116) is refused.
    • An entry that materialized (or is itself an object) and does not name the crypto provider in providesServices, including one that declares no providesServices, is ruled out: it is printed with ✗ Failed to load plugin, and the boot goes on without it, as before.
  • New export refuseUnloadedCryptoProviderPlugins (and its input type UnloadedStackPlugin) beside refuseTwoCryptoProviderPlugins in commands/serve.ts. It throws the existing HostCryptoProviderRefusal.
  • This is the rule the one-shot secret commands keep for the same host config.

Nothing a stack can declare changes; a boot in which every entry loads is unchanged.

Why

QA 第三轮发现的安全加固(细节按披露裁决不公开)

Verification

Every reading below is on head 5ed7975ab (one commit on main at d3f36861e) unless it says otherwise.

Pins, red-first and restore
  • New packages/cli/test/serve-crypto-provider-plugin-load-failure.integration.test.ts: four real os serve boots (src, through the runServe helper), one cause each. Two are refused and name the entry and crypto-provider: an instance declaring providesServices: ['crypto-provider'] that the kernel does not accept, and a package specifier that does not import. Two CONTROLs reach the ready banner on a bound port: an entry declaring another service that the kernel does not accept, and a stack whose every entry loads.
  • packages/cli/src/commands/serve-host-crypto-provider.test.ts: five unit cases for refuseUnloadedCryptoProviderPlugins (never materialized, materialized and declaring, an object entry, a ruled-out CONTROL, several named in one refusal), plus a source pin that the record sits in the plugin loop and the check sits between the loop and createHostCryptoProvider.
  • Red-first, on the pre-rebase commit: with serve.ts set back to its main blob, the integration file read 2 failed | 2 passed (4), the two refusal cases red and the two CONTROLs green. Restored: blob equal to HEAD, git diff HEAD empty, 4 passed (4). The CLI runs from src here, so no rebuild sits between mutation and run.
Package suite, build and typecheck
  • pnpm turbo run build --filter=@objectstack/cli...: 63 tasks successful.
  • tsc --noEmit in packages/cli: exit 0.
  • pnpm --filter @objectstack/cli test under the verify lock: Test Files 605 passed | 1 skipped (606), Tests 7490 passed | 9 skipped (7499), VERDICT command-exit 0.
Derived gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 64 families on this diff, every one run and exit 0 (check:type-check-debt under the verify lock). --ran reconciliation: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.

Not measured: os start was not booted separately; it goes through the same run().

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; eslint --no-inline-config on the three changed source files: exit 0.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English, except the maintainer summary the lane's PRs carry.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…ide the crypto provider does not load

An entry of the stack's plugins that does not load, and that nothing
rules out as the crypto provider, now refuses the boot, naming the entry
and why it did not load. An entry that never materialized says nothing
about what it provides and is refused; one that declares the crypto
provider in providesServices is refused; one that declares other
services, or none, is printed and the boot goes on as before.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 47 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 455229e7aebae2fe91ada77736377fe145defba3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 867f8bb8a26fe5e434850ba0aaa0cd1e99daafad — the merge of head 5ed7975abfba52f8ad14d088902b79711e14ef4f into base 455229e7aebae2fe91ada77736377fe145defba3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 867f8bb8a26fe5e434850ba0aaa0cd1e99daafad && git checkout 867f8bb8a26fe5e434850ba0aaa0cd1e99daafad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 455229e7aebae2fe91ada77736377fe145defba3 5ed7975abfba52f8ad14d088902b79711e14ef4f && git checkout -B drift-repro 455229e7aebae2fe91ada77736377fe145defba3 && git merge --no-ff 5ed7975abfba52f8ad14d088902b79711e14ef4f

node scripts/docs-audit/affected-docs.mjs --json 455229e7aebae2fe91ada77736377fe145defba3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract-review-claim: session_01FEkQ5o8PE7ZtVjcor51wBZ · head 5ed7975 · 2026-11-07T20:45Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5ed7975abfba52f8ad14d088902b79711e14ef4f
Local-runs: none
Scope: packages/cli/src/commands/serve.ts, packages/cli/src/commands/serve-host-crypto-provider.test.ts, packages/cli/test/serve-crypto-provider-plugin-load-failure.integration.test.ts + packages/types/src/crypto-provider-service.ts, packages/cli/src/utils/one-shot-settings.ts, packages/cli/src/utils/stack-collections.ts, packages/cli/src/utils/plugin-detection.ts, packages/cli/src/utils/merge-boot-config.ts, packages/cli/src/commands/start.ts, packages/cli/src/index.ts, packages/cli/package.json, packages/cli/test/helpers/serve-process.ts, packages/core/src/stack-plugins.ts, packages/core/src/kernel.ts, packages/core/src/plugin-loader.ts, docs/adr/0116-plugin-ordering-declared-contract.md, .changeset/31007-one-shot-crypto-provider.md (precedent, read for ②)

Inputs: the PR body, its one commit, the four-path net diff from merge-base 0d0c377b (the head sits one commit on main at d3f36861e), the check-runs on the head (32 success, 13 skipped, 0 failure; Test Core (1/8) and Test Core (4/8) still in progress when this record was rendered, nothing concluded red), and the drafter's private hand-back, whose claims were checked against the tree. No card, by the maintainer's ruling for this series; #28284 is the series mailbox and carries nothing on this PR.

① Derived judgments

  • Accept set of os serve (@objectstack/cli), narrowed on exactly two shapes. After the stack-plugin loop in run() (serve.ts 4719-4765 on the head) and before the host crypto provider is built (5125), refuseUnloadedCryptoProviderPlugins (6443) throws HostCryptoProviderRefusal for every entry the loop could not load that is (a) a string specifier that never materialized, or (b) an entry that materialized to, or is itself, an object whose providesServices names 'crypto-provider'. The throw escapes to run()'s outer catch (6078), which prints the message and exits 1; the integration pin shows the refusal line and no bound port. Right: the published provider contract on main (packages/types/src/crypto-provider-service.ts) says a provider plugin declares the service in providesServices, and the one-shot secret commands on main (one-shot-settings.ts, readDeclaredCryptoProvider) already refuse the same two shapes over the same host config. This is that rule applied to the long-lived host.
  • Entries that declare other services, or none, keep the old handling (printed ✗ Failed to load plugin, boot goes on). Right against the contract, which is declaration-based (the same reading cryptoProviderPlugins takes on main), but the new docstring says otherwise (see ④, should-fix 1).
  • Postures. The check sits unconditionally in run(), so it holds in production and under --dev; stackBootPlugins appends devPlugins after plugins under --dev, so they are covered; os start spawns a serve child (start.ts) and so shares it. Right, with one labelling caveat (④, should-fix 2).
  • Public surface: unchanged. The two new named exports (refuseUnloadedCryptoProviderPlugins, UnloadedStackPlugin) sit in commands/serve.ts; the package's export map reaches only dist/index.js, ./console, ./hook-body, and src/index.ts re-exports only the default ServeCommand. Nothing new is reachable from @objectstack/cli. Right.
  • Refusal text. Names each refused entry as plugins[i] ('specifier or name') with the load error, lists only refused entries (ruled-out ones are not named), and carries no registered value. Right (unit pin expectNoKeyMaterial).
  • Metadata-protocol: none. Nothing a metadata author writes changes. Right.
  • Order of events. Record in the loop's catch (4761) precedes the check (4767), which precedes provider construction (5125); the unit source-pin holds the three strings, all present on the head at those lines. Right.

② Semver level

  • Changeset: '@objectstack/cli': patch, a bug fix in a released package; correct level, and skip-changeset would have been wrong.
  • Clause-②: no is correct, not no (narrowing). The question is whether a published package's accept set or public surface is narrowed. The surface is unchanged (①). The configs now refused are the two shapes the published provider contract on main already refuses for the same host config in the one-shot secret commands; the long-lived host was the one consumer still booting them after printing a load error. That is the spec's own refusal made real at the remaining consumer, not a declared capability taken away: no public doc on main promises that os serve goes on after a plugins entry fails to load (content/docs searched), and nothing an author can write is removed or renamed, so no FROM-to-TO migration exists to carry. Precedent: fix(cli): one-shot secret commands seal with the crypto provider os serve uses #31228 (.changeset/31007-one-shot-crypto-provider.md, merged) refused previously-succeeding one-shot runs on the same configs and declared Clause-②: no at patch. Both declaration lines are present and identical in the PR body and the changeset.
  • Disclosure check (PR body, commit message, changeset, new source comments and test headers): every sentence stays at the level of behaviour and of the provider contract main already publishes in packages/types/src/crypto-provider-service.ts and serve.ts's existing comments; the why line is the maintainer's fixed one, 「QA 第三轮发现的安全加固(细节按披露裁决不公开)」. No breach found.

③ Boundary flags

  1. Hand-back findings 1 and 2 (pre-existing, outside this PR's narrowing). Both confirmed in the tree; neither is a gap this PR opens or claims to cover. Escalated: each becomes its own private item in this series; details stay off GitHub under the disclosure ruling.
  2. Hand-back finding 3 (a provider plugin that loads but whose init() fails). Answered, closed: the boot exits 1. No gap.
  3. Hand-back finding 4 (overlap with fix(cli): one-shot secret commands seal with the crypto provider os serve uses #31228). Resolved as stated; but the PR landed second and did not adopt the shared reading (④, should-fix 3).
  4. os start not measured. Answered, closed: start.ts spawns the serve child; same run(), same check.
  5. Caller-class matrix n/a. Agreed: a boot-time host decision with no request caller.

④ Design judgment

The narrowing, read against the spec on main: it does not take a declared capability away at the consumer. The capability the author declares is the plugin; the behaviour removed is the host's own leniency after it had already rejected that plugin with an error line, a leniency no published doc declares and one the published provider contract and the one-shot rule on main already refuse. The design holds; three should-fixes, no must-fix.

Break attempts, each against the live tree: six shapes of an entry failing to load, or being dropped, between the stack loop and the provider construction. None defeats the check within this PR's scope; the detail is in the seat's private notes, under the disclosure ruling.

Findings:

  1. Should-fix: the docstring states a stricter rule than the code applies. refuseUnloadedCryptoProviderPlugins's comment says an entry is ruled out "only by what it says it provides: ... has a providesServices (ADR-0116) that does not name the provider", and the PR body says the same; the code and the CONTROL pin also rule out a materialized object with no providesServices at all, as the commit message says ("or none"). The code is the contract-consistent reading; make the docstring and the PR body say it plainly ("declares other services, or declares none, is ruled out").
  2. Should-fix: label a devPlugins entry as such. Under --dev the index is a position in the merged list, so a devPlugins[j] entry is named plugins[plugins.length + j]. Record the source list in UnloadedStackPlugin (or offset the index) so the refusal names where the entry actually sits; the name in parentheses keeps it findable meanwhile.
  3. Should-fix: one reading of the declaration. The new function restates the providesServices predicate that cryptoProviderPlugins (6382) already is, which one-shot-settings.ts names as "the one reading" serve and the one-shot commands share. Route the ruling-out through that predicate so the two cannot drift.

Implemented-by: claude/project-thread-fayfxc
Reviewed-by: session_01FEkQ5o8PE7ZtVjcor51wBZ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

席位意见:接受。os serve 在一个可能提供加密提供者的 plugins 项没能加载时拒绝启动,并点名该项;声明了其它服务(或什么都没声明)的项照旧跳过。契约复审 PASS,无必改项。

席位核对 — ACCEPT · domain:server#5 · Head-sha 5ed7975abfba52f8ad14d088902b79711e14ef4f · 2026-11-07T21:07Z

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」. Security work in this series follows the maintainer's 「所有的安全卡不要创建issue,直接派发处理修复」, so the PR refers to the series mailbox (Refs #28284, no closing keyword) and has no card.

  • Shape: 4 files (+264 / −1), read from the diff: packages/cli/src/commands/serve.ts, its unit test, one new integration test, one changeset. One commit on main at d3f36861e, no force push. Clause-②: no, Metadata-protocol: none; changeset @objectstack/cli patch. No new export reachable from the package.
  • Review: CONTRACT_REVIEW_TIER (fable), a reviewer other than the author: PASS on this head (6712974584), no must-fix, three should-fixes. Should-fix 1 (prose) is closed in the PR body without a push, so the head is unchanged; should-fixes 2 and 3 ride a follow-up on a fresh branch after this merges. Out-of-scope items stay off GitHub under the disclosure ruling. Ruling-ref: 2026-11-07T16:00Z 「你有 fable ,车道契约复审可以自己做」.
  • Gates: head check-runs 37 success, 15 skipped, none red or pending. check-expected-skips: OK (exit 0). check-governed-merges: NOT governed, size under the human-merge threshold. The drafter's dispatch-gates --commands: 64 derived, 64 run, all exit 0. Mergeable state clean.
  • Landing: ungoverned, tier S. Ready + auto-merge in one relay dispatch.

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants