You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit eb92a48
Browse filesBrowse the repository at this point in the historyBrowse files
fix(cli)!: os serve refuses to boot when an installed plugin that may provide the crypto provider is refused at boot (#31449)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1zG3Xn4JonaKvDLLr5gnjL)_
Refs #28284
Clause-②: yes (narrowing) — `os serve` (and the `serve` child of `os
start` and `os dev`) refuses to boot when a plugin `os plugin install`
installed is refused at boot and nothing rules out that it provides the
crypto provider (`crypto-provider`), where every such refusal was
non-fatal and the boot went on; widened beside it: the optional
`declaredServices` on `InstalledPluginRefusal` and
`LoadedInstalledPlugin` (`@objectstack/runtime`).
Metadata-protocol: none
<!-- adr-0087: not-required (no-migration-prescription) no key of any
schema is added, removed or re-shaped and no stored row is converted: a
boot whose installed plugins hold a refusal that may be the crypto
provider stops until the operator fixes that plugin or disables it,
which is a choice about one installed plugin made by hand, not a
mechanical conversion -->
**BREAKING** for a deployment that boots `os serve`, `os start` or `os
dev` with an installed plugin (`os plugin install`) that is refused at
boot.
## 维护者速读
- **改了什么**:用 `os plugin install`
安装的插件,启动时会再验证一次。现在若某个已安装插件在启动时被拒、又无法排除它是加密提供者(`crypto-provider`),`os
serve`(以及 `os start`、`os dev` 的 serve
子进程)拒绝启动,点名该插件、拒绝代码和让下次启动继续的步骤。规则与栈 `plugins` 里加载失败的项相同。
- **为什么改**:QA 第三轮发现的安全加固(细节按披露裁决不公开)
- **风险与回滚**:改 `@objectstack/cli` 和 `@objectstack/runtime`(均为 patch,声明为
`Clause-②: yes (narrowing)`)。
- 以下情况照旧继续启动:没有安装记录的项目;被禁用的插件;运行层级不是 `node`
的插件(`runtime_undeclared`、`runtime_not_dispatched`);已导入、是对象、且
`providesServices` 可读并且不含 `crypto-provider` 的被拒插件。
- 其余被拒的已安装插件会让启动停下,包括整个加载器失败。
- runtime 的两个已发布类型各新增一个可选字段 `declaredServices`。
- 回滚即 revert 本 PR。
- **先看**:`packages/cli/src/commands/serve.ts` 的
`refuseRefusedInstalledCryptoProviderPlugins`,以及它在
`registerInstalledPlugins` 之后的那一次调用。
- **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project
integration test/serve-installed-plugins.integration.test.ts`,预期 9/9。
- **需要你做的**:无,席位做 contract review 后落地。
## What changes
- **Before.** A refused installed plugin was printed (`✗ Refused
installed plugin id@version (code)`), and the boot went on without it,
whatever the refusal.
- **Now.** `os serve` refuses to boot when an installed plugin that may
provide the crypto provider is refused at boot.
- The new `refuseRefusedInstalledCryptoProviderPlugins(refused)` runs
right after the installed plugins register, before the host's crypto
provider is chosen and before any plugin starts.
- It follows the rule `refuseUnloadedCryptoProviderPlugins` keeps for a
stack `plugins` entry.
- **Ruled out** (the boot goes on, as before):
- a refusal whose verified artifact declares a tier this host does not
run in process (`runtime_undeclared`, `runtime_not_dispatched`);
- a plugin that was imported, is an object, and whose `providesServices`
(ADR-0116) could be read and does not name `crypto-provider`.
- **Refused:** every other refusal, including:
- one raised before the import;
- `import_failed`;
- an import that is not an object;
- an imported plugin whose declaration throws when read;
- the loader failing outright (`loader_failed`, `records_unreadable`).
- A disabled plugin is not a refusal.
- **Remedy.** The refusal names each plugin as `id@version` with its
refusal code, and the step that lets the next boot go on. For most codes
that step is `os plugin disable <id>`. Codes that `os plugin disable`
cannot clear name their own step:
- `install_dir_unexpected`: install it again;
- `uninstall_failed` and `record_changed`: a later boot finishes the
uninstall;
- `disabled_state_unreadable`: repair the disable-state file;
- `records_unreadable`: repair the installations file;
- `loader_failed`: fix the loader.
- **`@objectstack/runtime` installed-plugin loader:**
`InstalledPluginRefusal` and `LoadedInstalledPlugin` gain an optional
`declaredServices`.
- It is read once per imported plugin, and set on a refusal raised after
the import (`not_a_plugin`, `signature_conflict`) and on a loaded entry.
- It holds the string entries of `providesServices`, or `[]` when there
are none.
- It is absent when the plugin was not imported, is not an object, or
its declaration throws when read.
- **`registerInstalledPlugins` (cli):** a refusal it raises
(`plugin_name_taken`, `kernel_refused`) carries the loaded entry's
`declaredServices`.
## Why
QA 第三轮发现的安全加固(细节按披露裁决不公开)
## Verification
Readings on head `dedc92d80`: two commits on `main` at `ae816c40b`. The
second answers the contract review: the declaration, a non-object
import, each code's own step, and one declaration read.
<details><summary>Pins, red-first and restore</summary>
- `serve-host-crypto-provider.test.ts`: a new describe block (6 cases)
for `refuseRefusedInstalledCryptoProviderPlugins`, plus a source-order
pin.
- Every pre-import code that `os plugin disable` clears, and
`import_failed`, is refused. The refusal names the plugin, the code and
`os plugin disable <id>`.
- A post-import refusal is judged by `declaredServices`. One that
declares `crypto-provider` is refused. Other services and none are ruled
out. An unreadable declaration is refused.
- CONTROL: `runtime_not_dispatched` and `runtime_undeclared` are ruled
out, and no refusal at all is not refused.
- `loader_failed` is refused with no disable remedy.
- `install_dir_unexpected`, `uninstall_failed`, `record_changed` and
`disabled_state_unreadable` each name their own step and no disable. In
a mixed refusal, only the clearable code gets the disable.
- Several refusals are all named, and the ruled-out ones are not.
- The source-order pin: the check sits after `registerInstalledPlugins`
and before the host provider is built.
- `test/serve-installed-plugins.integration.test.ts` (#27123) now boots
three times with the real `os serve --no-server`
(`OS_MIGRATE_AND_EXIT=1`).
- First, a refused boot: exit non-zero and no `Migration complete`. The
tampered tree (`tree_changed`) and the pre-retirement artifact
(`manifest_invalid`) are named with `os plugin disable` for each. The
sandbox tier is printed as refused but is not a reason the boot stopped.
No installed plugin's `init` ran.
- Then `os plugin disable` of those two, and the file's original two
boots. Those pins are unchanged, except that the tampered and retired
installs now read as disabled in the first of them.
- The test-budget header moves 40 → 60 for the third boot. It reads
22.41 s locally.
- `installed-plugin-loader.test.ts` (runtime, 7 new cases):
- Post-import refusals carry `declaredServices`, with non-string entries
dropped and `[]` for none.
- A non-object import (a number, a class) carries none.
- A throwing read leaves it absent.
- A pre-import refusal carries none.
- A loaded entry carries its declaration.
- `installed-plugins-boot.test.ts` (1 new case): `kernel_refused` and
`plugin_name_taken` copy the loaded entry's `declaredServices`, and
carry none when the loader read none.
- Red-first, first commit: with `serve.ts` set back to its `main` blob,
the integration file plus `serve-host-crypto-provider.test.ts` read 9
failed | 35 passed (44).
- The refused-boot pin failed.
- Two later pins failed, because the untouched plugin's `init` had
already run in that boot.
- The 6 then-new crypto cases failed.
- Red-first, second commit: against the first commit's blobs, the loader
file read 2 failed | 63 passed (65), on the two non-object rows. The
crypto file read 1 failed | 35 passed (36), on the own-step case.
- Restored, on this head:
- the integration file plus the crypto file: 45 passed (45);
- the boot helper: 8/8;
- the loader: 65/65.
</details>
<details><summary>Package suites and typecheck</summary>
- `tsc --noEmit` in `packages/cli` and `packages/runtime`: exit 0.
- On the first commit under the verify lock, before the rebase onto
`ae816c40b`:
- `pnpm --filter @objectstack/runtime test`: Test Files 555 passed
(555), Tests 8962 passed | 19 skipped, `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/cli test`: Test Files 608 passed | 1
skipped (609), Tests 7551 passed | 9 skipped, `VERDICT command-exit 0`.
- The rebase's only overlap with this diff is a 3-line comment change
elsewhere in `serve.ts`. The second commit changes the three files whose
pins are read above.
- `eslint --no-inline-config` on the changed source and test files: exit
0.
- `check-adr-0087-registration`: the changeset reads
`[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`. `check-changeset-no-major`: OK.
</details>
<details><summary>Derived gates</summary>
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derives 72 families on this head.
- Every family ran and exited 0. `check:type-check-debt` ran under the
verify lock.
- `--ran` reconciliation: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN.
</details>
Not measured:
- A real crypto-provider plugin installed and refused end to end. The
integration pins refuse with a plain node plugin; the
provider-declaration branch is pinned at the function and at the loader.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `eslint --no-inline-config` on the changed
files: exit 0.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: not touched.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English, except the maintainer
summary the lane's PRs carry.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(cli)!: `os serve` refuses to boot when an installed plugin that may provide the crypto provider is refused at boot
7
+
8
+
Clause-②: yes (narrowing) — `os serve` (and the `serve` child of `os start` and `os dev`) refuses to boot when a plugin `os plugin install` installed is refused at boot and nothing rules out that it provides the crypto provider (`crypto-provider`), where every such refusal was non-fatal and the boot went on; widened beside it: the optional `declaredServices` on `InstalledPluginRefusal` and `LoadedInstalledPlugin` (`@objectstack/runtime`).
9
+
Metadata-protocol: none
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed or re-shaped and no stored row is converted: a boot whose installed plugins hold a refusal that may be the crypto provider stops until the operator fixes that plugin or disables it, which is a choice about one installed plugin made by hand, not a mechanical conversion -->
12
+
13
+
**BREAKING** for a deployment that boots `os serve`, `os start` or `os dev` with an installed plugin (`os plugin install`) that is refused at boot.
14
+
15
+
-**Before.** A refused installed plugin was printed (`✗ Refused installed plugin id@version (code)`), and the boot went on without it, whatever the refusal.
16
+
-**Now.** The boot stops, before any plugin starts, when a refused installed plugin may provide the crypto provider. That provider seals every secret field, datasource credential and encrypted setting, so the boot does not choose another one. This is the rule a stack `plugins` entry that does not load already follows. A refusal is ruled out, and the boot goes on as before, when:
17
+
- the plugin's verified artifact declares a tier this host does not run in process (`runtime_undeclared`, `runtime_not_dispatched`);
18
+
- or the plugin was imported, is an object, and its `providesServices` (ADR-0116) could be read and does not name `crypto-provider`.
19
+
Every other refusal stops the boot, a loader that fails outright included. A disabled plugin is not a refusal.
20
+
-**What it says.** The refusal names each plugin as `id@version` with its refusal code, and the step that lets the next boot go on. For most codes that is `os plugin disable <id>`.
21
+
- A record whose directory is not its install directory (`install_dir_unexpected`) is installed again.
22
+
- An uninstall the boot could not finish (`uninstall_failed`, `record_changed`) is finished by a later boot.
23
+
- An unreadable disable-state file or installations file is repaired.
24
+
-**`@objectstack/runtime`.**`InstalledPluginRefusal` and `LoadedInstalledPlugin` gain an optional `declaredServices`: what an imported plugin declares in `providesServices`, absent when it was not imported, is not an object, or its declaration could not be read.
25
+
-**Who moves, and the fix.** A deployment whose boot now stops fixes the named plugin (installs it again, trusts its key, or whatever its refusal code names), or runs the step the refusal names, then boots again.
it('refuses a refused installed plugin that may provide the crypto provider right after the installed plugins register, before the host provider is built',()=>{
0 commit comments