Skip to content

Commit 07559ce

Browse files
fix(cli): os serve refuses to boot when a plugins entry that may provide the crypto provider does not load (#31263)
<!-- 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-②: 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. <details><summary>Pins, red-first and restore</summary> - 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. </details> <details><summary>Package suite, build and typecheck</summary> - `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`. </details> <details><summary>Derived gates</summary> `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. </details> Not measured: `os start` was not booted separately; it goes through the same `run()`. ## 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 three changed source 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 by [Claude Code](https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d3b6ff4 commit 07559ce

4 files changed

Lines changed: 264 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os serve` refuses to boot when a `plugins` entry that may provide the crypto provider does not load
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`os serve` now refuses to boot when an entry of the stack's `plugins` does not load and nothing rules out that it provides the crypto provider (`crypto-provider`), which seals every secret field, datasource credential and encrypted setting. The refusal names each such entry and the reason it did not load.
11+
12+
- An entry that never materialized, such as a package specifier that does not resolve or import, says nothing about what it provides, so the boot is refused.
13+
- An entry that materialized and declares `providesServices: ['crypto-provider']` is refused.
14+
- An entry whose `providesServices` does not name the crypto provider is ruled out. The boot prints `✗ Failed to load plugin` for it and goes on without it, as before.
15+
16+
This is the rule the one-shot secret commands keep for the same host config.

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
createHostCryptoProvider,
6464
HostCryptoProviderRefusal,
6565
refuseTwoCryptoProviderPlugins,
66+
refuseUnloadedCryptoProviderPlugins,
6667
} from './serve.js';
6768

6869
// [#10126] Pay the first load of what `serve.ts` reaches through dynamic
@@ -445,6 +446,57 @@ describe('two registrations refuse at boot', () => {
445446
});
446447
});
447448

449+
describe('a plugins entry that did not load refuses the boot unless it is ruled out as the crypto provider', () => {
450+
const refusalOf = (unloaded: Parameters<typeof refuseUnloadedCryptoProviderPlugins>[0]): Error | undefined => {
451+
try { refuseUnloadedCryptoProviderPlugins(unloaded); }
452+
catch (err) { return err as Error; }
453+
return undefined;
454+
};
455+
456+
it('an entry that never materialized says nothing it provides, and is refused, named by its specifier', () => {
457+
const refusal = refusalOf([{ index: 2, entry: '@acme/kms-crypto', error: new Error("Failed to import plugin '@acme/kms-crypto': not found") }]);
458+
expect(refusal).toBeInstanceOf(HostCryptoProviderRefusal);
459+
expect(refusal!.message).toContain("plugins[2] ('@acme/kms-crypto') did not load");
460+
expect(refusal!.message).toContain(`'${CRYPTO_PROVIDER_SERVICE}'`);
461+
});
462+
463+
it('an entry that materialized and declares the provider is refused, named by its plugin name', () => {
464+
const plugin = providerPlugin('com.example.aws-kms-crypto', kmsProvider().provider);
465+
const refusal = refusalOf([
466+
{ index: 0, entry: '@acme/aws-kms-crypto', materialized: plugin, error: new Error('Invalid semantic version') },
467+
]);
468+
expect(refusal).toBeInstanceOf(HostCryptoProviderRefusal);
469+
expect(refusal!.message).toContain("plugins[0] ('@acme/aws-kms-crypto')");
470+
const inline = refusalOf([{ index: 1, entry: plugin, materialized: plugin, error: new Error('boom') }]);
471+
expect(inline!.message).toContain("plugins[1] ('com.example.aws-kms-crypto') did not load (boom)");
472+
expectNoKeyMaterial(inline!.message);
473+
});
474+
475+
it('an object entry that failed before it materialized is judged by what the entry itself declares', () => {
476+
const plugin = providerPlugin('com.example.vault-crypto', kmsProvider().provider);
477+
expect(refusalOf([{ index: 0, entry: plugin, error: new Error('boom') }])).toBeInstanceOf(HostCryptoProviderRefusal);
478+
});
479+
480+
it('CONTROL: an entry that declares other services, or none, is ruled out; nothing unloaded is not refused', () => {
481+
expect(refusalOf([
482+
{ index: 0, entry: { name: 'com.example.widgets', providesServices: ['widgets'], init: async () => {} }, error: new Error('x') },
483+
{ index: 1, entry: '@acme/widgets', materialized: { name: 'com.example.plain', init: async () => {} }, error: new Error('y') },
484+
])).toBeUndefined();
485+
expect(refusalOf([])).toBeUndefined();
486+
});
487+
488+
it('every refused entry is named, and the ruled-out ones are not', () => {
489+
const refusal = refusalOf([
490+
{ index: 0, entry: '@acme/one', error: new Error('a') },
491+
{ index: 1, entry: { name: 'com.example.widgets', providesServices: ['widgets'], init: async () => {} }, error: new Error('b') },
492+
{ index: 2, entry: '@acme/two', error: new Error('c') },
493+
]);
494+
expect(refusal!.message).toContain("plugins[0] ('@acme/one')");
495+
expect(refusal!.message).toContain("plugins[2] ('@acme/two')");
496+
expect(refusal!.message).not.toContain('com.example.widgets');
497+
});
498+
});
499+
448500
describe('the deferred path: nothing is sealed by a provider the boot did not choose', () => {
449501
it('a credential bound before the post-start wiring is sealed by the registered provider', async () => {
450502
const kms = kmsProvider();
@@ -564,6 +616,15 @@ describe('the wiring inside run()', () => {
564616
expect(SERVE.match(/setCryptoProvider\(/g)).toHaveLength(1);
565617
});
566618

619+
it('refuses an unloaded entry that may provide the crypto provider right after the plugins loop, before the host provider is built', () => {
620+
const record = SERVE.indexOf('unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });');
621+
const check = SERVE.indexOf('refuseUnloadedCryptoProviderPlugins(unloadedStackPlugins);');
622+
const host = SERVE.indexOf('hostCrypto = createHostCryptoProvider(() => localCryptoProvider.resolve());');
623+
expect(record).toBeGreaterThan(0);
624+
expect(check).toBeGreaterThan(record);
625+
expect(host).toBeGreaterThan(check);
626+
});
627+
567628
it('checks the declared providers before the boot', () => {
568629
const check = SERVE.indexOf('refuseTwoCryptoProviderPlugins([...stackMountedPlugins, ...resolverMounted]);');
569630
const start = SERVE.indexOf('await runtime.start();');

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4712,15 +4712,21 @@ export default class Serve extends Command {
47124712
// `materializeStackPlugin` — so the crypto-provider composition check
47134713
// before `runtime.start()` reads what each one declares.
47144714
const stackMountedPlugins: unknown[] = [];
4715+
// The entries that did not load, with what each materialized to (when it
4716+
// got that far), for `refuseUnloadedCryptoProviderPlugins` below.
4717+
const unloadedStackPlugins: UnloadedStackPlugin[] = [];
47154718
if (plugins.length > 0) {
4719+
let index = -1;
47164720
for (const plugin of plugins) {
4721+
index += 1;
4722+
let materialized: unknown;
47174723
try {
47184724
// [#22301] What an entry becomes — a string is a package specifier,
47194725
// a plain bundle (no `init`) is wrapped into `AppPlugin`, an instance
47204726
// is itself — is ONE rule, `materializeStackPlugin` (`@objectstack/
47214727
// core`), which `@objectstack/verify`'s `bootStack` mounts the same
47224728
// array by. Only the loading is this boot's own:
4723-
const pluginToLoad: any = await materializeStackPlugin(plugin, {
4729+
const pluginToLoad: any = materialized = await materializeStackPlugin(plugin, {
47244730
// Host-anchored, NOT a bare `import()`: this specifier comes from
47254731
// the served app's own config, so what the app DECLARES about it is
47264732
// the contract (commit 9cc6777d3). The helper carries the failure wrapper too.
@@ -4752,9 +4758,13 @@ export default class Serve extends Command {
47524758
trackPlugin(pluginName);
47534759
} catch (e: any) {
47544760
console.error(chalk.red(` ✗ Failed to load plugin: ${e.message}`));
4761+
unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });
47554762
}
47564763
}
47574764
}
4765+
// An entry that did not load and may provide the crypto provider refuses
4766+
// the boot, before any secret is sealed: see the function.
4767+
refuseUnloadedCryptoProviderPlugins(unloadedStackPlugins);
47584768

47594769
// [ADR-0025 §3.5 M1] The plugins `os plugin install` installed for this
47604770
// project and environment, verified again before any of their code is
@@ -6402,6 +6412,58 @@ export function refuseTwoCryptoProviderPlugins(composed: Iterable<unknown>): voi
64026412
}
64036413
}
64046414

6415+
/** A `plugins` entry `os serve` could not load: {@link refuseUnloadedCryptoProviderPlugins}. */
6416+
export interface UnloadedStackPlugin {
6417+
/** Its position in the stack's `plugins`. */
6418+
index: number;
6419+
/** The entry as the config wrote it. */
6420+
entry: unknown;
6421+
/** What it materialized to before it failed; `undefined` when it never did. */
6422+
materialized?: unknown;
6423+
error: unknown;
6424+
}
6425+
6426+
/**
6427+
* Refuse a boot in which a `plugins` entry did not load and nothing rules out
6428+
* that it provides the crypto provider ({@link CRYPTO_PROVIDER_SERVICE}).
6429+
*
6430+
* The provider seals every secret field, datasource credential and encrypted
6431+
* setting, and the host chooses it once at `kernel:ready`
6432+
* ({@link createHostCryptoProvider}). A boot that goes on without the plugin
6433+
* that provides it would choose another one, so it does not go on. An entry is
6434+
* ruled out only by what it says it provides: the plugin it materialized to
6435+
* (or the entry itself, when it is an object) has a `providesServices`
6436+
* (ADR-0116) that does not name the provider. An entry that never
6437+
* materialized (a package specifier that does not resolve or import) says
6438+
* nothing, and is refused. Every other unloaded entry keeps the boot's
6439+
* existing handling: it is printed and the boot goes on without it.
6440+
*
6441+
* The one-shot secret commands keep the same rule over the same host config.
6442+
*/
6443+
export function refuseUnloadedCryptoProviderPlugins(unloaded: readonly UnloadedStackPlugin[]): void {
6444+
const refused = unloaded.filter(({ entry, materialized }) => {
6445+
const said = materialized && typeof materialized === 'object'
6446+
? materialized
6447+
: entry && typeof entry === 'object' ? entry : undefined;
6448+
if (!said) return true;
6449+
const provides = (said as { providesServices?: unknown }).providesServices;
6450+
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
6451+
});
6452+
if (refused.length === 0) return;
6453+
const label = ({ index, entry, materialized }: UnloadedStackPlugin): string => {
6454+
if (typeof entry === 'string') return `plugins[${index}] ('${entry}')`;
6455+
const name = (materialized as { name?: unknown } | null | undefined)?.name ?? (entry as { name?: unknown } | null)?.name;
6456+
return typeof name === 'string' ? `plugins[${index}] ('${name}')` : `plugins[${index}]`;
6457+
};
6458+
const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error));
6459+
throw new HostCryptoProviderRefusal(
6460+
`Refusing to boot: ${refused.map((u) => `${label(u)} did not load (${reason(u.error)})`).join('; ')}, `
6461+
+ `and nothing rules out that ${refused.length === 1 ? 'it provides' : 'one of them provides'} the crypto provider `
6462+
+ `('${CRYPTO_PROVIDER_SERVICE}'). It seals every secret field, datasource credential and encrypted setting, `
6463+
+ 'so this boot does not choose another one. Make the entry load, or remove it from the stack\'s plugins.',
6464+
);
6465+
}
6466+
64056467
/**
64066468
* What {@link createUnknownHostnameGuardPlugin} is constructed with.
64076469
*/
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 40 — spawns `os serve` (src, via runServe) once per single-cause fixture: four boots, two refused and two that reach the ready banner; budget to be re-measured
3+
4+
/**
5+
* `os serve` refuses to boot when a `plugins` entry fails to load and it cannot
6+
* rule out that the entry provides the crypto provider (`crypto-provider`).
7+
*
8+
* The crypto provider seals every secret the deployment holds. A boot that
9+
* goes on without the plugin that provides it chooses another one at
10+
* `kernel:ready`, so this boot refuses instead, naming the entry. That is the
11+
* rule the one-shot secret commands keep for the same host config.
12+
*
13+
* - an entry that does not materialize (a specifier that does not resolve):
14+
* nothing says what it provides, so the boot is refused;
15+
* - an entry that materializes, declares the crypto provider in
16+
* `providesServices`, and is refused by the kernel: refused;
17+
* - CONTROL: an entry that materializes, declares nothing of the kind, and is
18+
* refused by the kernel: the boot goes on, as before, and says so;
19+
* - CONTROL: a config whose every entry loads boots.
20+
*
21+
* A real `os serve` process per case (`runServe`): the refusal is a property of
22+
* the boot loop inside `run()`, which no in-process call reaches.
23+
*/
24+
25+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
26+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
27+
import { tmpdir } from 'node:os';
28+
import { join } from 'node:path';
29+
import { runServe } from './helpers/serve-process.js';
30+
31+
/** Either terminal outcome: the complete ready banner, or the refusal line. */
32+
const SETTLED = /Press Ctrl\+C to stop|Refusing to boot/;
33+
const REFUSED = /Refusing to boot/;
34+
35+
const OBJECT = `objects: [{ name: 'cpl_probe', label: 'Probe', fields: { title: { type: 'text', label: 'Title' } } }],`;
36+
37+
const CONFIGS = {
38+
// A plugin instance beside the specifier makes this a host config, whose
39+
// `plugins` the boot loop mounts (a config of specifiers alone boots the
40+
// library stack instead).
41+
unresolvable: `export default {
42+
plugins: [{ name: 'com.acme.host', version: '1.0.0', init: async () => {} }, '@acme/not-installed-crypto-provider'],
43+
${OBJECT}
44+
};
45+
`,
46+
declaringRefused: `export default {
47+
plugins: [{
48+
name: 'com.acme.kms-crypto-provider',
49+
version: 'not-a-version',
50+
providesServices: ['crypto-provider'],
51+
init: async () => {},
52+
}],
53+
${OBJECT}
54+
};
55+
`,
56+
nonDeclaringRefused: `export default {
57+
plugins: [{
58+
name: 'com.acme.unrelated',
59+
version: 'not-a-version',
60+
providesServices: ['acme-widgets'],
61+
init: async () => {},
62+
}],
63+
${OBJECT}
64+
};
65+
`,
66+
allLoad: `export default {
67+
plugins: [{ name: 'com.acme.fine', version: '1.0.0', init: async () => {} }],
68+
${OBJECT}
69+
};
70+
`,
71+
} as const;
72+
73+
const dirs: Partial<Record<keyof typeof CONFIGS, string>> = {};
74+
75+
beforeAll(() => {
76+
for (const [key, config] of Object.entries(CONFIGS) as Array<[keyof typeof CONFIGS, string]>) {
77+
const dir = mkdtempSync(join(tmpdir(), `os-crypto-plugin-load-${key}-`));
78+
writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8');
79+
dirs[key] = dir;
80+
}
81+
});
82+
83+
afterAll(() => {
84+
for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true });
85+
});
86+
87+
const boot = async (key: keyof typeof CONFIGS) => {
88+
const run = await runServe(dirs[key]!, [], { waitFor: SETTLED, timeoutMs: 150_000 });
89+
return { ...run, output: run.stdout + run.stderr };
90+
};
91+
92+
describe('os serve — a plugins entry that fails to load and may provide the crypto provider', () => {
93+
it('an entry that does not materialize: the boot is refused, naming the entry', async () => {
94+
const { output, port } = await boot('unresolvable');
95+
expect(output).toContain('Failed to load plugin');
96+
expect(output, output).toMatch(REFUSED);
97+
expect(output).toContain('@acme/not-installed-crypto-provider');
98+
expect(output).toContain("'crypto-provider'");
99+
expect(port, 'a refused boot binds nothing').toBeUndefined();
100+
}, 180_000);
101+
102+
it('an entry that declares the crypto provider and is refused by the kernel: the boot is refused, naming it', async () => {
103+
const { output, port } = await boot('declaringRefused');
104+
expect(output).toContain('Failed to load plugin');
105+
expect(output, output).toMatch(REFUSED);
106+
expect(output).toContain('com.acme.kms-crypto-provider');
107+
expect(port).toBeUndefined();
108+
}, 180_000);
109+
110+
it('CONTROL: an entry that declares no crypto provider and is refused by the kernel: the boot goes on', async () => {
111+
const { output, port } = await boot('nonDeclaringRefused');
112+
expect(output).toContain('Failed to load plugin');
113+
expect(output, output).not.toMatch(REFUSED);
114+
expect(output).toMatch(/Press Ctrl\+C to stop/);
115+
expect(port).toBeGreaterThan(0);
116+
}, 180_000);
117+
118+
it('CONTROL: a config whose every entry loads boots', async () => {
119+
const { output, port } = await boot('allLoad');
120+
expect(output).not.toContain('Failed to load plugin');
121+
expect(output, output).not.toMatch(REFUSED);
122+
expect(port).toBeGreaterThan(0);
123+
}, 180_000);
124+
});

0 commit comments

Comments
 (0)