Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/27686-plugin-install-parses-compiled-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/cli': minor
'@objectstack/runtime': minor
---

fix(cli)!: `os plugin install` and the boot-time loader parse an artifact's compiled manifest through `ManifestSchema`, so a stale `.osplugin` (e.g. `packaging: 'manifest-deps'`) is refused with the schema's prescription instead of failing at boot as `import_failed`

Clause-②: yes (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: `ManifestSchema` is consumed as it stands, and what changes is that two more doors hold the compiled manifest inside an artifact to it, as `os plugin build` already holds the source manifest. The refusal is the schema's own message (for `manifest-deps`, the retirement prescription), and the fix is to the source manifest and a rebuild, so there is nothing new for a metadata migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at `os plugin install` (`@objectstack/cli`) and at the boot-time loader for installed plugins (`@objectstack/runtime`'s `loadInstalledPlugins` / `verifyInstalledPlugins`, run by `os serve`, `os dev` and `os start`).

- **Before.** Both doors `JSON.parse`d the artifact's `objectstack.plugin.json` and read only `id` and `version` (and `runtime`, `main`, `integrity`, `permissions` by hand). An artifact built before a schema change installed: one carrying the retired `packaging: 'manifest-deps'` installed, and at boot its externalized imports failed and it was refused as `import_failed`, without the retirement prescription. Any other compiled-manifest drift (a missing `name` or `type`, an unknown key) installed the same way.
- **Now.** One helper, `readCompiledPluginManifest` (a new named export of `@objectstack/runtime`'s root entry, with its result type `CompiledPluginManifestRead`: the one widening in this change), parses the compiled manifest through `ManifestSchema` for both doors.
- `os plugin install` refuses an invalid one before anything is unpacked, as `manifest_invalid`, listing the schema's issues (`--json`: `details`). For `manifest-deps` that is the retirement prescription. Nothing is written under the plugin home and no installation is recorded.
- The boot loader refuses the plugin by name as `manifest_invalid` (a new `InstalledPluginRefusalCode`), with the schema's message, before anything of it is imported. It stays non-fatal: the boot goes on and every other installed plugin loads.
- `os plugin install`'s `manifest_incomplete` and `integrity_malformed` refusals are gone: a missing `id` / `version` and a malformed `integrity` map are schema issues, refused as `manifest_invalid`.
- **Also.** `os plugin build`'s native-addon refusal names, for a bundled package that builds or ships a native addon, the files that import it and the chain that reaches it from the plugin's own source (`src/index.ts → ws → bufferutil`), the way the `.node`-module line already names its importers. A transitive hit now traces to the direct dependency.
- **Who sees it.** Anyone installing, or booting with, an `.osplugin` whose compiled manifest `ManifestSchema` refuses: one built by an `os plugin build` from before a schema change, or one assembled by hand.

**The remedy.** Fix the plugin's source `objectstack.plugin.json` as the refusal says (for `manifest-deps`: delete the `packaging` key; `bundled` is the default and the only strategy) and rebuild the artifact with `os plugin build`, then sign and install it again. An artifact `os plugin build` produced from a valid manifest installs and boots unchanged.
10 changes: 8 additions & 2 deletions docs/qa/platform-checklist/areas/cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,7 +1716,7 @@
"title": "os plugin build enforces the manifest at the packaging boundary: an invalid manifest is refused with located errors, `bundled` is the one packaging strategy (the retired `manifest-deps` is refused at the manifest, a native addon is refused by name), and the compiled manifest the artifact carries is readable back",
"since": "v15",
"status": "active",
"revision": 5,
"revision": 6,
"priority": "P2",
"surface": "cli",
"personas": [
Expand Down Expand Up @@ -1799,7 +1799,7 @@
"packages/cli/src/commands/plugin/build.ts#formatZodErrors — manifest read + named refusal; ManifestSchema.safeParse + formatZodErrors; the explicit id/version guard; packaging default and the Loaded line; entry resolution; the bundled-only esbuild call (only `@objectstack/*` external); findNativeAddons and the native-addon refusal ahead of the integrity map; computeIntegrity + the compiled manifest; the artifact write and its printed block",
"packages/cli/src/utils/osplugin.ts#MANIFEST_FILENAME — MANIFEST_FILENAME = 'objectstack.plugin.json'; OSPLUGIN_EXT; computeIntegrity (manifest and SIGNATURE excluded, keys sorted); the ustar writer with mtime pinned to 0; readOspluginManifest",
"packages/cli/src/commands/plugin/publish.ts#version — artifact discovery; readOspluginManifest before any network call; the id/version refusal; the unsigned-artifact notice; the auth refusal",
"packages/cli/src/commands/plugin/install.ts#PluginInstall — the install-time re-verification the integrity negative points at (verifyIntegrity over the unpacked entries, `integrity_mismatch` / `integrity_malformed`, nothing installed) and the install-time tier warning; not scored here",
"packages/cli/src/commands/plugin/install.ts#PluginInstall — the install-time re-verification the integrity negative points at (verifyIntegrity over the unpacked entries, `integrity_mismatch`; a compiled manifest ManifestSchema refuses, a malformed integrity map included, is `manifest_invalid` one step earlier through readCompiledPluginManifest; nothing installed) and the install-time tier warning; not scored here",
"packages/core/src/security/plugin-artifact-integrity.ts#verifyIntegrity — the one integrity verifier, shared by the publish preflight and the install-time check; not scored here",
"packages/runtime/src/installed-plugin-loader.ts#dispatchPluginRuntime — the boot-time tier dispatch point the trust-tier negative points at (`node` only; `sandbox` / `worker` refused by name; an undeclared tier refused); not scored here",
"packages/spec/src/kernel/manifest.zod.ts#ManifestSchema — ManifestSchema id and version (both required, version regex-pinned); PluginPackagingSchema ('bundled' only — 'manifest-deps' is a retired value refused with its migration prescription, ADR-0025 v2 §3.3); PluginRuntimeSchema (the trust tier, ADR-0025 §3.6)",
Expand Down Expand Up @@ -1838,6 +1838,12 @@
"date": "2026-10-29",
"change": "the integrity and trust-tier negatives re-pointed, no clause changed: both said nothing in this repo verifies or dispatches, and both stopped being true with the install loop — os plugin install re-verifies the integrity map at unpack (install.ts#PluginInstall through verifyIntegrity, slice 1, 10f27b6508) and the boot-time loader dispatches on `runtime` before any import (installed-plugin-loader.ts#dispatchPluginRuntime, slice 2, d4ff1a025f). Each negative now names that code as the check this item does NOT score, in place of the tracker numbers it cited, which do not resolve in this repository; three source rows added for the code they point at. The scored clauses and steps are unchanged. Re-verified against the source at 86f2afeb23, no qa-run record",
"ref": "#27685"
},
{
"revision": 6,
"date": "2026-10-30",
"change": "one source row re-pointed, no clause changed: os plugin install no longer has an `integrity_malformed` refusal. The compiled manifest is parsed through ManifestSchema first (readCompiledPluginManifest, the parse install and the boot loader share), so a malformed integrity map is refused as `manifest_invalid` before the integrity step; `integrity_mismatch` is unchanged. Re-verified against install.ts and compiled-plugin-manifest.ts at this PR's head, no qa-run record",
"ref": "#27686"
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ A code-bearing plugin — a directory carrying an `objectstack.plugin.json` mani
| `os plugin disable <id>` / `os plugin enable <id>` | Keep an installed plugin from loading at the next boot, or load it again |
| `os plugin uninstall <id>` | Mark an installed plugin for removal: the next boot loads nothing of it and removes its directory |

`os plugin install` refuses, by name, an unknown or revoked key, a signature over other bytes, a missing signature and an integrity mismatch. The next `os serve` (or `os dev` / `os start`) re-verifies every installation before importing any of its code — the kept artifact's sha256, its signature against the trusted keys as they are then (a key revoked since install refuses it), and the unpacked tree — and loads it by its declared `runtime` tier: `node` in process (author code in the host process, not isolated); `sandbox` and `worker` are refused by name until ADR-0025 M3 routes them, and a plugin that declares no tier is refused. A refused plugin does not stop the boot; each refusal is one `✗` line naming why. `os plugin` (singular) is unrelated to `os plugins` (plural), oclif's plugin manager, which this package does not ship — see [`os plugins` and `os help`](#os-plugins-and-os-help-not-commands).
`os plugin install` refuses, by name, an unknown or revoked key, a signature over other bytes, a missing signature, a compiled `objectstack.plugin.json` that `ManifestSchema` refuses (`manifest_invalid`, with the schema's own message: for a retired value such as `packaging: 'manifest-deps'`, its prescription) and an integrity mismatch. The next `os serve` (or `os dev` / `os start`) re-verifies every installation before importing any of its code — the kept artifact's sha256, its signature against the trusted keys as they are then (a key revoked since install refuses it), and the unpacked tree — and loads it by its declared `runtime` tier: `node` in process (author code in the host process, not isolated); `sandbox` and `worker` are refused by name until ADR-0025 M3 routes them, and a plugin that declares no tier is refused. A refused plugin does not stop the boot; each refusal is one `✗` line naming why. `os plugin` (singular) is unrelated to `os plugins` (plural), oclif's plugin manager, which this package does not ship — see [`os plugins` and `os help`](#os-plugins-and-os-help-not-commands).

### Quality

Expand Down
48 changes: 46 additions & 2 deletions packages/cli/src/commands/plugin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,50 @@ function nativePackageReason(dir: string): string | undefined {
return shipped ? `ships a compiled addon (${shipped})` : undefined;
}

/**
* Who reaches the package at `dir` (a {@link packageRootOf} directory): the
* files outside it that import a file inside it, and the shortest chain from
* the plugin's own source to it, one step per package — so a transitive hit
* (`ws` → `bufferutil`) names the direct dependency that pulled it in.
*/
function packageImporters(metafile: Metafile, dir: string): { importers: string[]; chain: string[] } {
const inputs = Object.keys(metafile.inputs);
const inside = (input: string): boolean => packageRootOf(input)?.dir === dir;
const importers = inputs.filter(
(i) => !inside(i) && metafile.inputs[i]!.imports.some((imp) => inside(imp.path)),
);
// Breadth-first from the plugin's own files (outside every `node_modules`).
const from = new Map<string, string | null>();
const queue = inputs.filter((i) => packageRootOf(i) === undefined);
for (const start of queue) from.set(start, null);
let hit: string | undefined;
while (queue.length > 0 && hit === undefined) {
const input = queue.shift()!;
for (const imp of metafile.inputs[input]?.imports ?? []) {
if (from.has(imp.path) || !(imp.path in metafile.inputs)) continue;
from.set(imp.path, input);
if (inside(imp.path)) {
hit = imp.path;
break;
}
queue.push(imp.path);
}
}
const chain: string[] = [];
for (let at: string | undefined = hit; at; at = from.get(at) ?? undefined) {
const step = packageRootOf(at)?.name ?? at;
if (chain[0] !== step) chain.unshift(step);
}
return { importers, chain };
}

/**
* Every native addon a build would pack or load (ADR-0025 v2 §3.3), one line
* each: a `.node` module the bundle reaches (named with its importers), a
* bundled package that builds or ships one (its JS may be bundleable, but the
* binary it locates at run time — `bindings`, `node-gyp-build` — is not in the
* artifact), and a `.node` file staged from `assets/`.
* artifact; named with its importers and the chain that reaches it from the
* plugin's own source), and a `.node` file staged from `assets/`.
*/
function findNativeAddons(metafile: Metafile, cwd: string, assets: ArchiveFile[]): string[] {
const found: string[] = [];
Expand All @@ -158,7 +196,13 @@ function findNativeAddons(metafile: Metafile, cwd: string, assets: ArchiveFile[]
}
for (const { name, dir } of packages.values()) {
const reason = nativePackageReason(resolvePath(cwd, dir));
if (reason) found.push(`package \`${name}\` (${dir}) ${reason}`);
if (!reason) continue;
const { importers, chain } = packageImporters(metafile, dir);
found.push(
`package \`${name}\` (${dir}) ${reason}`
+ (importers.length > 0 ? `, imported by ${importers.join(', ')}` : '')
+ (chain.length > 1 ? ` (reached through ${chain.join(' → ')})` : ''),
);
}
for (const asset of assets) {
if (isNativeAddonPath(asset.path)) found.push(asset.path);
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/commands/plugin/install.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,17 @@ const KEY_ID = 'acme-2026';
const text = (t: string) => new TextEncoder().encode(t);

/** A `.osplugin` built as `os plugin build` builds one, its integrity map computed unless given. */
function artifact(name: string, opts: { id: string; version?: string; runtime?: string; files?: ArchiveFile[]; integrity?: Record<string, string>; dir?: string }): string {
function artifact(name: string, opts: { id: string; version?: string; runtime?: string; files?: ArchiveFile[]; integrity?: Record<string, string>; dir?: string; manifest?: Record<string, unknown> }): string {
const files: ArchiveFile[] = opts.files ?? [{ path: 'dist/index.mjs', data: text(`export const id = ${JSON.stringify(opts.id)};\n`) }];
const manifest = {
id: opts.id,
version: opts.version ?? '1.0.0',
name: 'Acme',
type: 'plugin',
runtime: opts.runtime ?? 'node',
packaging: 'bundled',
integrity: opts.integrity ?? computeIntegrity(files),
...opts.manifest,
};
const path = join(opts.dir ?? project, `${name}.osplugin`);
writeFileSync(path, createTarGz([...files, { path: MANIFEST_FILENAME, data: text(JSON.stringify(manifest)) }]));
Expand Down Expand Up @@ -330,6 +332,31 @@ describe('os plugin install (ADR-0025 §3.5 M1)', () => {
expect(existsSync(join(home, 'plugins', 'env_local', 'escape.txt'))).toBe(false);
}, CASE_TIMEOUT_MS);

it("refuses a pre-retirement artifact (packaging: 'manifest-deps') with the schema's prescription, before unpacking anything", async () => {
const path = artifact('retired', { id: 'com.acme.retired', manifest: { packaging: 'manifest-deps' } });
sign(path);
await trustPublisher();
const result = await run(PluginInstall, [path, ...db(), '--json']);
expectRefused(result, 'manifest_invalid', 'com.acme.retired');
// The schema's own words reach the operator: the retired value and its remedy.
const details = (result.payload.details as string[]).join('\n');
expect(details).toMatch(/^packaging: `packaging: 'manifest-deps'` was removed/m);
expect(details).toMatch(/Delete the key/);
// Nothing of it was written under the plugin home.
expect(existsSync(join(home, 'plugins', 'env_local', 'com.acme.retired'))).toBe(false);
}, CASE_TIMEOUT_MS);

it('refuses a compiled manifest ManifestSchema refuses, by name, naming each issue', async () => {
const path = artifact('drifted', { id: 'com.acme.drifted', manifest: { name: undefined, integrity: ['not', 'a', 'map'] } });
sign(path);
await trustPublisher();
const result = await run(PluginInstall, [path, ...db(), '--json']);
expectRefused(result, 'manifest_invalid', 'com.acme.drifted');
const details = (result.payload.details as string[]).join('\n');
expect(details).toMatch(/^name: /m);
expect(details).toMatch(/^integrity: /m);
}, CASE_TIMEOUT_MS);

it('refuses an unsigned artifact, and installs it only under --trust-unsigned, saying so on stderr', async () => {
const path = artifact('unsigned', { id: 'com.acme.unsigned' });
expectRefused(await run(PluginInstall, [path, ...db(), '--json']), 'signature_missing', 'com.acme.unsigned');
Expand Down
Loading
Loading