Skip to content

Commit eb92a48

Browse 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>
1 parent 708922f commit eb92a48

8 files changed

Lines changed: 358 additions & 29 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
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.

‎packages/cli/src/commands/serve-host-crypto-provider.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
refuseTwoCryptoProviderPlugins,
6767
refuseUnloadedCryptoProviderPlugins,
6868
refuseSetAsideCryptoProviderPlugins,
69+
refuseRefusedInstalledCryptoProviderPlugins,
6970
} from './serve.js';
7071

7172
// [#10126] Pay the first load of what `serve.ts` reaches through dynamic
@@ -519,6 +520,82 @@ describe('a plugins entry that did not load refuses the boot unless it is ruled
519520
});
520521
});
521522

523+
describe('an installed plugin refused at boot refuses the boot unless it is ruled out as the crypto provider', () => {
524+
type Refused = Parameters<typeof refuseRefusedInstalledCryptoProviderPlugins>[0][number];
525+
const refusalOf = (refused: Refused[]): Error | undefined => {
526+
try { refuseRefusedInstalledCryptoProviderPlugins(refused); }
527+
catch (err) { return err as Error; }
528+
return undefined;
529+
};
530+
const refused = (id: string, error: Refused['error'], declaredServices?: string[]): Refused =>
531+
({ id, version: '1.0.0', error, message: 'x', ...(declaredServices ? { declaredServices } : {}) });
532+
533+
it('a plugin refused before its import says nothing it provides, and is refused, named with its reason and the way to boot without it', () => {
534+
for (const error of ['tree_changed', 'signature_invalid', 'signature_key_revoked', 'artifact_changed', 'manifest_invalid', 'entry_missing', 'import_failed'] as const) {
535+
const refusal = refusalOf([refused('com.acme.kms', error)]);
536+
expect(refusal, error).toBeInstanceOf(HostCryptoProviderRefusal);
537+
expect(refusal!.message).toContain(`installed plugin com.acme.kms@1.0.0 was refused (${error})`);
538+
expect(refusal!.message).toContain(`'${CRYPTO_PROVIDER_SERVICE}'`);
539+
expect(refusal!.message).toContain('os plugin disable com.acme.kms');
540+
}
541+
});
542+
543+
it('a plugin refused after its import is judged by what it declares: the provider is refused, other services or none are ruled out', () => {
544+
for (const error of ['not_a_plugin', 'signature_conflict', 'plugin_name_taken', 'kernel_refused'] as const) {
545+
expect(refusalOf([refused('com.acme.kms', error, [CRYPTO_PROVIDER_SERVICE])]), error).toBeInstanceOf(HostCryptoProviderRefusal);
546+
expect(refusalOf([refused('com.acme.widgets', error, ['widgets'])]), error).toBeUndefined();
547+
expect(refusalOf([refused('com.acme.plain', error, [])]), error).toBeUndefined();
548+
// Imported, but its declaration could not be read: nothing rules it out.
549+
expect(refusalOf([refused('com.acme.unread', error)]), error).toBeInstanceOf(HostCryptoProviderRefusal);
550+
}
551+
});
552+
553+
it('CONTROL: a tier this host does not run in process is ruled out; no refusal is not refused', () => {
554+
expect(refusalOf([refused('com.acme.sandboxed', 'runtime_not_dispatched'), refused('com.acme.untiered', 'runtime_undeclared')])).toBeUndefined();
555+
expect(refusalOf([])).toBeUndefined();
556+
});
557+
558+
it('the loader failing outright is refused, with no plugin to disable', () => {
559+
const refusal = refusalOf([{ id: '*', version: '*', error: 'loader_failed', message: 'x' }]);
560+
expect(refusal).toBeInstanceOf(HostCryptoProviderRefusal);
561+
expect(refusal!.message).toContain('the installed plugins could not be read (loader_failed)');
562+
expect(refusal!.message).not.toContain('os plugin disable');
563+
});
564+
565+
it('a code os plugin disable cannot clear names its own step instead of the disable', () => {
566+
for (const [error, step] of [
567+
['install_dir_unexpected', 'Install com.acme.kms again (os plugin install)'],
568+
['uninstall_failed', 'com.acme.kms is being uninstalled'],
569+
['record_changed', 'com.acme.kms is being uninstalled'],
570+
['disabled_state_unreadable', "Repair this environment's disable-state file"],
571+
] as const) {
572+
const refusal = refusalOf([refused('com.acme.kms', error)]);
573+
expect(refusal, error).toBeInstanceOf(HostCryptoProviderRefusal);
574+
expect(refusal!.message).toContain(`installed plugin com.acme.kms@1.0.0 was refused (${error})`);
575+
expect(refusal!.message).toContain(step);
576+
expect(refusal!.message).not.toContain('os plugin disable');
577+
}
578+
const mixed = refusalOf([refused('com.acme.one', 'tree_changed'), refused('com.acme.two', 'install_dir_unexpected')]);
579+
expect(mixed!.message).toContain('boot without it: os plugin disable com.acme.one.');
580+
expect(mixed!.message).toContain('Install com.acme.two again (os plugin install)');
581+
expect(mixed!.message).not.toContain('os plugin disable com.acme.two');
582+
});
583+
584+
it('every refused plugin is named, and the ruled-out ones are not', () => {
585+
const refusal = refusalOf([
586+
refused('com.acme.one', 'tree_changed'),
587+
refused('com.acme.widgets', 'kernel_refused', ['widgets']),
588+
refused('com.acme.sandboxed', 'runtime_not_dispatched'),
589+
refused('com.acme.two', 'signature_key_untrusted'),
590+
]);
591+
expect(refusal!.message).toContain('com.acme.one@1.0.0 was refused (tree_changed)');
592+
expect(refusal!.message).toContain('com.acme.two@1.0.0 was refused (signature_key_untrusted)');
593+
expect(refusal!.message).toContain('os plugin disable com.acme.one, os plugin disable com.acme.two');
594+
expect(refusal!.message).not.toContain('com.acme.widgets');
595+
expect(refusal!.message).not.toContain('com.acme.sandboxed');
596+
});
597+
});
598+
522599
describe('a plugins entry the standalone boot sets aside refuses the boot unless it is ruled out as the crypto provider', () => {
523600
const refusalOf = async (authored: unknown, importSpecifier: (specifier: string) => Promise<unknown>): Promise<Error | undefined> => {
524601
try { await refuseSetAsideCryptoProviderPlugins(authored, importSpecifier); }
@@ -692,6 +769,15 @@ describe('the wiring inside run()', () => {
692769
expect(host).toBeGreaterThan(check);
693770
});
694771

772+
it('refuses a refused installed plugin that may provide the crypto provider right after the installed plugins register, before the host provider is built', () => {
773+
const register = SERVE.indexOf('const installedPluginsBoot = await registerInstalledPlugins({ kernel, track: trackPlugin });');
774+
const check = SERVE.indexOf('refuseRefusedInstalledCryptoProviderPlugins(installedPluginsBoot?.refused ?? []);');
775+
const host = SERVE.indexOf('hostCrypto = createHostCryptoProvider(() => localCryptoProvider.resolve());');
776+
expect(register).toBeGreaterThan(0);
777+
expect(check).toBeGreaterThan(register);
778+
expect(host).toBeGreaterThan(check);
779+
});
780+
695781
it('refuses a set-aside entry that may provide the crypto provider before the standalone stack replaces the authored plugins', () => {
696782
const branch = SERVE.indexOf('if (useArtifactFallback || shouldBootWithLibrary(config)) {');
697783
const check = SERVE.indexOf('await refuseSetAsideCryptoProviderPlugins(');

‎packages/cli/src/commands/serve.ts‎

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import { missingProviderMessage } from '../utils/capability-preflight.js';
8383
// app-declared package wins there (per commit 5a90c56d1).
8484
import { resolveDeploymentAppName } from '@objectstack/plugin-email';
8585
import { createHash } from 'node:crypto';
86-
import { resolveObjectStackHome } from '@objectstack/runtime';
86+
import { resolveObjectStackHome, type InstalledPluginRefusal } from '@objectstack/runtime';
8787
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
8888
// The ONE port contract — range, reader and refusal prose — shared with the two
8989
// commands that spawn this one (#12673). `serve`'s own door below calls it
@@ -4787,9 +4787,11 @@ export default class Serve extends Command {
47874787
// environment's trusted keys, the unpacked tree, the `runtime` tier) and
47884788
// registered here, after the config's own `plugins`, while the kernel
47894789
// still takes plugins. Only `runtime: 'node'` is dispatched; a refused
4790-
// plugin is printed by name and the boot goes on without it. No
4791-
// installations file, no read. See `../utils/installed-plugins-boot.ts`.
4790+
// plugin is printed by name, and the boot goes on without it unless it
4791+
// may provide the crypto provider: see the function. No installations
4792+
// file, no read. See `../utils/installed-plugins-boot.ts`.
47924793
const installedPluginsBoot = await registerInstalledPlugins({ kernel, track: trackPlugin });
4794+
refuseRefusedInstalledCryptoProviderPlugins(installedPluginsBoot?.refused ?? []);
47934795

47944796
// Register REST API and Dispatcher plugins (consume http.server + protocol services)
47954797
//
@@ -6483,6 +6485,69 @@ export function refuseUnloadedCryptoProviderPlugins(unloaded: readonly UnloadedS
64836485
);
64846486
}
64856487

6488+
/**
6489+
* Refuse a boot in which a plugin `os plugin install` installed was refused at
6490+
* boot and nothing rules out that it provides the crypto provider
6491+
* ({@link CRYPTO_PROVIDER_SERVICE}): the rule
6492+
* {@link refuseUnloadedCryptoProviderPlugins} keeps for the stack's `plugins`.
6493+
*
6494+
* A refusal is ruled out when the plugin's verified artifact declares a tier
6495+
* this host does not run in process (`runtime_undeclared`,
6496+
* `runtime_not_dispatched`), or when the plugin was imported and what it
6497+
* declares in `providesServices` (ADR-0116) could be read and does not name
6498+
* the provider. Every other refusal, the loader's own failure included, says
6499+
* nothing it provides, and is refused. A disabled plugin is not a refusal.
6500+
*
6501+
* The refusal names each plugin with its refusal code and the step that lets
6502+
* the next boot go on: `os plugin disable <id>` for most codes. A code that
6503+
* `os plugin disable` cannot clear names its own step instead: a record whose
6504+
* directory is not its install directory is checked before the disabled set,
6505+
* so it is installed again; an uninstall the boot could not finish is finished
6506+
* by a later boot; an unreadable disable-state file, or installations file,
6507+
* is repaired; a loader that failed outright is fixed.
6508+
*/
6509+
export function refuseRefusedInstalledCryptoProviderPlugins(refused: readonly InstalledPluginRefusal[]): void {
6510+
const stopping = refused.filter(({ error, declaredServices }) => {
6511+
if (error === 'runtime_undeclared' || error === 'runtime_not_dispatched') return false;
6512+
return declaredServices === undefined || declaredServices.includes(CRYPTO_PROVIDER_SERVICE);
6513+
});
6514+
if (stopping.length === 0) return;
6515+
const unnamed = ({ id }: InstalledPluginRefusal): boolean => id === '*' || id === '?';
6516+
const label = (r: InstalledPluginRefusal): string =>
6517+
unnamed(r) ? `the installed plugins could not be read (${r.error})` : `installed plugin ${r.id}@${r.version} was refused (${r.error})`;
6518+
/** The step for a code `os plugin disable` cannot clear; `null` when it can. */
6519+
const ownStep = ({ id, error }: InstalledPluginRefusal): string | null => {
6520+
switch (error) {
6521+
case 'install_dir_unexpected':
6522+
return `Install ${id} again (os plugin install): its record names a directory that is not its install directory.`;
6523+
case 'uninstall_failed':
6524+
case 'record_changed':
6525+
return `${id} is being uninstalled: fix what stopped it, and a later boot finishes the uninstall.`;
6526+
case 'disabled_state_unreadable':
6527+
return "Repair this environment's disable-state file: until it reads, no installed plugin can be disabled.";
6528+
case 'records_unreadable':
6529+
return "Repair this project's installations file.";
6530+
case 'loader_failed':
6531+
return 'Fix what failed the installed-plugin loader.';
6532+
default:
6533+
return null;
6534+
}
6535+
};
6536+
const disables = [...new Set(stopping.filter((r) => !unnamed(r) && ownStep(r) === null).map(({ id }) => `os plugin disable ${id}`))];
6537+
const steps = [
6538+
...(disables.length > 0
6539+
? [`Fix what refused ${disables.length === 1 ? 'it' : 'them'}, or boot without ${disables.length === 1 ? 'it' : 'them'}: ${disables.join(', ')}.`]
6540+
: []),
6541+
...new Set(stopping.map(ownStep).filter((step): step is string => step !== null)),
6542+
];
6543+
throw new HostCryptoProviderRefusal(
6544+
`Refusing to boot: ${stopping.map(label).join('; ')}, and nothing rules out that `
6545+
+ `${stopping.length === 1 ? 'it provides' : 'one of them provides'} the crypto provider ('${CRYPTO_PROVIDER_SERVICE}'). `
6546+
+ 'It seals every secret field, datasource credential and encrypted setting, so this boot does not choose another one. '
6547+
+ steps.join(' '),
6548+
);
6549+
}
6550+
64866551
/**
64876552
* Refuse a boot that would set aside a `plugins` entry which may provide the
64886553
* crypto provider ({@link CRYPTO_PROVIDER_SERVICE}).

‎packages/cli/src/utils/installed-plugins-boot.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ vi.mock('./format.js', () => ({ isExitSignal: () => false }));
3434

3535
import { registerInstalledPlugins } from './installed-plugins-boot.js';
3636

37+
// Paid at collection, not inside a clocked case: what the code under test
38+
// reaches through dynamic `import()`s (`check:test-source-alias`).
39+
import '@objectstack/service-datasource';
40+
import '@objectstack/driver-turso';
41+
import '@objectstack/driver-sql';
42+
import '@objectstack/platform-objects/plugin';
43+
import '@objectstack/plugin-security';
44+
import '@objectstack/rest';
45+
3746
let dir: string;
3847

3948
beforeEach(() => {
@@ -121,6 +130,29 @@ describe('the serve wiring of installed plugins', () => {
121130
expect(k.used).toEqual([]);
122131
});
123132

133+
it('a plugin refused here carries what the loader read it declares; one the loader could not read carries none', async () => {
134+
const kms = { name: 'kms', init() {} };
135+
const dup = { name: 'host-plugin', init() {} };
136+
const unread = { name: 'unread', init() {} };
137+
stub.load = async () => ({
138+
loaded: [
139+
{ record: record('com.acme.kms'), plugin: kms, declaredServices: ['crypto-provider'] },
140+
{ record: record('com.acme.dup'), plugin: dup, declaredServices: [] },
141+
{ record: record('com.acme.unread'), plugin: unread },
142+
],
143+
refused: [],
144+
disabled: [],
145+
removed: [],
146+
});
147+
const k = kernel({ taken: ['host-plugin'], use: async () => { throw new Error('no'); } });
148+
const out = await boot(k);
149+
expect(out?.refused).toEqual([
150+
{ id: 'com.acme.kms', version: '1.0.0', error: 'kernel_refused', message: 'no', declaredServices: ['crypto-provider'] },
151+
expect.objectContaining({ id: 'com.acme.dup', error: 'plugin_name_taken', declaredServices: [] }),
152+
{ id: 'com.acme.unread', version: '1.0.0', error: 'kernel_refused', message: 'no' },
153+
]);
154+
});
155+
124156
it('reads nothing when this project has no installations file', async () => {
125157
rmSync(stub.installationsFile);
126158
stub.load = async () => { throw new Error('the loader ran'); };

0 commit comments

Comments
 (0)