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
37 changes: 37 additions & 0 deletions .changeset/27123-boot-time-plugin-loader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/cli': patch
'@objectstack/runtime': patch
'@objectstack/core': patch
---

feat(cli, runtime, core): `os serve` loads the plugins `os plugin install` installed, re-verifying each before any of its code is imported, and dispatches on the declared `runtime` tier; `os plugin disable | enable | uninstall` (#27123)

Clause-②: no (narrowing) — `@objectstack/core`'s `PluginLoader` (so every `kernel.use`) refuses a plugin object whose `signature` no host recorded a verdict for, where any well-formed signature passed. Nothing of `@objectstack/spec` changes; the rest (the boot loader, the commands, the new exports) widens
Metadata-protocol: none

<!-- adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader) The one narrowing is a runtime check of a published runtime class: `PluginLoader` refuses a plugin object's `signature` that no host recorded a verdict for. No Zod schema, no spec declaration and no stored representation carries that field or the verdict, so there is nothing for the metadata migration to rewrite and no tombstone; what an out-of-repo host changes is its own code, as the Migration paragraph says. -->

**BREAKING** accept-set narrowing at `kernel.use` (`@objectstack/core`): a plugin object carrying a `signature` is refused unless the host that verified its artifact recorded that verdict against the object.

The second slice of ADR-0025's local install loop (§3.5 M1). Before this, an installed plugin was unpacked and recorded, and nothing loaded it.

**Migration (`@objectstack/core`):** a host outside this repo that passes `kernel.use` a plugin object carrying a `signature` now has that plugin refused (`Plugin X carries a signature … that no host verified against its artifact`), where any well-formed signature used to pass. FROM: setting `plugin.signature` and calling `kernel.use(plugin)`. TO: verify the artifact yourself, then call `recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 })` from `@objectstack/core` before `kernel.use`; or leave `signature` unset. No plugin in this repo sets it.

- **The boot-time loader** (`@objectstack/runtime`: `loadInstalledPlugins`, `verifyInstalledPlugins`). `os serve` (and `os dev` / `os start` through it) calls it after the config's own `plugins` are registered and before the kernel bootstraps, then `kernel.use`s each plugin that passed, so dependency order and health checks stay the kernel's. For each installation of this project and environment, in this order, the first failing check refuses that plugin by name, before anything of it is imported:
- the record names the directory the store derives for its id and version (`install_dir_unexpected`); nothing is read from or removed under any other;
- a disabled id is skipped, before anything of it is read. A disable-state file that is there and cannot be read refuses each plugin instead (`disabled_state_unreadable`): an unreadable disable is not taken for none;
- the kept `artifact.osplugin` hashes to the recorded sha256 (`artifact_changed`, `install_dir_missing`); a kept file larger than any artifact an install accepts is refused without being read;
- its detached signature, over the raw kept bytes and before anything is inflated, names the recorded key id, the key is `active` in `sys_trusted_key` with the fingerprint the install verified, and it verifies (`signature_missing`, `signature_malformed`, `signature_key_mismatch`, `signature_key_untrusted`, `signature_key_revoked`, `trusted_key_invalid`, `trusted_key_changed`, `signature_invalid`, `trusted_keys_unreadable`). The signature covers the bytes only; what ties them to the record's id and version is the manifest comparison below. So a key revoked since install refuses the plugin at its next boot. An install admitted under `--trust-unsigned` loads with a warning on every boot;
- the unpacked tree is byte-for-byte the artifact, with no other file and no symbolic link, and the artifact's manifest is the recorded id and version (`tree_changed`, `manifest_mismatch`, `artifact_unreadable`);
- **the tier dispatch point** (`dispatchPluginRuntime`, ADR-0025 §3.6): `runtime: 'node'` imports the entry module in process; `sandbox` and `worker` are refused by name until M3 routes them (`runtime_not_dispatched`), with no in-process fallback; a manifest that declares no tier is refused (`runtime_undeclared`, default-deny);
- the entry module is a file of the artifact (`entry_missing`).

A check that throws on one installation (a file it cannot read, one that vanished mid-walk) refuses that plugin by name too (`install_unreadable`): one installation never fails the boot. What passed is then imported by the rule a stack's `plugins` entry takes (a plain bundle is wrapped in `AppPlugin`). The refusals from here on come after the entry module's top-level code ran, so the plugin is not registered but its module was evaluated: the import throws (`import_failed`), it exports no plugin or reading its export throws (`not_a_plugin`), it declares a signature other than the verified one (`signature_conflict`), and the host's own `plugin_name_taken` / `kernel_refused`.

A bare `@objectstack/*` import inside a verified install directory resolves to the packages the host runs on (a resolve hook, `module.register`): `os plugin build` leaves them external, and an install directory under `OS_HOME` has no `node_modules` holding them. Every other bare specifier resolves as Node resolves it from the install directory, and a file the plugin imports lazily is read when it is imported, after the check.

The environment's trusted keys are read once per boot, through the same read-only one-shot boot `os plugin install` reads them through, and only when a signed install needs them; with no installations file nothing is read at all. A refused plugin does not stop the boot: it is printed as one `✗` line naming the refusal, and the banner counts the refusals; a loader that throws outright is one `loader_failed` line, and the boot goes on without installed plugins. An installed plugin never replaces a plugin the boot already registered under the same name (`plugin_name_taken`); a host plugin registered later under the same name replaces the installed one, with the kernel's warning.
- **The kernel's signature check is real.** `PluginLoader` used to pass any well-formed `signature` a plugin carried. The host that verified the artifact now records its verdict against the plugin object (`recordPluginArtifactVerdict`, `@objectstack/core`), and the loader refuses a plugin whose signature no host verified. This stops a `signature` field from reading as verified; it is not a boundary against code already running in the process, which can call the public `recordPluginArtifactVerdict` itself.
- **`os plugin install`** verifies the signature over the raw bytes before it inflates the archive, refuses an artifact file larger than any artifact may unpack to before reading it, and bounds the inflate (`MAX_UNPACKED_ARTIFACT_BYTES`, 256 MiB; the reader moved to `@objectstack/core` as `readTarGz`, re-exported where the CLI read it). The reader takes regular files and directories only, joins a POSIX `prefix` onto the name, refuses a link, device or extension header and a cut-short entry by name, and copies neither its input nor its files. **`os plugin publish`** reads an artifact through the same reader, so its preflight, beside `install`'s, now refuses an artifact that inflates past 256 MiB or carries a link, device, extension-header or cut-short entry, where the old reader inflated without bound and read every entry as a file; `os plugin build` writes regular files only. The checks, the write and the record now run under the installations lock. An artifact whose tier the boot does not dispatch installs with a warning naming the refusal, and `--json` carries `boot: { loads, error }`.
- **`os plugin disable ID` / `os plugin enable ID`** keep an installed plugin from loading, or load it again, at the next boot (the package disable-state store, keyed by the plugin environment id). On a disable-state file they cannot read they refuse (`disable_state_failed`) and write nothing. **`os plugin uninstall ID`** marks the installation; the next boot loads nothing of it and, under the installations lock and only while the record is still the marked one (`record_changed` otherwise: an id installed again since the boot read the records keeps its fresh install), removes its directory unless another project sharing the home and environment still records it, drops the record and forgets its disable. A removal that fails is `uninstall_failed`, and a record still marked is tried again at the next boot. Each command refuses an id this project has not installed by name (`plugin_not_installed`, also when it was removed while the command ran); `--json` on all three.
- **The installations file takes a lock** for every read-modify-write: `<file>.lock`, created exclusively with the writer's pid, thread and a fresh token. A writer waits up to 10 s for a live holder, then refuses naming it (`PluginInstallationsLockedError`, which the commands report as `installations_locked`); the commands report any other failure under their own code. A lock a crashed process left is cleared by the next writer: its pid is not a live process, or it is older than 5 minutes, or it names the writer's own pid and thread while the writer holds nothing (another thread of the same process is a live holder), or it cannot be parsed and is older than a second. Clearing holds `<file>.lock.steal` and removes the lock only while it is still the file judged stale, so two writers that judge the same lock stale cannot both take it; a writer releases its lock only while the file is still its own.
10 changes: 10 additions & 0 deletions .changeset/27123-runtime-tier-enforced-at-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': patch
---

docs(spec): the plugin manifest's `runtime` tier is described as enforced at boot for a plugin `os plugin install` installed — only `node` is loaded, `sandbox` and `worker` are refused until ADR-0025 M3 routes them (#27123)

Clause-②: no — no key or accepted value changes; `node`, `sandbox` and `worker` still parse, and an unset tier is still legal
Metadata-protocol: none

The `.describe()` text and TSDoc of `PluginRuntimeSchema` and the manifest's `runtime` field said load-side enforcement was not implemented. The boot-time loader for plugins `os plugin install` installed now dispatches on the tier before importing anything: `node` loads in process (not isolated), `sandbox` and `worker` are refused by name, and an installed plugin that declares no tier is refused. The text says that, both halves still stated (the cloud marketplace publish gate's HTTP 422 for an unverified publisher requesting `node` is unchanged), and the retracted sentence is pinned absent. The field's TSDoc also says that an unset tier, which declares a pure-metadata package, is an omission on an artifact `os plugin build` produced (which carries code), and that the boot-time loader refuses an installed plugin with no tier either way. A plugin a stack's own `plugins` array names is not dispatched on this key. The liveness ledger's `runtime` row moves from `live-elsewhere` to `live` on the local dispatch point, and the ledger README keeps its account as the worked example of `live-elsewhere`, which no row holds now.
26 changes: 15 additions & 11 deletions content/docs/plugins/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -312,25 +312,29 @@ const dbPlugin: PluginMetadata = {

ObjectStack supports plugin security features:

- **Signature Verification**: Cryptographic signatures for plugin integrity
- **Signature Verification**: a signed plugin `os plugin install` installed is verified against the environment's trusted keys at install and again at every boot, before any of its code is imported. One installed with `--trust-unsigned` has no signature to verify: at boot its kept artifact is checked against the hash recorded at install, and its files against that artifact.
- **Permission Enforcement**: Fine-grained access control for plugin operations

A `signature` field on a plugin object is not a verification by itself. The
kernel's loader refuses a plugin that carries one unless the host that verified
its artifact recorded that verdict against the object, so leave `signature`
unset on a plugin you register yourself. A host that verifies artifacts of its
own records the verdict before `kernel.use`:

```typescript
import { recordPluginArtifactVerdict } from '@objectstack/core';

// After this host verified the artifact's bytes against a trusted key:
recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 });
await kernel.use(plugin); // refused without the verdict
```

Plugin configuration is the plugin's own concern: take it as a constructor or
factory argument and parse it at your own seam (a Zod `schema.parse` in the
factory, constructor or `init`). The kernel-side `configSchema` metadata field
was retired under ADR-0049 — the kernel never received a plugin's config, so
the field could not validate anything.

```typescript
const securePlugin: PluginMetadata = {
name: 'com.example.secure',
version: '1.0.0',
signature: 'ed25519:key-1:<base64url-signature>',

async init(ctx) { /* ... */ },
};
```

### Startup Timeout

Plugins can configure a custom startup timeout (default: 30 seconds):
Expand Down
31 changes: 20 additions & 11 deletions content/docs/protocol/kernel/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ command, so no `objectstack upgrade …` line on this page is runnable.

**Code-plugin installation is local and partial.** `os plugin install
./x.osplugin` verifies a local artifact's signature against the environment's
trusted keys and its per-file integrity, then unpacks it; nothing loads an
installed plugin at boot yet, and there is no `os plugin enable` or `test`.
trusted keys and its per-file integrity, then unpacks it, and the next
`os serve` re-verifies it and loads it — `runtime: 'node'` only, in process;
`sandbox` and `worker` are refused at boot until
[ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md)
§3.5 M1 records the loader as the next step; see
[Plugin Installation](#plugin-installation) for what an author does today.
M3. `os plugin disable`, `enable` and `uninstall` act at the next boot; there
is no `os plugin test`. See [Plugin Installation](#plugin-installation) for
what an author does today.
</Callout>

ObjectStack manages the complete **lifecycle** of the platform runtime—from initial boot through schema evolution and graceful shutdown, and describes the design intent for plugin installation, upgrades, and rollbacks. The boot sequence is implemented as described; the installation and upgrade flows are design intent (see the callout above).
Expand Down Expand Up @@ -201,8 +203,11 @@ class AppPlugin implements Plugin {
records and the platform ships only in part: `os plugin install ./x.osplugin`
verifies a local artifact (its signature against the environment's trusted
keys, its files against the manifest's `integrity` map), unpacks it and
records the installation, writing nothing until every check passes. Download
from the cloud, loading an installed plugin at boot, and the install hooks and
records the installation, writing nothing until every check passes. At the
next boot, `os serve` re-verifies each installation (the kept artifact's
sha256, its signature against the trusted keys as they are now, the unpacked
tree) and loads it by its declared tier: `node` in process, `sandbox` and
`worker` refused by name. Download from the cloud and the install hooks and
registry below have no command and no runtime path. The sections below up to
*Upgrades* describe that design, not a command you can run. See
[What an author does today](#what-an-author-does-today).
Expand Down Expand Up @@ -375,8 +380,8 @@ transitions (this page, above), not code hooks; boot-time code goes in
### What an author does today

The one install command for a code plugin is `os plugin install`, for a
signed artifact on local disk, and nothing loads what it installs yet. What
exists, per the
signed artifact on local disk; the next `os serve` loads what it installed.
What exists, per the
[CLI reference](https://github.com/objectstack-ai/objectstack/blob/main/packages/cli/README.md):

- **Runtime plugins** declared in `objectstack.config.ts` `plugins` are loaded
Expand All @@ -390,8 +395,12 @@ exists, per the
manifest) are packaged and shipped with `os plugin build`, `os plugin sign`
and `os plugin publish`. A signed `.osplugin` on local disk is installed with
`os plugin install` once its publisher key is trusted (`os plugin keygen`,
`os plugin trust add`); `os serve` does not load installed plugins yet, and a
published `.osplugin` has no `os` command that fetches it from the cloud.
`os plugin trust add`). `os serve` re-verifies and loads it at boot when it
declares `runtime: 'node'` (author code in the host process, not isolated),
refuses `sandbox` and `worker` until they are routed, and prints each refusal
by name; `os plugin disable | enable | uninstall ID` take effect at the next
boot. A published `.osplugin` has no `os` command that fetches it from the
cloud.

See [Plugin System](/docs/plugins) for how kernel plugins are created and
registered.
Expand Down Expand Up @@ -920,7 +929,7 @@ Plugin authors must document breaking changes in CHANGELOG.md.

ObjectStack lifecycle management provides:
- **Deterministic Boot:** 7-phase boot sequence with clear error handling
- **Atomic Installations (design):** ADR-0025 calls for transactional, all-or-nothing plugin installs; `os plugin install` verifies a local artifact in full before it writes anything, but nothing loads an installed plugin at boot yet
- **Atomic Installations (design):** ADR-0025 calls for transactional, all-or-nothing plugin installs; `os plugin install` verifies a local artifact in full before it writes anything, and `os serve` re-verifies it before loading it
- **Zero-Downtime Upgrades:** Blue-green and rolling strategies for production
- **Automatic Rollback:** Failed upgrades auto-revert to previous version
- **Health Monitoring:** Built-in health checks validate system state
Expand Down
Loading
Loading