diff --git a/.changeset/27686-plugin-install-parses-compiled-manifest.md b/.changeset/27686-plugin-install-parses-compiled-manifest.md new file mode 100644 index 00000000000..2cd327ad1ca --- /dev/null +++ b/.changeset/27686-plugin-install-parses-compiled-manifest.md @@ -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 + + + +**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. diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index e8afa6ee9a7..326e2a3eacd 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -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": [ @@ -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)", @@ -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" } ] }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 5d150e15a94..0be368fe949 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -155,7 +155,7 @@ A code-bearing plugin — a directory carrying an `objectstack.plugin.json` mani | `os plugin disable ` / `os plugin enable ` | Keep an installed plugin from loading at the next boot, or load it again | | `os plugin uninstall ` | 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 diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index f5cb8ec5cca..8550569bd8f 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -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(); + 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[] = []; @@ -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); diff --git a/packages/cli/src/commands/plugin/install.integration.test.ts b/packages/cli/src/commands/plugin/install.integration.test.ts index 5230c560970..1d4882e0749 100644 --- a/packages/cli/src/commands/plugin/install.integration.test.ts +++ b/packages/cli/src/commands/plugin/install.integration.test.ts @@ -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; dir?: string }): string { +function artifact(name: string, opts: { id: string; version?: string; runtime?: string; files?: ArchiveFile[]; integrity?: Record; dir?: string; manifest?: Record }): 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)) }])); @@ -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'); diff --git a/packages/cli/src/commands/plugin/install.ts b/packages/cli/src/commands/plugin/install.ts index dfe129cff76..a7cdb95efbf 100644 --- a/packages/cli/src/commands/plugin/install.ts +++ b/packages/cli/src/commands/plugin/install.ts @@ -17,7 +17,11 @@ * signature that does not match the bytes. * 2. Read the archive (the inflate bounded, `MAX_UNPACKED_ARTIFACT_BYTES`) * and its compiled `objectstack.plugin.json`; refuse an entry that could - * land outside the install directory. + * land outside the install directory, and a manifest `ManifestSchema` + * refuses (`readCompiledPluginManifest`, the read the boot-time loader + * makes too), with the schema's own message: an artifact built before a + * value was retired (`packaging: 'manifest-deps'`) gets that value's + * prescription here, before anything is unpacked. * 3. Re-verify every file against the manifest's `integrity` map; refuse a * digest mismatch, a declared file that is missing, or a file the map * does not declare. @@ -64,6 +68,7 @@ import { loadPluginInstallations, pluginInstallDir, dispatchPluginRuntime, + readCompiledPluginManifest, recordPluginInstallation, withPluginInstallationsLock, PluginInstallationsLockedError, @@ -291,23 +296,26 @@ export default class PluginInstall extends Command { }); return; } - let manifest: Record; - try { - const entry = files.find((f) => f.path === MANIFEST_FILENAME); - if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in the artifact`); - const parsed = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${MANIFEST_FILENAME} is not a JSON object`); - manifest = parsed as Record; - } catch (error) { - await refuse({ error: 'manifest_unreadable', message: `Cannot read the artifact's manifest: ${(error as Error).message}` }); - return; - } - const id = typeof manifest.id === 'string' ? manifest.id.trim() : ''; - const version = typeof manifest.version === 'string' ? manifest.version.trim() : ''; - if (!id || !version) { - await refuse({ error: 'manifest_incomplete', message: 'The artifact manifest is missing `id` or `version`. Rebuild it with `os plugin build`.' }); + // Through `ManifestSchema`, the read the boot-time loader makes too: an + // artifact built before a schema change is refused here with the + // schema's own words (a retired value's prescription), not at boot. + const read = readCompiledPluginManifest(files); + if (!read.ok) { + await refuse( + read.reason === 'unreadable' + ? { error: 'manifest_unreadable', message: `Cannot read the artifact's manifest: ${read.message}` } + : { + error: 'manifest_invalid', + message: + `The artifact's ${MANIFEST_FILENAME} is not a valid plugin manifest, so nothing was installed. ` + + 'Fix the source manifest and rebuild the artifact with `os plugin build`:', + details: read.issues, + }, + ); return; } + const manifest = read.manifest; + const { id, version } = manifest; const environmentId = pluginEnvironmentId(); let installDir: string; try { @@ -324,19 +332,9 @@ export default class PluginInstall extends Command { ); } - // 3. Per-file integrity, re-verified at unpack (ADR-0025 §3.2). ───── - const declaredIntegrity = manifest.integrity; - if ( - declaredIntegrity !== undefined && declaredIntegrity !== null - && (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity)) - ) { - await refuse({ - error: 'integrity_malformed', - message: 'The artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild it with `os plugin build`.', - }); - return; - } - const integrity = verifyIntegrity(files, declaredIntegrity as Record | undefined, { + // 3. Per-file integrity, re-verified at unpack (ADR-0025 §3.2); the + // map's shape is `ManifestSchema`'s, checked above. ────────────── + const integrity = verifyIntegrity(files, manifest.integrity, { exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME], }); if (!integrity.ok) { @@ -394,9 +392,9 @@ export default class PluginInstall extends Command { id, version, versionId: `${id}@${version}`, - runtime: typeof manifest.runtime === 'string' ? manifest.runtime : null, + runtime: manifest.runtime ?? null, status: 'installed', - grantedPermissions: permissions && typeof permissions === 'object' && !Array.isArray(permissions) ? permissions : null, + grantedPermissions: permissions ?? null, dir: installDir, artifact: { file: INSTALLED_ARTIFACT_FILE, sha256: artifactSha }, signatureFile: signature === null ? null : INSTALLED_SIGNATURE_FILE, diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index 2c64720dd66..9e8f819ca5d 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -553,6 +553,7 @@ function writePluginFixture(): void { const manifest = { id: 'com.example.osf-plugin', version: '1.0.0', + name: 'One-shot fixture', type: 'plugin', runtime: 'node', packaging: 'bundled', diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index 60a3d4db420..d591fbd770a 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -197,6 +197,7 @@ const PREPARE: Record) => Prom const manifest = { id: 'com.purity.e2e', version: '1.0.0', + name: 'Purity', type: 'plugin', runtime: 'node', packaging: 'bundled', diff --git a/packages/cli/test/plugin-build-bundled-only.test.ts b/packages/cli/test/plugin-build-bundled-only.test.ts index 2b521ab1d8d..4f70849cf9d 100644 --- a/packages/cli/test/plugin-build-bundled-only.test.ts +++ b/packages/cli/test/plugin-build-bundled-only.test.ts @@ -186,6 +186,31 @@ describe('os plugin build — a native addon is refused (ADR-0025 v2 §3.3)', () }); } + it('names the importer chain of a transitive native package, so it traces to the direct dependency', async () => { + const dir = await project({}, { + 'node_modules/fake-native/package.json': JSON.stringify({ name: 'fake-native', version: '1.0.0', main: 'index.js' }), + 'node_modules/fake-native/binding.gyp': `{ 'targets': [] }\n`, + 'node_modules/fake-native/index.js': `module.exports = require('./build/Release/fake.js');\n`, + 'node_modules/fake-native/build/Release/fake.js': `module.exports = 'NATIVE';\n`, + 'node_modules/ws-like/package.json': JSON.stringify({ name: 'ws-like', version: '1.0.0', main: 'index.js' }), + 'node_modules/ws-like/index.js': `module.exports = require('./lib/buffer-util.js');\n`, + 'node_modules/ws-like/lib/buffer-util.js': `module.exports = require('fake-native');\n`, + 'src/index.ts': `import ws from 'ws-like';\nexport const value = ws;\n`, + }); + + const { exit, printed } = await build(dir); + + expect(exit).toBe(1); + expect(refusals(printed)).toMatch(/native addon/i); + expect(printed).toContain('package `fake-native` (node_modules/fake-native) declares a node-gyp build (binding.gyp)'); + // The file that imports it, and the chain from the plugin's own source. + expect(printed).toContain('imported by node_modules/ws-like/lib/buffer-util.js'); + expect(printed).toContain('reached through src/index.ts → ws-like → fake-native'); + // `ws-like` is pure JavaScript: named on the chain, never as a native package. + expect(printed).not.toContain('package `ws-like`'); + expect(existsSync(join(dir, ARTIFACT))).toBe(false); + }); + it('refuses a `.node` file under `assets/`, naming it', async () => { const dir = await project({}, { 'assets/icon.svg': '', diff --git a/packages/cli/test/serve-installed-plugins.integration.test.ts b/packages/cli/test/serve-installed-plugins.integration.test.ts index 46db45b525d..4b7b79d5c6f 100644 --- a/packages/cli/test/serve-installed-plugins.integration.test.ts +++ b/packages/cli/test/serve-installed-plugins.integration.test.ts @@ -39,9 +39,10 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { generateEd25519KeyPair, signPayload } from '@objectstack/core'; -import { loadPluginInstallations, pluginInstallDir } from '@objectstack/runtime'; +import { loadPluginInstallations, pluginInstallDir, recordPluginInstallation } from '@objectstack/runtime'; import { isExitSignal } from '../src/utils/format.js'; -import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from '../src/utils/osplugin.js'; +import { MANIFEST_FILENAME, computeIntegrity, createTarGz, readTarGz, sha256Hex, type ArchiveFile } from '../src/utils/osplugin.js'; +import { materializePlugin } from '../src/utils/plugin-install.js'; import PluginBuild from '../src/commands/plugin/build.js'; import PluginInstall from '../src/commands/plugin/install.js'; import PluginDisable from '../src/commands/plugin/disable.js'; @@ -121,7 +122,7 @@ const savedCwd = process.cwd(); const marker = (id: string) => join(home, `init-${id}`); /** A signed `.osplugin` whose `init` writes {@link marker}. */ -function signedArtifact(id: string, runtime = 'node'): string { +function signedArtifact(id: string, runtime = 'node', extra: Record = {}): string { const files: ArchiveFile[] = [{ path: 'dist/index.mjs', data: text( @@ -130,7 +131,7 @@ function signedArtifact(id: string, runtime = 'node'): string { + `init() { writeFileSync(${JSON.stringify(marker(id))}, 'ran'); } };\n`, ), }]; - const manifest = { id, version: '1.0.0', type: 'plugin', runtime, packaging: 'bundled', integrity: computeIntegrity(files) }; + const manifest = { id, version: '1.0.0', name: 'Probe', type: 'plugin', runtime, packaging: 'bundled', integrity: computeIntegrity(files), ...extra }; const path = join(root, `${id}.osplugin`); writeFileSync(path, createTarGz([...files, { path: MANIFEST_FILENAME, data: text(JSON.stringify(manifest)) }])); writeFileSync(`${path}.sig`, signPayload(new Uint8Array(readFileSync(path)), publisher.privateKeyPem, KEY_ID)); @@ -206,7 +207,8 @@ function boot(): Promise { }); } -const IDS = ['com.acme.loads', 'com.acme.sandboxed', 'com.acme.tampered', 'com.acme.disabled', 'com.acme.removed'] as const; +const RETIRED = 'com.acme.retired'; +const IDS = ['com.acme.loads', 'com.acme.sandboxed', 'com.acme.tampered', 'com.acme.disabled', 'com.acme.removed', RETIRED] as const; let first: Boot; let second: Boot; @@ -250,6 +252,23 @@ beforeAll(async () => { const builtInstall = await run(PluginInstall, [await builtArtifact(), ...db, '--json']); expect(builtInstall.exitCode, builtInstall.stdout.slice(-400)).toBe(0); writeFileSync(join(pluginInstallDir('env_local', 'com.acme.tampered', '1.0.0'), 'dist', 'index.mjs'), 'export default { name: "evil", init() {} };\n'); + // A pre-retirement install: what an `os plugin install` that read only the + // manifest's `id`/`version` laid down from an artifact this one refuses + // (`packaging: 'manifest-deps'`), signed by the trusted key. + { + const path = signedArtifact(RETIRED, 'node', { packaging: 'manifest-deps' }); + const bytes = new Uint8Array(readFileSync(path)); + const dir = pluginInstallDir('env_local', RETIRED, '1.0.0'); + materializePlugin({ dir, files: readTarGz(bytes), artifact: bytes, signature: readFileSync(`${path}.sig`, 'utf8') }); + const template = loadPluginInstallations('env_local').find((r) => r.id === 'com.acme.loads')!; + recordPluginInstallation('env_local', { + ...template, + id: RETIRED, + versionId: `${RETIRED}@1.0.0`, + dir, + artifact: { ...template.artifact, sha256: sha256Hex(bytes) }, + }); + } expect((await run(PluginDisable, ['com.acme.disabled', '--json'])).exitCode).toBe(0); expect((await run(PluginUninstall, ['com.acme.removed', '--json'])).exitCode).toBe(0); @@ -295,6 +314,15 @@ describe('[#27123] os serve loads installed plugins, verified, by tier', () => { expect(ranAfterFirst).not.toContain('com.acme.tampered'); }); + it("refuses a pre-retirement artifact (packaging: 'manifest-deps') with the schema's prescription, not import_failed, and boots on", () => { + expect(first.output).toMatch( + /✗ Refused installed plugin com\.acme\.retired@1\.0\.0 \(manifest_invalid\): .*packaging: `packaging: 'manifest-deps'` was removed/, + ); + expect(first.output).not.toMatch(/com\.acme\.retired@1\.0\.0 \(import_failed\)/); + expect(ranAfterFirst).not.toContain(RETIRED); + expect(ranInSecond).not.toContain(RETIRED); + }); + it('skips the disabled plugin, saying so, until it is enabled', () => { expect(first.output).toMatch(/Installed plugin com\.acme\.disabled@1\.0\.0 is disabled/); expect(ranAfterFirst).not.toContain('com.acme.disabled'); @@ -313,6 +341,7 @@ describe('[#27123] os serve loads installed plugins, verified, by tier', () => { expect(loadPluginInstallations('env_local').map((r) => r.id).sort()).toEqual([ BUILT, 'com.acme.disabled', + RETIRED, 'com.acme.sandboxed', 'com.acme.tampered', ]); diff --git a/packages/runtime/src/compiled-plugin-manifest.ts b/packages/runtime/src/compiled-plugin-manifest.ts new file mode 100644 index 00000000000..5a2eb28a723 --- /dev/null +++ b/packages/runtime/src/compiled-plugin-manifest.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE read of the compiled manifest an `.osplugin` artifact carries + * (ADR-0025 §3.2), shared by both doors that read it after the build: + * `os plugin install` (before anything is unpacked) and the boot-time loader + * (`installed-plugin-loader.ts`, check 4, before anything is imported). + * + * The manifest is parsed through `ManifestSchema`, the contract `os plugin + * build` already holds the source manifest to, so an artifact built before a + * schema change (a retired value such as `packaging: 'manifest-deps'`) or one + * whose compiled manifest drifted is refused with the schema's own words: the + * prescription a retired value carries reaches the operator instead of a + * failure further down the line (`import_failed`). ⛔ Neither door keeps a + * second copy of any manifest rule: both ask this function and map its answer + * to their own refusal channel. + */ + +import { formatZodIssue } from '@objectstack/spec'; +import { ManifestSchema, type ObjectStackManifestParsed } from '@objectstack/spec/kernel'; + +/** The manifest at an artifact's root. */ +export const COMPILED_PLUGIN_MANIFEST_FILE = 'objectstack.plugin.json'; + +/** An artifact entry, as `readTarGz` answers it. */ +export interface CompiledManifestEntry { + path: string; + data: Uint8Array; +} + +export type CompiledPluginManifestRead = + | { ok: true; manifest: ObjectStackManifestParsed } + /** `unreadable`: no manifest, or not a JSON object. `invalid`: `ManifestSchema` refused it. */ + | { ok: false; reason: 'unreadable' | 'invalid'; message: string; issues: string[] }; + +/** + * Read and parse the compiled manifest among an artifact's `files`. On a + * refusal, `issues` holds one `path: message` line per schema issue (empty + * for `unreadable`) and `message` says the whole of it on one line. + */ +export function readCompiledPluginManifest(files: readonly CompiledManifestEntry[]): CompiledPluginManifestRead { + let raw: unknown; + try { + const entry = files.find((f) => f.path === COMPILED_PLUGIN_MANIFEST_FILE); + if (!entry) throw new Error(`${COMPILED_PLUGIN_MANIFEST_FILE} not found`); + raw = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${COMPILED_PLUGIN_MANIFEST_FILE} is not a JSON object`); + } catch (error) { + return { ok: false, reason: 'unreadable', message: error instanceof Error ? error.message : String(error), issues: [] }; + } + const parsed = ManifestSchema.safeParse(raw); + if (parsed.success) return { ok: true, manifest: parsed.data }; + const issues = parsed.error.issues.flatMap((issue) => + formatZodIssue(issue as Parameters[0]) + .split('\n') + .map((line) => line.trim().replace(/^✗\s*/, '')), + ); + return { + ok: false, + reason: 'invalid', + message: `${COMPILED_PLUGIN_MANIFEST_FILE} is not a valid plugin manifest: ${issues.join('; ')}`, + issues, + }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b9da74e2249..b20d09ae75a 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -70,6 +70,10 @@ export { verifyInstalledPlugins, loadInstalledPlugins, } from './installed-plugin-loader.js'; +// The one read of an artifact's compiled manifest, through `ManifestSchema`: +// `os plugin install` and the boot-time loader both ask it. +export { readCompiledPluginManifest } from './compiled-plugin-manifest.js'; +export type { CompiledPluginManifestRead } from './compiled-plugin-manifest.js'; export type { InstalledPluginRefusalCode, InstalledPluginRefusal, diff --git a/packages/runtime/src/installed-plugin-loader.test.ts b/packages/runtime/src/installed-plugin-loader.test.ts index 963e2fe80dc..cf419a5d50b 100644 --- a/packages/runtime/src/installed-plugin-loader.test.ts +++ b/packages/runtime/src/installed-plugin-loader.test.ts @@ -163,7 +163,7 @@ interface InstallOptions { function install(o: InstallOptions = {}): PluginInstallationRecord & { artifactBytes: Uint8Array } { const id = o.id ?? 'com.acme.hello'; const version = o.version ?? '1.0.0'; - const manifest = { id, version, name: 'Hello', runtime: 'node', ...o.manifest }; + const manifest = { id, version, name: 'Hello', type: 'plugin', runtime: 'node', ...o.manifest }; const entry = o.entry ?? `export default { name: ${JSON.stringify(id)}, version: '1.0.0', init() { globalThis.__initCount = (globalThis.__initCount ?? 0) + 1; } };\n`; const files: File[] = [ { path: 'objectstack.plugin.json', data: text(JSON.stringify(manifest)) }, @@ -366,6 +366,12 @@ describe('every change to an install refuses that plugin by name and imports not ['the kept artifact is another version than the record', 'manifest_mismatch', () => { install({ manifest: { version: '2.0.0' } }); }], + ['the manifest carries the retired packaging: manifest-deps', 'manifest_invalid', () => { + install({ manifest: { packaging: 'manifest-deps' } }); + }], + ['the manifest is not a ManifestSchema manifest (no type)', 'manifest_invalid', () => { + install({ manifest: { type: undefined } }); + }], ['the kept artifact is not an archive', 'artifact_unreadable', () => { const r = install({ signed: false }); writeFileSync(join(r.dir, INSTALLED_ARTIFACT_FILE), 'not gzip'); @@ -407,6 +413,20 @@ describe('every change to an install refuses that plugin by name and imports not expect(out.refused).toEqual([expect.objectContaining({ id: '*', error: 'records_unreadable' })]); }); + it("a pre-retirement artifact (packaging: 'manifest-deps') is refused with the schema's prescription, never imported; the next plugin still loads", async () => { + install({ id: 'com.acme.retired', manifest: { packaging: 'manifest-deps' } }); + install({ id: 'com.acme.fine' }); + const out = await load(); + expect(out.refused).toEqual([expect.objectContaining({ id: 'com.acme.retired', error: 'manifest_invalid' })]); + // The schema's own words: the retired value, and the remedy it prescribes. + expect(out.refused[0].message).toMatch(/packaging: `packaging: 'manifest-deps'` was removed/); + expect(out.refused[0].message).toMatch(/Delete the key/); + // Only the valid plugin's entry module was imported. + expect(out.imported).toHaveLength(1); + expect(out.imported[0]).toContain('com.acme.fine'); + expect(out.loaded.map((l) => l.record.id)).toEqual(['com.acme.fine']); + }); + it('refuses one plugin and still loads the next', async () => { install({ id: 'com.acme.sandboxed', manifest: { runtime: 'sandbox' } }); install({ id: 'com.acme.fine' }); diff --git a/packages/runtime/src/installed-plugin-loader.ts b/packages/runtime/src/installed-plugin-loader.ts index d5c7f504849..d7f1854f670 100644 --- a/packages/runtime/src/installed-plugin-loader.ts +++ b/packages/runtime/src/installed-plugin-loader.ts @@ -33,7 +33,11 @@ * 4. **The tree is the artifact.** The artifact is read back (bounded, * `readTarGz`) and every file in the install directory must be byte-equal * to its entry, with no file the artifact does not hold beside the two the - * install keeps. Its manifest's `id` and `version` are the record's. + * install keeps. Its manifest parses through `ManifestSchema` + * (`readCompiledPluginManifest`, the read `os plugin install` makes too), + * so an artifact built before a schema change is refused with the + * schema's own message (`manifest_invalid`) and never imported; its `id` + * and `version` are the record's. * 5. **The tier dispatch point** ({@link dispatchPluginRuntime}). M1 wires * `runtime: 'node'` only: the entry module is imported in process. * `sandbox` and `worker` are refused by name until M3 routes them, and a @@ -98,9 +102,8 @@ import { type PluginInstallationRecord, } from './plugin-installation-store.js'; import type { TrustedKeyResolution } from './plugin-trusted-keys.js'; +import { readCompiledPluginManifest } from './compiled-plugin-manifest.js'; -/** The manifest an artifact carries at its root. */ -const MANIFEST_FILE = 'objectstack.plugin.json'; /** The entry `os plugin build` compiles to, when a manifest names none. */ const DEFAULT_ENTRY = 'dist/index.mjs'; @@ -125,6 +128,7 @@ export type InstalledPluginRefusalCode = | 'signature_invalid' | 'unsigned_without_override' | 'artifact_unreadable' + | 'manifest_invalid' | 'manifest_mismatch' | 'tree_changed' | 'runtime_undeclared' @@ -356,16 +360,13 @@ async function checkRecord( } const unsafe = files.find((f) => !safeEntryPath(f.path)); if (unsafe) return refuse('artifact_unreadable', `its kept artifact holds the entry ${JSON.stringify(unsafe.path)}, which cannot sit in an install directory.`); - let manifest: Record; - try { - const entry = files.find((f) => f.path === MANIFEST_FILE); - if (!entry) throw new Error(`${MANIFEST_FILE} not found`); - const parsed = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${MANIFEST_FILE} is not a JSON object`); - manifest = parsed as Record; - } catch (error) { - return refuse('artifact_unreadable', `its kept artifact's manifest cannot be read: ${thrownMessage(error)}.`); + const read = readCompiledPluginManifest(files); + if (!read.ok) { + return read.reason === 'unreadable' + ? refuse('artifact_unreadable', `its kept artifact's manifest cannot be read: ${read.message}.`) + : refuse('manifest_invalid', `its kept artifact's ${read.message}`); } + const manifest = read.manifest; if (manifest.id !== record.id || manifest.version !== record.version) { return refuse( 'manifest_mismatch', diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index bb75bcdd4d9..5668dd9bd3d 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -304,7 +304,7 @@ }, "integrity": { "status": "live", - "evidence": "packages/cli/src/commands/plugin/install.ts#PluginInstall (re-verification at unpack — `verifyIntegrity(files, manifest.integrity, …)` over the archive's entries before anything is written; a digest mismatch, a declared file that is missing or a packaged file the map does not name ⇒ `integrity_mismatch`, a map that is not an object ⇒ `integrity_malformed`, exit 1, nothing installed); packages/cli/src/commands/plugin/publish.ts#PluginPublish (the publish preflight self-check against the manifest's own declared digests, refusing the publish on the same three violations)", + "evidence": "packages/cli/src/commands/plugin/install.ts#PluginInstall (re-verification at unpack — `verifyIntegrity(files, manifest.integrity, …)` over the archive's entries before anything is written; a digest mismatch, a declared file that is missing or a packaged file the map does not name ⇒ `integrity_mismatch`, a map that is not a path → digest record is refused one step earlier as a `ManifestSchema` issue, `manifest_invalid`, by readCompiledPluginManifest, the parse both install and the boot loader share; exit 1, nothing installed); packages/cli/src/commands/plugin/publish.ts#PluginPublish (the publish preflight self-check against the manifest's own declared digests, refusing the publish on the same three violations)", "verifiedAt": "2026-10-28", "evidenceScope": "in-repo", "note": "Per-file content digests of the packaged artifact, computed at build (packages/cli/src/commands/plugin/build.ts#PluginBuild via computeIntegrity). 2026-10-28 (#27123): FLIPPED dead → live. The row had stood `dead` per the #13464 mandate as the enforce-or-remove worklist entry for the RULED leg, re-verification at install-time unpack (ADR-0025 §3.5 step 5), and named the flip as belonging to whatever built that leg; `os plugin install` is it, re-hashing every unpacked entry against the map and refusing the install by name before it writes anything. Both readers live in packages/core/src/security/plugin-artifact-integrity.ts#verifyIntegrity. An absent map is still a permissive pass (the field is `.optional()`). Not yet built, and not this key's reader: the boot-time loader (ADR-0025 §3.5 M1's next slice), which is to re-verify the kept original artifact against its detached signature — a different mechanism on a different field (packages/core/src/security/plugin-artifact-signature.ts)." diff --git a/packages/spec/src/kernel/manifest-packaging-retirement.test.ts b/packages/spec/src/kernel/manifest-packaging-retirement.test.ts index 801962af552..c196e36af2c 100644 --- a/packages/spec/src/kernel/manifest-packaging-retirement.test.ts +++ b/packages/spec/src/kernel/manifest-packaging-retirement.test.ts @@ -253,6 +253,12 @@ describe('tree-scoped absence: nothing inside the declared radius still authors 'packages/spec/src/shared/retired-key-removal-version.pin.test.ts', // The build door's own pin authors it to assert `os plugin build` refuses it. 'packages/cli/test/plugin-build-bundled-only.test.ts', + // The install door's own pin authors it to assert `os plugin install` refuses it before unpacking. + 'packages/cli/src/commands/plugin/install.integration.test.ts', + // The boot loader's own pin authors it to assert a pre-retirement install is refused by name. + 'packages/runtime/src/installed-plugin-loader.test.ts', + // The `os serve` boot pin authors it to assert a pre-retirement install is refused at boot, not imported. + 'packages/cli/test/serve-installed-plugins.integration.test.ts', // The D3 entry's `surface` names the value it retires. 'packages/spec/src/migrations/entries/semantic/18.manifest-packaging-manifest-deps-retired.ts', ]);