Skip to content

Commit 4cd48e2

Browse files
fix(cli)!: the one-shot secret commands choose the crypto provider from the installed plugins too, by os serve's rule (#31506)
<!-- 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 secret rewrap`, `os secret orphans` and the data migrations' settings arm refuse to run when a plugin `os plugin install` installed is refused and nothing rules out that it provides the crypto provider (`crypto-provider`), and when a config plugin and an installed plugin both declare it, where they ran without reading the installed plugins; widened beside it: the optional `leaveUninstallsPending` on `LoadInstalledPluginsInput` (`@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 one-shot run 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 runs `os secret rewrap`, `os secret orphans` or a data migration with an installed plugin (`os plugin install`) that `os serve` would refuse to boot with, or with a crypto provider declared both in the host config's `plugins` and by an installed plugin. ## 维护者速读 - **改了什么**:一次性命令(`os secret rewrap`、`os secret orphans`、数据迁移的 settings 部分)选择加密提供者时,现在除了宿主配置的 `plugins`,也读取 `os plugin install` 安装的插件,走 `os serve` 启动时读取它们的同一个加载器,并按 `os serve` 的同一规则判断。 - 已加载且声明 `crypto-provider` 的已安装插件,就是本次运行加解密所用的插件。 - 被拒且无法排除是加密提供者的已安装插件,会让本次运行拒绝执行。 - 两处同时声明加密提供者,也拒绝执行。 - 本次运行不注册、也不移除任何已安装插件;待完成的卸载留给下次启动。 - **为什么改**:QA 第三轮发现的安全加固(细节按披露裁决不公开) - **风险与回滚**:改 `@objectstack/cli` 和 `@objectstack/runtime`(均为 patch,声明为 `Clause-②: yes (narrowing)`)。 - 没有安装记录的项目,行为不变。 - runtime 的 `loadInstalledPlugins` 新增一个可选输入 `leaveUninstallsPending`。 - 回滚即 revert 本 PR。 - **先看**:`packages/cli/src/utils/one-shot-settings.ts` 的 `readDeclaredCryptoProvider` 和新的 `readInstalledPlugins`;`packages/cli/src/utils/installed-plugins-boot.ts` 的 `loadHostInstalledPlugins`。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run src/utils/one-shot-crypto-installed-plugins.test.ts`,预期 11/11。 - **需要你做的**:无,席位做 contract review 后落地。 ## What changes - **Before.** A one-shot run (`oneShotCrypto`) chose the crypto provider from the host config's `plugins` only. The installed plugins of the project were not read. - **Now.** `readDeclaredCryptoProvider` reads the installed plugins beside the config's `plugins`, and judges them by `os serve`'s rule. - **The read.** It goes through the new `loadHostInstalledPlugins` (cli `utils/installed-plugins-boot.ts`). That is the loader call `registerInstalledPlugins` already made, split out so `os serve` and the one-shot run read the installed set one way: the trusted keys, the bundle wrap, host package resolution, and a loader failure as one `loader_failed` refusal. `registerInstalledPlugins` now calls it, unchanged otherwise. - **An installed plugin that loads and declares `crypto-provider`** is the declaring plugin, composed into the run's boot exactly as a config one is. - **A refused installed plugin** goes through `serve`'s `refuseRefusedInstalledCryptoProviderPlugins`: the same rule-outs, the same refusal and the same per-code step. - **Two declarations**, config or installed, are refused by `refuseTwoCryptoProviderPlugins`, both named. - **A name already taken:** an installed plugin whose name a config plugin, or an installed plugin read before it, takes is refused as `plugin_name_taken`, and one whose name cannot be read as `kernel_refused` (with the thrown reason), as `os serve` refuses them. - **A signed installed provider** boots: the wrapper that names the provider when its `init` fails carries the artifact verdict its host recorded for the plugin. - **The refusal texts** say an installed provider was installed by `os plugin install`, and a config one that it is declared in the host config. - **With no host config**, the installed plugins are still read, as `os serve` reads them. - **Nothing is registered or removed.** The run passes `leaveUninstallsPending: true`. - **`@objectstack/runtime`:** `LoadInstalledPluginsInput.leaveUninstallsPending?`. When it is set, a pending uninstall is neither finished nor loaded, as the next boot that finishes it would not load it either. When it is unset, `os serve`'s boot is unchanged. ## Why QA 第三轮发现的安全加固(细节按披露裁决不公开) ## Verification Readings on head `9f07d719c` (two commits on `main` at `a61ddc93a`; the second answers review round 1). <details><summary>Pins, red-first and restore</summary> - New `packages/cli/src/utils/one-shot-crypto-installed-plugins.test.ts` (11). It stands in for `loadHostInstalledPlugins`, whose verification is pinned in the runtime suite and end to end in `test/serve-installed-plugins.integration.test.ts`. It boots each composition in a real `ObjectKernel`. - With no host config, and again beside a config that declares none, an installed plugin that loads and declares the provider is the one the run seals and opens with. The local key is never resolved, even with `OS_SECRET_KEY` set. - Refused, each naming the plugin, its code and the step: - a refused installed plugin (`tree_changed`); - an imported refused plugin that declares the provider; - `loader_failed`. - CONTROL: a ruled-out refusal (`runtime_not_dispatched`, or a `kernel_refused` plugin that declares other services), a loaded plugin that declares none, and no installations file each leave the local provider over the existing key. - Refused before either initializes: a config plugin and an installed plugin that both declare it, both named. - Refused as `plugin_name_taken`: an installed declaring plugin whose name a config plugin takes; and one whose name an installed plugin read before it takes (that first plugin is not named). - A signed installed provider, with the verdict recorded on it as the loader records it, boots and seals with its provider. - An installed provider that registers none, and one whose `init` throws, are each refused with "as a plugin installed by `os plugin install`" and never "host config". - The loader is asked with `{ leaveUninstallsPending: true }`. - `installed-plugin-loader.test.ts` (runtime, 1 new case): with `leaveUninstallsPending`, a pending uninstall keeps its directory and record and is not loaded, while the next plugin loads. CONTROL: the default read finishes it. - Red-first with `main`'s blobs: - `one-shot-settings.ts`: the new file read 7 failed | 1 passed (8). The control case was the one that passed. - `installed-plugin-loader.ts`: the loader file read 1 failed | 65 passed (66). - Round 1, `one-shot-settings.ts` at `f89f99cd0`'s blob: the new file read 3 failed | 8 passed (11). The three new cases failed. - Restored, on this head: 11/11 and 66/66. The five cli files around it (the new file, `one-shot-crypto-provider`, `one-shot-settings.pin`, `installed-plugins-boot`, `serve-host-crypto-provider`) read 74/74. `one-shot-crypto-provider.test.ts` and `installed-plugins-boot.test.ts` are unchanged and green (24/24 across the three cli files). </details> <details><summary>Package suites and typecheck</summary> - `tsc --noEmit` in `packages/cli` and `packages/runtime`: exit 0 (cli again on this head). - Under the verify lock, on `f89f99cd0` and before the rebase onto `a61ddc93a`, which touched no file of this diff: - `pnpm --filter @objectstack/runtime test`: Test Files 560 passed (560), Tests 9001 passed | 19 skipped, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/cli test` (both tiers, so the one-shot family integration and the `os secret` driver contracts too): Test Files 612 passed | 1 skipped (613), Tests 7591 passed | 9 skipped, `VERDICT command-exit 0`. - `eslint --no-inline-config` on the changed source and test files: exit 0. - `check-adr-0087-registration`: `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 66 families on this diff, the same list on both heads. They ran again on this head after a cli build. - Every family ran and exited 0. `check:type-check-debt` ran under the verify lock. - `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. </details> Not measured: - A one-shot command end to end over a real signed installed provider plugin. The installed set's verification and import are the loader `os serve` uses, which is pinned on its own, and the choice over it, signed stub included, is pinned here with a stand-in loader. - The package suites on this head: the round-1 delta touches `one-shot-settings.ts`, its new test file and the changeset only. - A name an installed plugin shares with a plugin `os serve` auto-registers (not one of the host config's). `os serve` refuses it as `plugin_name_taken`, but the one-shot run does not compose those plugins, so it compares against the host config's names and the installed plugins read before it only. ## 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 by [Claude Code](https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a70821 commit 4cd48e2

6 files changed

Lines changed: 499 additions & 27 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli)!: the one-shot secret commands choose the crypto provider from the installed plugins too, by `os serve`'s rule
7+
8+
Clause-②: yes (narrowing) — `os secret rewrap`, `os secret orphans` and the data migrations' settings arm refuse to run when a plugin `os plugin install` installed is refused and nothing rules out that it provides the crypto provider (`crypto-provider`), and when a config plugin and an installed plugin both declare it, where they ran without reading the installed plugins; widened beside it: the optional `leaveUninstallsPending` on `LoadInstalledPluginsInput` (`@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 one-shot run 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 runs `os secret rewrap`, `os secret orphans` or a data migration with an installed plugin (`os plugin install`) that `os serve` would refuse to boot with, or with a crypto provider declared both in the host config's `plugins` and by an installed plugin.
14+
15+
- **Before.** A one-shot run chose the crypto provider from the host config's `plugins` only. The installed plugins of the project were not read.
16+
- **Now.** The installed plugins are read beside the config's `plugins`, through the loader `os serve`'s boot reads them with, and judged by the same rule:
17+
- an installed plugin that loads and declares `crypto-provider` is the plugin the run seals and opens with;
18+
- a refused installed plugin that may provide it refuses the run, naming the plugin, its refusal code and the step `os serve`'s refusal names;
19+
- two declaring plugins, config or installed, are refused, both named;
20+
- an installed plugin whose name a config plugin, or an installed plugin read before it, takes is refused as `plugin_name_taken`, as `os serve` refuses it.
21+
The run registers and removes none of the installed plugins: a pending uninstall is left for the next boot.
22+
- **`@objectstack/runtime`.** `loadInstalledPlugins` takes an optional `leaveUninstallsPending`. When it is set, a pending uninstall is neither finished nor loaded.
23+
- **Who moves, and the fix.** A deployment whose one-shot run now stops runs the step the refusal names (fix the installed plugin, or `os plugin disable <id>`), or keeps one declaration of the crypto provider, then runs it again.

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

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
loadInstalledPlugins,
88
pluginInstallationsFile,
99
type InstalledPluginRefusal,
10+
type InstalledPluginsLoad,
1011
} from '@objectstack/runtime';
1112
import { isExitSignal } from './format.js';
1213
import { pluginEnvironmentId } from './plugin-install.js';
@@ -75,17 +76,23 @@ function withDeclaredServices(declaredServices: string[] | undefined, refusal: I
7576
return declaredServices === undefined ? refusal : { ...refusal, declaredServices };
7677
}
7778

78-
export async function registerInstalledPlugins(input: {
79-
kernel: BootKernel;
80-
track(name: string): void;
81-
}): Promise<InstalledPluginsBoot | null> {
79+
/**
80+
* The installed plugins of this project and environment, verified and imported
81+
* by the runtime's loader with this host's parts (module header), and nothing
82+
* registered: `null` when there is no installations file. A loader that throws
83+
* outright is one `loader_failed` refusal. `os serve` registers what this
84+
* answers ({@link registerInstalledPlugins}); a one-shot command reads it
85+
* without registering, with `leaveUninstallsPending`, so it removes nothing.
86+
*/
87+
export async function loadHostInstalledPlugins(
88+
opts: { leaveUninstallsPending?: boolean } = {},
89+
): Promise<InstalledPluginsLoad | null> {
8290
const environmentId = pluginEnvironmentId();
8391
if (!existsSync(pluginInstallationsFile(environmentId))) return null;
84-
85-
let result: Awaited<ReturnType<typeof loadInstalledPlugins>>;
8692
try {
87-
result = await loadInstalledPlugins({
93+
return await loadInstalledPlugins({
8894
environmentId,
95+
leaveUninstallsPending: opts.leaveUninstallsPending,
8996
resolveKeys: async (keyIds) => {
9097
const read = await readTrustedKeys(keyIds, { databaseUrl: process.env.OS_DATABASE_URL, json: false });
9198
if (!read.ok) throw new Error(read.message);
@@ -99,13 +106,21 @@ export async function registerInstalledPlugins(input: {
99106
});
100107
} catch (error) {
101108
if (isExitSignal(error)) throw error;
102-
result = {
109+
return {
103110
loaded: [],
104111
removed: [],
105112
disabled: [],
106113
refused: [{ id: '*', version: '*', error: 'loader_failed', message: `the installed-plugin loader failed (${thrownMessage(error)}); no installed plugin was loaded.` }],
107114
};
108115
}
116+
}
117+
118+
export async function registerInstalledPlugins(input: {
119+
kernel: BootKernel;
120+
track(name: string): void;
121+
}): Promise<InstalledPluginsBoot | null> {
122+
const result = await loadHostInstalledPlugins();
123+
if (result === null) return null;
109124

110125
const out: InstalledPluginsBoot = { loaded: [], refused: [...result.refused] };
111126
for (const { record, kept } of result.removed) {

0 commit comments

Comments
 (0)