diff --git a/.changeset/artifact-pinned-boot-os-artifact-url.md b/.changeset/artifact-pinned-boot-os-artifact-url.md new file mode 100644 index 0000000000..d81e18627b --- /dev/null +++ b/.changeset/artifact-pinned-boot-os-artifact-url.md @@ -0,0 +1,75 @@ +--- +"@objectstack/runtime": minor +"@objectstack/cli": minor +--- + +feat(cli,runtime): `OS_ARTIFACT_URL` — boot a stack from a published artifact by reference (#8368) + +`objectstack start` / `serve` can now be pointed at an artifact **by reference** +with a single environment variable, so a fixed runtime image plus one env var is +a running app. Upgrading the app becomes an env change and a restart rather than +an image rebuild — the runtime image and the app artifact become two independent +release axes. + +```bash +OS_ARTIFACT_URL=https://cdn.example.com/hotcrm-2.2.2.json # fetched at boot +OS_ARTIFACT_URL=file:///srv/app/objectstack.json # read directly +OS_ARTIFACT_URL='https://cdn.example.com/hotcrm-2.2.2.json#sha256=<64 hex>' # content-verified +``` + +**One variable, not two.** The optional integrity pin is SRI-style and lives +inside the URL **fragment**; there is deliberately no companion +`OS_ARTIFACT_SHA256`. A fragment is client-side by standard and is never sent to +the server, so the pin travels with the reference — one value to copy, one value +to rotate — without changing anything the artifact host sees. A second variable +would make "URL updated, hash not" a reachable state; this shape makes it +unspellable. + +**Precedence.** `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` > +`/dist/objectstack.json`. Beating `OS_ARTIFACT_PATH` matters in practice: +the official runtime image sets it to `/srv/app/objectstack.json`, so on a +container carrying no app it is always set and always points at a file that does +not exist. `OS_ARTIFACT_URL` also wins over an `objectstack.config.ts` in the +working directory — naming a published artifact is an explicit instruction, and +a deployed app must not depend on which directory the process is standing in. + +**What it refuses, and how loudly:** + +- **No pin → no verification.** A fetch or read failure fails the boot loudly so + container orchestration retries. There is no cache-fallback on this path: with + no pin there is nothing to authenticate a cached copy with. +- **Pin present → verified before boot.** A mismatch refuses and names the + **expected and the actual** digest, so a republished artifact is + distinguishable from a substituted one. A fetch failure may fall back to a + locally cached copy, but only one whose bytes still hash to the pin — the + cache is re-hashed on every read, so the filename is never the authority — and + it says so with a loud warning. +- **`engines.protocol` is validated against the runtime** at reference + resolution, before anything connects, and an incompatible artifact refuses + with both ways out named (repoint the reference, or run a matching image). +- **Migration policy.** Safe migrations run at boot; a destructive change (the + `os migrate apply --allow-destructive` class) refuses the boot with an + operator message naming every change. Never skipped in silence. This applies + to the artifact-pinned boot only — every other boot keeps the standing + production policy, under which the schema is never auto-altered. + +**Secrets.** The reference may be a pre-signed URL, i.e. the credential *is* the +URL. Nothing downstream of resolution ever sees it: remote bytes are +materialised to a local file under `/artifacts` and the boot continues +against that path, so the URL reaches neither the banner, nor the metadata +service's artifact-source record, nor any log line. Every message this path +produces — including messages originating inside `fetch`, which routinely carry +the whole URL — is scrubbed of userinfo and query material. Userinfo is moved +into an `Authorization: Basic` header, both because `fetch` refuses to construct +a request from a URL carrying credentials and because a credential in the +request line lands in the artifact host's access log. + +Materialising the fetched bytes is also what makes the pin mean anything: the +bytes that were hashed are the bytes that boot, and the artifact is fetched +exactly once. + +Not included, by design: `OS_PACKAGE_REF` registry resolution, signature +enforcement and entitlements; multi-tenant fleet / hostname routing. Fetching +and booting an artifact is open-framework mechanism — walled tenancy postures +remain entitled through `@objectstack/organizations` regardless of how the +artifact arrives. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 4d1bc518fe..f78d839c4a 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -319,6 +319,7 @@ os start | Flag | Env equivalent | Purpose | |---|---|---| | `-a, --artifact ` | `OS_ARTIFACT_PATH` | File path or `http(s)://` URL to the compiled artifact | +| — | `OS_ARTIFACT_URL` | Boot a published artifact **by reference**, optionally content-hash pinned via a `#sha256=` fragment. See [Artifact-pinned boot](/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url) | | `-d, --database ` | `OS_DATABASE_URL` | `file:…` / `libsql://` / `postgres://` / `mongodb://` / `memory://` | | `--database-driver ` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `sqlite-wasm` \| `turso` \| `postgres` \| `mysql` \| `mongodb` \| `memory` when the URL is ambiguous | | `--database-auth-token ` | `OS_DATABASE_AUTH_TOKEN` | Auth token for libsql/Turso | @@ -336,7 +337,7 @@ os start > (CORS). **Pin the port explicitly** (`OS_PORT=8080 os start`) and keep > `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` in sync when you change it. -**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_PATH` > `/dist/objectstack.json` > `/dist/objectstack.json` > auto-compile from `objectstack.config.ts` (when present) > empty kernel. +**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` > `/dist/objectstack.json` > `/dist/objectstack.json` > auto-compile from `objectstack.config.ts` (when present) > empty kernel. **Resolution priority (database):** `--database` > `OS_DATABASE_URL` > `DATABASE_URL` (legacy) > `file:/data/objectstack.db`. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 4656824f84..0530b6e443 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -55,6 +55,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. | | `OS_STORAGE_ROOT` | path | — | **Deprecated alias for `OS_STORAGE_LOCAL_ROOT`.** Still read for one release, with a startup warning; it will be removed in a future major. Rename it now. Before the rename the two halves of the platform spelled this value differently — the CLI wrote `OS_STORAGE_ROOT` while the settings service read `OS_STORAGE_LOCAL_ROOT` — so **any value other than the default was silently discarded** at startup and uploads landed in `./.objectstack/data/uploads` regardless. If you set `OS_STORAGE_ROOT` on an older release, check where your uploads actually are before assuming a backup covered them. | | `OS_ARTIFACT_PATH` | path | — | Path or `http(s)://` URL to a compiled `objectstack.json` artifact to boot the kernel from. | +| `OS_ARTIFACT_URL` | url | — | Boot a **published artifact by reference** — `https://…/hotcrm-2.2.2.json` (fetched at boot) or `file:///…/objectstack.json` (read directly, the volume-mount workflow). Overrides `OS_ARTIFACT_PATH` and any `objectstack.config.ts` in the working directory; `--artifact` still wins. Optionally pinned with an SRI-style fragment: `…/hotcrm-2.2.2.json#sha256=<64 hex chars>` — there is deliberately **no** companion `OS_ARTIFACT_SHA256`, because a URL fragment is client-side by standard (never sent to the server) and so travels with the reference as one value. See [Artifact-pinned boot](/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url). | --- diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 0daa8e1258..0a9898d88c 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -114,6 +114,53 @@ docker run -p 8080:8080 \ (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come straight from release storage instead of a mount.) +### Artifact-pinned boot (`OS_ARTIFACT_URL`) + +The image above carries no app. `OS_ARTIFACT_URL` names one **by reference**, so +a fixed runtime image plus one environment variable is a running app — and +upgrading the app is an env change plus a restart, never an image rebuild. The +runtime image and the app artifact become two independent release axes. + +```bash +docker run -p 8080:8080 \ + -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \ + -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ + -e OS_AUTH_SECRET -e OS_SECRET_KEY \ + ghcr.io/objectstack-ai/objectstack:14.8.0 +``` + +Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly +(the volume-mount workflow above, spelled as a URL). The variable overrides the +image's preset `OS_ARTIFACT_PATH` and any `objectstack.config.ts` in the working +directory. + +**The integrity pin lives in the URL fragment.** `#sha256=<64 hex chars>` is +SRI-style and there is deliberately no companion `OS_ARTIFACT_SHA256`: a +fragment is client-side by standard and is never sent to the server, so the pin +travels with the reference as a single value to copy and a single value to +rotate. Two variables would make "URL updated, hash not" a state you can reach. + +| Situation | What the runtime does | +|---|---| +| No `#sha256=` fragment | Boots without verification. A fetch or read failure **fails the boot** so your orchestrator retries — there is no cache fallback, because there is nothing to authenticate a cached copy with. | +| `#sha256=` present, content matches | Boots, and keeps the verified copy under `/artifacts`. | +| `#sha256=` present, content differs | **Refuses to boot**, naming the expected *and* the actual digest. | +| `#sha256=` present, artifact host unreachable | Falls back to the cached copy **only** if it still hashes to the pin, with a loud warning that the instance is running on cached content. | +| Artifact's `engines.protocol` excludes this runtime | **Refuses to boot** — the safety belt of the two-axis split. Repoint the reference, or run a matching image version. | +| The artifact needs a destructive schema change | Safe migrations run at boot; a destructive one **refuses to boot** and names each change. Run `os migrate apply --allow-destructive` deliberately, then restart. Never skipped in silence. | + +**Recommended production discipline** (convention, not enforced by the runtime): +publish immutable, version-named objects; give only CI write access to the +artifact host; and pin the digest in the fragment. Together these make "which +bytes is this instance running?" a question with one answer. + +**Pre-signed URLs are safe to use.** The reference may carry auth material — a +signature query parameter, or `user:token@host` — and it is never echoed into +logs or HTTP responses. Userinfo is sent as an `Authorization: Basic` header +rather than in the request line (so it does not land in your artifact host's +access log), and remote bytes are materialised to a local file before the boot +continues, so the URL does not reach any downstream surface at all. + For a self-contained deployable image, extend it. The Dockerfile below (plus the compose stack in the next section and a `.dockerignore`) ships ready-made in the project scaffold — diff --git a/docker/Dockerfile b/docker/Dockerfile index bed697ffea..f37e2f2098 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,6 +18,19 @@ # OS_ARTIFACT_PATH also accepts an https:// URL, so the artifact can be # fetched from your release storage instead of copied in. # +# Or name the artifact BY REFERENCE and skip the image build entirely +# (#8368) — OS_ARTIFACT_URL overrides the OS_ARTIFACT_PATH preset below, so a +# container carrying no app boots the referenced one: +# +# docker run -p 8080:8080 \ +# -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \ +# -e OS_DATABASE_URL=... -e OS_AUTH_SECRET -e OS_SECRET_KEY \ +# ghcr.io/objectstack-ai/objectstack: +# +# The `#sha256=` fragment is an optional SRI-style integrity pin, verified +# before boot; a mismatch refuses to boot. Docs: +# https://docs.objectstack.ai/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url +# # Published by .github/workflows/docker-publish.yml on every framework # release; the image tag always matches the @objectstack/cli version inside. # Docs: https://docs.objectstack.ai/docs/deployment/self-hosting diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index ecd666c44b..85edac7b72 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -862,7 +862,75 @@ export default class Serve extends Command { const configMissing = !fs.existsSync(absolutePath); let useArtifactFallback = false; let useEmptyBoot = false; - if (configMissing) { + + // ── Artifact-pinned boot (#8368) ───────────────────────────────── + // `OS_ARTIFACT_URL` names the artifact BY REFERENCE — an https:// URL + // fetched at boot, or a file:// URL read directly (the volume-mount + // workflow) — with an optional SRI-style `#sha256=` integrity pin in the + // fragment. It is resolved here, before anything else looks for an + // artifact, and it wins over every local lookup: + // + // --artifact > OS_ARTIFACT_URL > OS_ARTIFACT_PATH > /dist/… + // + // Beating OS_ARTIFACT_PATH is not a nicety, it is the acceptance + // criterion: the official runtime image sets + // `ENV OS_ARTIFACT_PATH=/srv/app/objectstack.json`, so on a container that + // carries no app at all that variable is always set and always points at a + // file that does not exist. Without this precedence, "runtime container + + // one env var" would refuse with "the artifact named by OS_ARTIFACT_PATH + // does not exist" and the feature would be unreachable exactly where it + // was designed to be used. + // + // It also wins over an `objectstack.config.ts` that happens to be in the + // cwd. Setting this variable is an explicit instruction to boot a specific + // published artifact; silently preferring whatever source tree the process + // is standing in would make the deployed app depend on the container's + // working directory. + // + // The resolver hands back a LOCAL path — remote bytes are materialised + // under `/artifacts` — so nothing downstream of this line ever sees + // the URL. That is what keeps a pre-signed reference out of the banner, + // the metadata service's artifact-source record and every log line, and it + // also means the bytes that were hashed are the bytes that boot. + let pinnedArtifact: { localPath: string; display: string } | undefined; + const artifactUrlEnv = process.env.OS_ARTIFACT_URL; + if (artifactUrlEnv && artifactUrlEnv.trim() !== '') { + const runtimeMod = await import('@objectstack/runtime'); + const { resolveArtifactReference, resolveArtifactFetchTimeoutMs, resolveObjectStackHome } = runtimeMod; + try { + const resolved = await resolveArtifactReference(artifactUrlEnv, { + homeDir: resolveObjectStackHome(), + fetchTimeoutMs: resolveArtifactFetchTimeoutMs(process.env), + // The cache-fallback warning must survive the boot-quiet window — + // "this instance is serving cached content" is the one degraded-boot + // note an operator must not miss. + warn: (m) => process.stderr.write(chalk.yellow(m) + '\n'), + }); + pinnedArtifact = { localPath: resolved.localPath, display: resolved.display }; + printDiagnostic(); + printDiagnostic(chalk.dim( + ` Artifact (OS_ARTIFACT_URL): ${resolved.display}` + + ` [${resolved.origin}${resolved.expectedSha256 ? `, sha256 verified` : ', unpinned'}]`, + )); + useArtifactFallback = true; + } catch (err: any) { + // Every message from the resolver is already scrubbed of the URL's + // credential-bearing parts, so it is printed verbatim. ONE write, for + // the reason spelled out at the "Nothing to serve" exit below. + printDiagnostic( + '\n' + + chalk.red(` ✗ Cannot boot from OS_ARTIFACT_URL.\n`) + + chalk.dim(' ') + String(err?.message ?? err).split('\n').join('\n ') + '\n' + + '\n' + + chalk.dim(' The boot is refused rather than degraded — container orchestration\n') + + chalk.dim(' should retry, and a runtime told to serve one specific artifact must\n') + + chalk.dim(' never invent a different one.'), + ); + this.exit(1); + } + } + + if (configMissing && !pinnedArtifact) { const { resolveDefaultArtifactPath } = await import('@objectstack/runtime'); const artifactSource = resolveDefaultArtifactPath(); if (!artifactSource) { @@ -895,11 +963,13 @@ export default class Serve extends Command { chalk.red(' ✗ Nothing to serve — no config and no compiled artifact.') + '\n' + chalk.dim(` Looked for a config at: ${absolutePath}\n`) + chalk.dim(` Looked for an artifact at: ${path.resolve(process.cwd(), 'dist/objectstack.json')}\n`) - + chalk.dim(' OS_ARTIFACT_PATH is not set.\n') + + chalk.dim(' Neither OS_ARTIFACT_URL nor OS_ARTIFACT_PATH is set.\n') + '\n' + chalk.dim(' Hint: `objectstack init` scaffolds a new project;\n') + chalk.dim(' `objectstack start` boots an app-less kernel against your marketplace;\n') - + chalk.dim(' `objectstack build` (or OS_ARTIFACT_PATH) supplies a compiled artifact.\n') + + chalk.dim(' `objectstack build` (or OS_ARTIFACT_PATH) supplies a compiled artifact;\n') + + chalk.dim(' OS_ARTIFACT_URL=https://…/objectstack.json boots a published artifact\n') + + chalk.dim(' by reference (optionally pinned with #sha256=<64 hex chars>).\n') + chalk.dim(' Already have a project? Check your working directory.'), ); this.exit(1); @@ -912,6 +982,8 @@ export default class Serve extends Command { printDiagnostic(); if (useEmptyBoot) { printDiagnostic(chalk.dim(' No objectstack.config.ts or artifact found — booting empty kernel...')); + } else if (pinnedArtifact) { + printDiagnostic(chalk.dim(' Booting from the artifact named by OS_ARTIFACT_URL (default host)...')); } else if (useArtifactFallback) { printDiagnostic(chalk.dim(' No objectstack.config.ts found — booting from artifact (default host)...')); } else { @@ -1080,7 +1152,16 @@ export default class Serve extends Command { // "missing artifact" error and assemble a bare kernel that // can later install marketplace apps at runtime. const { createDefaultHostConfig } = await import('@objectstack/runtime'); - const bootResult = await createDefaultHostConfig({ requireArtifact: !useEmptyBoot, dev: isDev }); + const bootResult = await createDefaultHostConfig({ + requireArtifact: !useEmptyBoot, + dev: isDev, + // #8368: the already-fetched, already-verified LOCAL copy. Passing + // it explicitly (rather than re-deriving from the environment) is + // what stops the loader from fetching the URL a second time — a pin + // that verifies one response while a different response boots would + // verify nothing. + ...(pinnedArtifact ? { artifactPath: pinnedArtifact.localPath } : {}), + }); // [#4002] `api` merges per key — see mergeBootConfig. A shallow spread // let the boot builder's two scoping keys wipe the author's whole `api` // block, silently dropping `requireAuth` / `enforceProjectMembership`. @@ -3014,6 +3095,51 @@ export default class Serve extends Command { } } + // ── Artifact-pinned boot: migration policy (#8368, acceptance #5) ── + // Only on the OS_ARTIFACT_URL path. On this path "upgrade the app" is an + // env change plus a restart, with nobody at a terminal to read a drift + // warning at the moment it matters — so safe changes are applied and a + // destructive one refuses the boot instead of being warned about and + // skipped. Every other boot keeps the standing production policy + // untouched (schema is never auto-altered under NODE_ENV=production). + // + // Registered as a plugin whose `kernel:ready` hook runs the gate: Phase 3 + // is after every plugin's start() — so ObjectQL's schema sync has already + // created tables and added columns — and before Phase 4 opens the HTTP + // socket. A throw from a boot-path hook propagates and fails the boot, so + // "refuse to boot" is literal: the port never binds. + if (pinnedArtifact) { + const artifactDisplay = pinnedArtifact.display; + await kernel.use({ + name: 'com.objectstack.cli.artifact-boot-migration-gate', + version: '1.0.0', + init: async (ctx: any) => { + ctx.hook('kernel:ready', async () => { + const { findSqlDriverForKernel } = await import('../utils/schema-migrate.js'); + const { runArtifactBootMigrationGate } = await import('../utils/artifact-boot-migration.js'); + const verdict = await runArtifactBootMigrationGate({ + driver: findSqlDriverForKernel(kernel), + artifactDisplay, + info: (m) => printDiagnostic(chalk.dim(m)), + warn: (m) => console.warn(chalk.yellow(m)), + }); + if (!verdict.ok) { + // Restore stdout before the refusal so the boot-quiet window + // cannot swallow the one message that explains the exit. + restoreOutput(); + console.error('\n' + verdict.refusal); + throw new Error( + `Refusing to boot: ${verdict.destructive.length} destructive schema change(s) ` + + `required by the artifact named by OS_ARTIFACT_URL. ` + + `Run 'os migrate apply --allow-destructive' deliberately, then restart.`, + ); + } + }); + }, + } as any); + trackPlugin('ArtifactBootMigrationGate'); + } + // Boot the runtime await runtime.start(); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 1f6317febe..b6e6488c4e 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -52,6 +52,14 @@ export default class Start extends Command { '<%= config.bin %> start --home ~/my-objectstack', '<%= config.bin %> start --artifact ./build/myapp.json', '<%= config.bin %> start --artifact https://cdn.example.com/app.json --port 8080', + { + // #8368: artifact-pinned boot. One env var names the app; the integrity + // pin is SRI-style INSIDE the fragment (a fragment is client-side by + // standard and never sent to the artifact host), so there is no second + // variable to keep in sync with the first. + command: 'OS_ARTIFACT_URL="https://cdn.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" <%= config.bin %> start', + description: 'Boot a published artifact by reference, content-hash verified before boot', + }, '<%= config.bin %> start --database file:./data/prod.db', '<%= config.bin %> start --database postgres://user:pass@host:5432/mydb', { @@ -89,7 +97,7 @@ export default class Start extends Command { // Artifact source artifact: Flags.string({ char: 'a', - description: 'Path or http(s):// URL to the compiled objectstack.json (overrides $OS_ARTIFACT_PATH; auto-detected from ./dist/objectstack.json or /dist/objectstack.json; when an objectstack.config.ts is present and no artifact exists, it is compiled automatically)', + description: 'Path or http(s):// URL to the compiled objectstack.json (overrides $OS_ARTIFACT_URL and $OS_ARTIFACT_PATH; auto-detected from ./dist/objectstack.json or /dist/objectstack.json; when an objectstack.config.ts is present and no artifact exists, it is compiled automatically)', }), compile: Flags.boolean({ @@ -178,10 +186,24 @@ export default class Start extends Command { // auto-compile the config to ./dist/objectstack.json when no // artifact has been built yet, so `os start` works on a fresh // clone without needing a separate `os build`. - let artifactSource = resolveArtifactSource(flags.artifact, homeDir); + // ── Artifact-pinned boot (#8368) ──────────────────────────────── + // `OS_ARTIFACT_URL` names a published artifact by reference. `start` does + // NOT resolve it — `serve` does, once, and owns the fetch, the `#sha256=` + // verification, the protocol handshake and the migration gate. All `start` + // does is get out of the way: no local lookup, no auto-compile, and no + // OS_ARTIFACT_PATH / OS_BOOT_EMPTY in the child env that would contradict + // the reference. The variable itself is inherited by the child. + // + // An explicit `--artifact` still wins (flags over env, as everywhere in + // this command), and it wins by REMOVING the variable from the child env — + // leaving both set would hand `serve` two answers and let it pick. + const artifactUrl = flags.artifact ? undefined : process.env.OS_ARTIFACT_URL?.trim() || undefined; + + let artifactSource = artifactUrl ? undefined : resolveArtifactSource(flags.artifact, homeDir); const shouldAutoCompile = hasProjectConfig && !flags.artifact + && !artifactUrl && !process.env.OS_ARTIFACT_PATH && (flags.compile || !artifactSource); @@ -247,7 +269,13 @@ export default class Start extends Command { printKV('Config', path.relative(cwd, projectConfigPath) || 'objectstack.config.ts', '📂'); } printKV('Home', homeDir, '🏠'); - if (artifactSource) { + if (artifactUrl) { + // Redacted: the reference may be a pre-signed URL whose query string IS + // the credential (#8368 acceptance #6). The banner prints scheme, host + // and path only — enough to recognise which artifact was named. + const { redactArtifactUrl } = await import('@objectstack/runtime'); + printKV('Artifact', `${redactArtifactUrl(artifactUrl)} (OS_ARTIFACT_URL)`, '📦'); + } else if (artifactSource) { printKV('Artifact', artifactSource.display, '📦'); } else { printKV('Artifact', 'none (empty kernel — install apps via the Console marketplace)', '📦'); @@ -274,8 +302,21 @@ export default class Start extends Command { ...(flags['database-driver'] ? { OS_DATABASE_DRIVER: flags['database-driver'] } : {}), ...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}), AUTH_SECRET: authSecret, - ...(artifactSource ? { OS_ARTIFACT_PATH: artifactSource.path } : { OS_BOOT_EMPTY: '1' }), + // #8368: with OS_ARTIFACT_URL in play, neither knob is set — the child + // resolves the reference itself. OS_BOOT_EMPTY in particular must NOT be + // set here: it would tell `serve` that booting an app-less kernel is an + // acceptable outcome, turning an unreachable artifact host into a + // silently empty platform instead of the loud refusal acceptance #2 asks + // for. + ...(artifactUrl + ? {} + : artifactSource + ? { OS_ARTIFACT_PATH: artifactSource.path } + : { OS_BOOT_EMPTY: '1' }), }; + // Flags over env: an explicit --artifact removes the reference rather than + // racing it (see the resolution note above). + if (flags.artifact) delete localEnv.OS_ARTIFACT_URL; // NODE_ENV is only forced to production when the user has not set it. // Allows `NODE_ENV=development objectstack start` to work for debugging. if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production'; diff --git a/packages/cli/src/utils/artifact-boot-migration.test.ts b/packages/cli/src/utils/artifact-boot-migration.test.ts new file mode 100644 index 0000000000..27fdb9e73b --- /dev/null +++ b/packages/cli/src/utils/artifact-boot-migration.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Boot-time migration policy for the artifact-pinned boot (#8368, acceptance #5). + * + * The criterion has three clauses and each one is a different way of being + * wrong, so each is asserted separately: + * + * 1. safe migrations RUN at boot — a gate that only refuses would satisfy a + * "does it refuse?" test while quietly leaving every loosening change + * unapplied, which is the state `os migrate` exists to avoid; + * 2. a destructive change REFUSES the boot — with the changes named, because + * a refusal an operator cannot act on is a crash loop with extra steps; + * 3. nothing is skipped in SILENCE — the driver declining a change is + * reported as its own event, not folded into "applied". + * + * The `needs_confirm` case is pinned deliberately: it is the boundary this gate + * must not redraw. `os migrate apply` applies it without `--allow-destructive` + * (`category !== 'destructive' || allowDestructive`), so a gate that refused on + * it would be inventing a second, stricter opinion about which changes are + * dangerous — and two opinions is how one of them ends up wrong. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { ManagedDriftEntry } from '@objectstack/driver-sql'; +import type { SqlDriverLike } from './schema-migrate.js'; +import { + formatDestructiveDriftRefusal, + runArtifactBootMigrationGate, +} from './artifact-boot-migration.js'; + +const SGR = /\x1b\[[0-9;]*m/g; +const plain = (s: string) => s.replace(SGR, ''); + +const ARTIFACT = 'https://artifacts.example.com/hotcrm-2.2.2.json'; + +function entry( + category: ManagedDriftEntry['category'], + over: Partial = {}, +): ManagedDriftEntry { + return { + kind: 'missing_column', + severity: category === 'destructive' ? 'error' : 'warning', + table: 'crm_lead', + column: 'legacy_score', + category, + op: { type: 'add_column', table: 'crm_lead', column: 'legacy_score' } as any, + message: `${category} change on crm_lead.legacy_score`, + ...over, + }; +} + +/** A driver that reports `drift` and records what it was asked to apply. */ +function fakeDriver(drift: ManagedDriftEntry[], opts: { skip?: ManagedDriftEntry[] } = {}) { + const applyCalls: Array<{ entries: ManagedDriftEntry[]; allowDestructive?: boolean }> = []; + const driver: SqlDriverLike = { + detectManagedDrift: vi.fn(async () => drift), + applyMigrationEntries: vi.fn(async ( + entries: ManagedDriftEntry[], + o: { allowDestructive?: boolean }, + ) => { + applyCalls.push({ entries, allowDestructive: o.allowDestructive }); + const skipped = opts.skip ?? []; + const skippedSet = new Set(skipped); + return { applied: entries.filter((e) => !skippedSet.has(e)), skipped }; + }), + }; + return { driver, applyCalls }; +} + +describe('runArtifactBootMigrationGate — safe changes run', () => { + it('applies safe drift at boot and reports each one', async () => { + const safe = entry('safe'); + const { driver, applyCalls } = fakeDriver([safe]); + const info: string[] = []; + + const verdict = await runArtifactBootMigrationGate({ + driver, artifactDisplay: ARTIFACT, info: (m) => info.push(m), + }); + + expect(verdict.ok).toBe(true); + expect(verdict.applied).toEqual([safe]); + // Applied, not merely detected: the driver was really called, and it + // was called WITHOUT the destructive licence. + expect(applyCalls).toHaveLength(1); + expect(applyCalls[0]!.allowDestructive).toBe(false); + expect(info.join('\n')).toContain('crm_lead.legacy_score'); + }); + + it('applies needs_confirm alongside safe — the same boundary os migrate apply draws', async () => { + const needsConfirm = entry('needs_confirm'); + const { driver, applyCalls } = fakeDriver([needsConfirm]); + + const verdict = await runArtifactBootMigrationGate({ driver, artifactDisplay: ARTIFACT }); + + expect(verdict.ok).toBe(true); + expect(applyCalls[0]!.entries).toEqual([needsConfirm]); + }); + + it('does nothing, and refuses nothing, when the schema is already in sync', async () => { + const { driver, applyCalls } = fakeDriver([]); + const verdict = await runArtifactBootMigrationGate({ driver, artifactDisplay: ARTIFACT }); + expect(verdict).toMatchObject({ ok: true, applied: [], destructive: [] }); + expect(applyCalls).toHaveLength(0); + }); +}); + +describe('runArtifactBootMigrationGate — destructive changes refuse the boot', () => { + it('refuses, and names every destructive change plus the resolving command', async () => { + const destructive = entry('destructive', { + column: 'old_stage', + message: 'crm_lead.old_stage is orphaned — "os migrate apply --allow-destructive" to drop it.', + }); + const { driver } = fakeDriver([destructive]); + + const verdict = await runArtifactBootMigrationGate({ driver, artifactDisplay: ARTIFACT }); + + expect(verdict.ok).toBe(false); + expect(verdict.destructive).toEqual([destructive]); + const refusal = plain(verdict.refusal!); + expect(refusal).toContain('Refusing to boot'); + expect(refusal).toContain('crm_lead.old_stage'); + expect(refusal).toContain('os migrate apply --allow-destructive'); + // The artifact is named: on this boot path the operator's next question + // is "which artifact asked for this?", and the answer is not in the cwd. + expect(refusal).toContain(ARTIFACT); + }); + + it('still applies the safe half before refusing — never all-or-nothing', async () => { + const safe = entry('safe'); + const destructive = entry('destructive', { column: 'old_stage' }); + const { driver, applyCalls } = fakeDriver([safe, destructive]); + + const verdict = await runArtifactBootMigrationGate({ driver, artifactDisplay: ARTIFACT }); + + expect(verdict.ok).toBe(false); + expect(verdict.applied).toEqual([safe]); + // The destructive entry was never handed to the driver at all. + expect(applyCalls[0]!.entries).toEqual([safe]); + expect(applyCalls[0]!.entries).not.toContain(destructive); + }); + + it('never applies a destructive change on its own authority', async () => { + const destructive = entry('destructive'); + const { driver, applyCalls } = fakeDriver([destructive]); + await runArtifactBootMigrationGate({ driver, artifactDisplay: ARTIFACT }); + // Nothing safe to apply ⇒ the driver is not called at all, and it is + // certainly never called with allowDestructive. + expect(applyCalls.every((c) => c.allowDestructive !== true)).toBe(true); + }); +}); + +describe('runArtifactBootMigrationGate — nothing is skipped in silence', () => { + it('warns when the DRIVER declines a change the gate asked for', async () => { + const safe = entry('safe'); + const { driver } = fakeDriver([safe], { skip: [safe] }); + const warnings: string[] = []; + + const verdict = await runArtifactBootMigrationGate({ + driver, artifactDisplay: ARTIFACT, warn: (m) => warnings.push(m), + }); + + // Boot continues (an unsupported dialect operation is not a destructive + // change) but the skip is an event, not a silence — and it is NOT + // reported as applied. + expect(verdict.ok).toBe(true); + expect(verdict.applied).toEqual([]); + expect(verdict.skipped).toEqual([safe]); + expect(warnings.join('\n')).toContain('not applied by the driver'); + }); + + it('warns and continues when drift detection itself fails', async () => { + const driver: SqlDriverLike = { + detectManagedDrift: vi.fn(async () => { throw new Error('table introspection exploded'); }), + applyMigrationEntries: vi.fn(async () => ({ applied: [], skipped: [] })), + }; + const warnings: string[] = []; + + const verdict = await runArtifactBootMigrationGate({ + driver, artifactDisplay: ARTIFACT, warn: (m) => warnings.push(m), + }); + + // Refusing here would make an unrelated database hiccup look identical + // to a destructive change — the operator would go hunting for a schema + // problem that does not exist. + expect(verdict.ok).toBe(true); + expect(warnings.join('\n')).toContain('table introspection exploded'); + expect(warnings.join('\n')).toContain('os migrate plan'); + }); + + it('passes when there is no SQL driver — nothing issues managed DDL', async () => { + const verdict = await runArtifactBootMigrationGate({ driver: null, artifactDisplay: ARTIFACT }); + expect(verdict).toMatchObject({ ok: true, applied: [], destructive: [] }); + }); +}); + +describe('formatDestructiveDriftRefusal', () => { + it('renders a table-scoped entry without a phantom column', async () => { + const text = plain(formatDestructiveDriftRefusal( + [entry('destructive', { column: undefined, table: 'crm_archive', message: 'table is orphaned' })], + ARTIFACT, + )); + expect(text).toContain('crm_archive — table is orphaned'); + expect(text).not.toContain('crm_archive.undefined'); + }); + + it('counts the changes and prescribes the review step before the apply step', () => { + const text = plain(formatDestructiveDriftRefusal( + [entry('destructive', { column: 'a' }), entry('destructive', { column: 'b' })], + ARTIFACT, + )); + expect(text).toContain('2 destructive schema change(s)'); + expect(text.indexOf('os migrate plan')).toBeLessThan(text.indexOf('os migrate apply --allow-destructive')); + expect(text).toContain('never skipped in silence'); + }); +}); diff --git a/packages/cli/src/utils/artifact-boot-migration.ts b/packages/cli/src/utils/artifact-boot-migration.ts new file mode 100644 index 0000000000..6a2302f3ad --- /dev/null +++ b/packages/cli/src/utils/artifact-boot-migration.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Boot-time migration policy for the artifact-pinned boot (#8368, acceptance #5). + * + * ## Why the artifact-pinned boot needs its own policy + * + * The two-axis deployment model — fixed runtime image, app artifact named by + * `OS_ARTIFACT_URL` — makes "upgrade the app" an env change plus a restart. + * That is precisely the moment the physical schema and the metadata can + * disagree, and it happens with no operator at a terminal: a container simply + * comes up carrying a different artifact than the one that shaped the database. + * + * The standing production policy is deliberately hands-off — `autoMigrate: + * 'safe'` is ignored under `NODE_ENV=production` and every divergence is + * warned about, because the operator is assumed to be running `os migrate` + * deliberately. On this path there is nobody to read a warning at the moment it + * matters, so the acceptance list asks for something stricter and more + * decidable: + * + * - **safe (loosening) drift is applied** at boot, the same set + * `os migrate apply` applies without `--allow-destructive`; + * - **destructive drift refuses the boot**, with a message naming every + * change and the command that resolves it; + * - **nothing is skipped silently** — the third state, "shrug and serve", is + * the one this gate exists to delete. + * + * `needs_confirm` drift is applied along with `safe`: `os migrate apply`'s own + * split is `category !== 'destructive' || allowDestructive`, so "the + * `--allow-destructive` class" the acceptance names is exactly + * `category === 'destructive'` and nothing else. Re-deriving that boundary here + * would be a second opinion about which changes are dangerous, and two opinions + * is how one of them ends up wrong. + * + * ## Where it runs + * + * On the `kernel:ready` hook (Phase 3): after every plugin's `start()`, so + * ObjectQL's schema sync has already created tables and added columns, and + * before Phase 4 opens the HTTP socket. A throw from a boot-path hook + * propagates and fails the boot, so "refuse to boot" is literal here — the port + * never binds. + * + * Phase 3 also makes the verdict safe to record: every provider has registered + * by then, so this is not the #4777 "judge a registry mid-fill" shape. The + * drift set is final for this boot. + */ + +import chalk from 'chalk'; +import type { ManagedDriftEntry } from '@objectstack/driver-sql'; +import type { SqlDriverLike } from './schema-migrate.js'; + +/** What the gate decided, in a form a test can assert without booting a kernel. */ +export interface ArtifactBootMigrationVerdict { + /** `false` means: refuse the boot and print {@link refusal}. */ + ok: boolean; + /** Safe/needs-confirm entries this gate actually applied. */ + applied: ManagedDriftEntry[]; + /** Entries the gate wanted to apply but the driver skipped. */ + skipped: ManagedDriftEntry[]; + /** Destructive entries — non-empty implies `ok === false`. */ + destructive: ManagedDriftEntry[]; + /** Operator-facing refusal text; set only when `ok === false`. */ + refusal?: string; +} + +/** One drift entry as a bullet — `table.column: message`, or `table: message`. */ +function describeEntry(entry: ManagedDriftEntry): string { + const where = entry.column ? `${entry.table}.${entry.column}` : entry.table; + return ` • ${where} — ${entry.message}`; +} + +/** + * The operator message for a refused boot. + * + * Pure and exported so the wording is testable without a database: a refusal + * whose text nobody checks drifts into "an error occurred", and this one is the + * only thing standing between an operator and an unexplained crash-loop. + */ +export function formatDestructiveDriftRefusal( + destructive: ManagedDriftEntry[], + artifactDisplay: string, +): string { + return [ + chalk.red(` ✗ Refusing to boot — ${destructive.length} destructive schema change(s) required.`), + '', + chalk.dim(` Artifact: ${artifactDisplay}`), + chalk.dim(' The artifact this runtime was told to boot needs changes that can destroy'), + chalk.dim(' data (dropping a column or table, tightening a constraint). Safe changes'), + chalk.dim(' were applied; these were NOT, and the boot stops rather than serving an'), + chalk.dim(' app whose schema silently disagrees with its metadata.'), + '', + ...destructive.map((d) => chalk.yellow(describeEntry(d))), + '', + chalk.dim(' Resolve deliberately, with a backup taken first:'), + chalk.dim(' os migrate plan # review'), + chalk.dim(' os migrate apply --allow-destructive # apply'), + '', + chalk.dim(' Then restart this runtime. A destructive change is never applied'), + chalk.dim(' automatically at boot, and never skipped in silence.'), + ].join('\n'); +} + +/** + * Run the artifact-pinned boot's migration policy against a live SQL driver. + * + * Returns a verdict rather than throwing, so the caller owns how a refusal + * travels (this one becomes a thrown boot failure; a test just reads it). + */ +export async function runArtifactBootMigrationGate(opts: { + driver: SqlDriverLike | null; + artifactDisplay: string; + info?: (message: string) => void; + warn?: (message: string) => void; +}): Promise { + const { driver, artifactDisplay } = opts; + const info = opts.info ?? (() => {}); + const warn = opts.warn ?? (() => {}); + + // No SQL driver (memory / mongo) — nothing issues managed DDL, so there is + // no drift to classify and nothing for this policy to decide. + if (!driver) return { ok: true, applied: [], skipped: [], destructive: [] }; + + let drift: ManagedDriftEntry[]; + try { + drift = await driver.detectManagedDrift(); + } catch (err: any) { + // Refusing on an introspection failure would make an unrelated database + // hiccup indistinguishable from a destructive change. Warn — loudly, + // this is the one path where the gate did not run. + warn( + ` ⚠ Could not check the physical schema against the artifact ` + + `(${err?.message ?? err}). Boot continues; run 'os migrate plan' to verify.`, + ); + return { ok: true, applied: [], skipped: [], destructive: [] }; + } + + const destructive = drift.filter((d) => d.category === 'destructive'); + const safe = drift.filter((d) => d.category !== 'destructive'); + + let applied: ManagedDriftEntry[] = []; + let skipped: ManagedDriftEntry[] = []; + if (safe.length > 0) { + const result = await driver.applyMigrationEntries(safe, { allowDestructive: false }); + applied = result.applied; + skipped = result.skipped; + for (const d of applied) { + info(` ↪ migrated ${d.op.type} on ${d.column ? `${d.table}.${d.column}` : d.table}`); + } + // A skip here is the driver declining (unsupported on this dialect), not + // this gate's policy — say so rather than let it pass as applied. + for (const d of skipped) { + warn(` ⚠ schema change not applied by the driver: ${describeEntry(d).trim()}`); + } + } + + if (destructive.length === 0) return { ok: true, applied, skipped, destructive }; + + return { + ok: false, + applied, + skipped, + destructive, + refusal: formatDestructiveDriftRefusal(destructive, artifactDisplay), + }; +} diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index 83f2dd3a46..5afa8d31df 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -83,6 +83,21 @@ function findSqlDriver(kernel: any): SqlDriverLike | null { return findSqlDriverVia((name) => kernel?.getService?.(name)); } +/** + * The same lookup, for callers that already hold a booted kernel (#8368's + * artifact-boot migration gate) instead of booting one through + * {@link bootSchemaStack}. + * + * Exported rather than re-derived at the call site so there stays ONE list of + * SQL-driver service names: a second copy would silently stop finding a driver + * the day a kind is added here, and "no SQL driver" is this gate's own + * everything-is-fine answer — the quietest possible way for a boot policy to + * stop running. + */ +export function findSqlDriverForKernel(kernel: unknown): SqlDriverLike | null { + return findSqlDriver(kernel); +} + /** * Arms the SQL driver's deferred-DDL mode before boot schema-sync can run * (#3917). diff --git a/packages/cli/test/artifact-pinned-boot.e2e.test.ts b/packages/cli/test/artifact-pinned-boot.e2e.test.ts new file mode 100644 index 0000000000..82fe269fa5 --- /dev/null +++ b/packages/cli/test/artifact-pinned-boot.e2e.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Artifact-pinned boot, end to end (#8368, acceptance #1 and #6). + * + * ## Why this one has to be a real child process + * + * The criterion is "a runtime container + `OS_ARTIFACT_URL` boots the + * referenced artifact **with no project checkout present**". Every part of that + * sentence is about the process's surroundings, not about a function's return + * value: the cwd holds no `objectstack.config.ts` and no `dist/`, the artifact + * arrives over the network or from a mounted file, and the boot has to reach + * "kernel bootstrapped" from there. A unit test can prove the resolver returns + * the right path; only a child process standing in an empty directory can prove + * a boot happens from it. + * + * `OS_MIGRATE_AND_EXIT=1` is what makes that affordable: `serve` runs the whole + * kernel bootstrap — plugins, datasource, schema sync, metadata hydration, and + * the #8368 migration gate on `kernel:ready` — then shuts down and exits 0 + * instead of serving. A successful exit therefore means the artifact really + * booted, not merely that a path resolved. + * + * ## The secrets assertion, and its positive control + * + * The refusal case drives a pre-signed URL — userinfo plus a signature query + * parameter — through the real failure path and reads the child's ENTIRE + * stdout+stderr. Asserting the credential is absent is worthless on its own: an + * empty capture, a child that died before printing, or a message about + * something else would all pass. So the same capture is asserted to contain the + * host and path of that same URL. The output is provably the output that would + * have carried the credential. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX_LOADER = resolve(HERE, '../../../node_modules/tsx/dist/loader.mjs'); + +/** A minimal but real compiled artifact: one app, one object. */ +const ARTIFACT = JSON.stringify( + { + manifest: { + id: 'com.example.hotcrm', + name: 'hotcrm', + version: '2.2.2', + type: 'app', + engines: { protocol: `^${PROTOCOL_MAJOR}` }, + }, + objects: [ + { + name: 'crm_lead', + label: 'Lead', + fields: { name: { type: 'text', label: 'Name' } }, + }, + ], + views: [], + apps: [], + flows: [], + requires: [], + }, + null, + 2, +); +const ARTIFACT_SHA256 = createHash('sha256').update(Buffer.from(ARTIFACT)).digest('hex'); + +const HOST_PATH = '/releases/hotcrm-2.2.2.json'; +const CREDENTIAL = 's3cr3t-signature-value'; +const USERINFO_SECRET = 'hunter2-userinfo'; + +let root: string; +let server: Server; +let origin: string; + +/** Serve the artifact over real HTTP so the boot performs a real fetch. */ +beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'os-8368-e2e-')); + server = createServer((req, res) => { + if ((req.url ?? '').startsWith(HOST_PATH)) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(ARTIFACT); + return; + } + res.writeHead(404).end('not found'); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const addr = server.address() as { port: number }; + origin = `127.0.0.1:${addr.port}`; +}); + +afterAll(async () => { + await new Promise((r) => server.close(() => r())); + rmSync(root, { recursive: true, force: true }); +}); + +interface RunResult { code: number; output: string } + +/** + * Run `os serve` in a FRESH EMPTY directory — no config, no `dist/`, nothing. + * That emptiness is the point of the criterion, so it is built here rather than + * assumed. + */ +function runServe(env: Record, opts: { migrateAndExit?: boolean } = {}): Promise { + const cwd = mkdtempSync(join(root, 'empty-')); + const home = join(cwd, 'home'); + mkdirSync(home, { recursive: true }); + // Guard the premise: an accidental fixture in the cwd would make a passing + // boot mean nothing at all. + expect(readdirSync(cwd).filter((f) => f !== 'home')).toEqual([]); + + return new Promise((resolvePromise) => { + const child = spawn( + process.execPath, + ['--import', TSX_LOADER, CLI, 'serve'], + { + cwd, + env: { + ...process.env, + NODE_ENV: 'production', + OS_HOME: home, + OS_DATABASE_URL: `file:${join(home, 'e2e.db')}`, + OS_SECRET_KEY: '0'.repeat(64), + AUTH_SECRET: '0'.repeat(32), + OS_DISABLE_CONSOLE: '1', + ...(opts.migrateAndExit === false ? {} : { OS_MIGRATE_AND_EXIT: '1' }), + // The image default this feature has to beat: always set on + // the official runtime image, always pointing at a file a + // container carrying no app does not have. + OS_ARTIFACT_PATH: join(cwd, 'dist/objectstack.json'), + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let output = ''; + child.stdout.on('data', (c) => { output += String(c); }); + child.stderr.on('data', (c) => { output += String(c); }); + child.on('close', (code) => resolvePromise({ code: code ?? -1, output })); + }); +} + +const BOOT_TIMEOUT = 180_000; + +describe('OS_ARTIFACT_URL — boots with no project checkout (acceptance #1)', () => { + it('boots an https:// artifact, overriding the image\'s OS_ARTIFACT_PATH default', async () => { + const { code, output } = await runServe({ + OS_ARTIFACT_URL: `http://${origin}${HOST_PATH}#sha256=${ARTIFACT_SHA256}`, + }); + expect(output).toContain('sha256 verified'); + expect(output).toContain('Migration complete'); + expect(code).toBe(0); + // The OS_ARTIFACT_PATH the image sets points at a file that does not + // exist; had it won, the boot would have refused with "does not exist". + expect(output).not.toContain('does not exist'); + }, BOOT_TIMEOUT); + + it('boots a file:// artifact — the volume-mount workflow', async () => { + const mounted = join(root, 'mounted-objectstack.json'); + writeFileSync(mounted, ARTIFACT); + const { code, output } = await runServe({ + OS_ARTIFACT_URL: `${pathToFileURL(mounted).href}#sha256=${ARTIFACT_SHA256}`, + }); + expect(output).toContain('sha256 verified'); + expect(code).toBe(0); + }, BOOT_TIMEOUT); + + it('boots unpinned, and says so rather than claiming verification', async () => { + const { code, output } = await runServe({ + OS_ARTIFACT_URL: `http://${origin}${HOST_PATH}`, + }); + expect(output).toContain('unpinned'); + expect(output).not.toContain('sha256 verified'); + expect(code).toBe(0); + }, BOOT_TIMEOUT); +}); + +describe('OS_ARTIFACT_URL — loud refusals (acceptance #2, #3, #6)', () => { + it('refuses a hash mismatch, naming expected AND actual, and exits non-zero', async () => { + const wrong = 'f'.repeat(64); + const { code, output } = await runServe({ + OS_ARTIFACT_URL: `http://${origin}${HOST_PATH}#sha256=${wrong}`, + }); + expect(code).not.toBe(0); + expect(output).toContain('Integrity check FAILED'); + expect(output).toContain(wrong); // expected + expect(output).toContain(ARTIFACT_SHA256); // actual + }, BOOT_TIMEOUT); + + it('refuses an unreachable artifact instead of degrading to an empty kernel', async () => { + const { code, output } = await runServe({ + // Port 1 on loopback: nothing listens, connection refused immediately. + OS_ARTIFACT_URL: 'http://127.0.0.1:1/nope.json', + }); + expect(code).not.toBe(0); + expect(output).toContain('Cannot boot from OS_ARTIFACT_URL'); + // The degradation this must never take. + expect(output).not.toContain('booting empty kernel'); + }, BOOT_TIMEOUT); + + it('never prints the credential of a pre-signed URL — on the real refusal path', async () => { + const presigned = + `http://svc-user:${USERINFO_SECRET}@${origin}${HOST_PATH}` + + `?X-Amz-Signature=${CREDENTIAL}&X-Amz-Credential=AKIAEXAMPLE` + + `#sha256=${'f'.repeat(64)}`; + const { code, output } = await runServe({ OS_ARTIFACT_URL: presigned }); + + expect(code).not.toBe(0); + // ── Positive control ──────────────────────────────────────────── + // Without this, every assertion below would also pass against an + // empty capture or a child that never reached this code path. + expect(output.length).toBeGreaterThan(0); + expect(output).toContain(origin); + expect(output).toContain(HOST_PATH); + expect(output).toContain('Integrity check FAILED'); + // ── The assertion that matters ────────────────────────────────── + expect(output).not.toContain(CREDENTIAL); + expect(output).not.toContain(USERINFO_SECRET); + expect(output).not.toContain('AKIAEXAMPLE'); + expect(output).not.toContain('svc-user'); + }, BOOT_TIMEOUT); + + it('refuses an artifact built for another protocol major (acceptance #4)', async () => { + const incompatible = JSON.stringify({ + manifest: { + id: 'com.example.old', + name: 'old', + version: '1.0.0', + type: 'app', + engines: { protocol: `^${PROTOCOL_MAJOR - 1}` }, + }, + objects: [], + }); + const file = join(root, 'incompatible.json'); + writeFileSync(file, incompatible); + + const { code, output } = await runServe({ + OS_ARTIFACT_URL: pathToFileURL(file).href, + }); + expect(code).not.toBe(0); + expect(output).toContain(`^${PROTOCOL_MAJOR - 1}`); + + // ── Why this assertion is written the strict way ──────────────── + // `AppPlugin` ALREADY runs the same handshake when a bundle reaches it, + // so "an incompatible artifact fails the boot" was true before this + // change too — a test asserting only a non-zero exit would pass against + // `origin/main`'s behaviour and prove nothing about #8368. What is new + // is WHERE the refusal happens: at reference resolution, before the + // artifact boot is even announced and before any datasource connects. + expect(output).toContain('Cannot boot from OS_ARTIFACT_URL'); + expect(output).not.toContain('Booting from the artifact named by OS_ARTIFACT_URL'); + }, BOOT_TIMEOUT); +}); diff --git a/packages/runtime/src/artifact-reference.test.ts b/packages/runtime/src/artifact-reference.test.ts new file mode 100644 index 0000000000..7149be260b --- /dev/null +++ b/packages/runtime/src/artifact-reference.test.ts @@ -0,0 +1,647 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Artifact-pinned boot — `OS_ARTIFACT_URL` (#8368). + * + * Every acceptance criterion except #5 (the migration gate, which needs a live + * SQL driver and is pinned in `packages/cli/src/utils/artifact-boot-migration.test.ts`) + * and the end-to-end half of #1 (a real `os serve` child with no project + * checkout, pinned in `packages/cli/test/artifact-pinned-boot.e2e.test.ts`). + * + * ## Two assertions here are written against a specific way of being wrong + * + * **#2 — "no cache-fallback logic" is tested by planting a cache that WOULD + * work.** Asserting that an unpinned fetch failure throws proves almost + * nothing: an implementation with a cache fallback also throws when the cache + * is empty, which is the state a naive test leaves it in. So the unpinned + * failure case below first writes a byte-identical artifact into the cache + * directory and only then cuts the network. The refusal has to happen with a + * usable copy sitting on disk, because that is the situation the criterion is + * actually about. + * + * **#6 — an absence assertion needs a positive control.** `expect(text).not.toContain(secret)` + * passes just as happily when `text` is `''`, when the code path never ran, or + * when the message was about something else entirely. Every leak assertion + * below is therefore paired with a positive control asserting that the SAME + * captured text contains the host and path of the same URL — proving the text + * really is the output that would have carried the credential. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; + +import { + ArtifactReferenceError, + OS_ARTIFACT_URL_ENV, + artifactCacheDir, + makeArtifactUrlScrubber, + parseArtifactReference, + pinnedCachePath, + redactArtifactUrl, + resolveArtifactFetchTimeoutMs, + resolveArtifactReference, + sha256Hex, + stagedArtifactPath, +} from './artifact-reference.js'; + +// ── Fixtures ───────────────────────────────────────────────────────── + +/** An artifact declaring a protocol range this runtime satisfies. */ +const COMPATIBLE_ARTIFACT = { + manifest: { + id: 'com.example.hotcrm', + name: 'hotcrm', + version: '2.2.2', + type: 'app', + engines: { protocol: `^${PROTOCOL_MAJOR}` }, + }, + objects: [], + requires: [], +}; + +const artifactJson = (body: unknown = COMPATIBLE_ARTIFACT) => JSON.stringify(body, null, 2); +const digestOf = (text: string) => createHash('sha256').update(Buffer.from(text)).digest('hex'); + +/** + * A pre-signed reference: the credential IS the URL. `hunter2-userinfo` and + * `s3cr3t-signature-value` are the two tokens no output may ever carry. + */ +const HOST = 'artifacts.example.com'; +const ARTIFACT_PATH = '/releases/hotcrm-2.2.2.json'; +const CREDENTIAL_QUERY = 'X-Amz-Signature=s3cr3t-signature-value&X-Amz-Credential=AKIAEXAMPLE'; +const PRESIGNED = `https://svc-user:hunter2-userinfo@${HOST}${ARTIFACT_PATH}?${CREDENTIAL_QUERY}`; +const SECRET_TOKENS = ['s3cr3t-signature-value', 'hunter2-userinfo', 'AKIAEXAMPLE', 'svc-user']; + +/** The positive control: text derived from this reference must name these. */ +const expectNamesTheArtifact = (text: string) => { + expect(text).toContain(HOST); + expect(text).toContain(ARTIFACT_PATH); +}; + +const expectNoSecrets = (text: string) => { + for (const token of SECRET_TOKENS) expect(text).not.toContain(token); +}; + +let home: string; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'os-artifact-ref-')); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** A `fetch` stand-in that answers once with `body`, then records the calls. */ +function fetchServing(body: string, init: { status?: number } = {}) { + const calls: string[] = []; + const impl = vi.fn(async (input: any, _init?: any) => { + calls.push(String(input)); + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Not Found', + arrayBuffer: async () => { + const buf = Buffer.from(body); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + }, + } as any; + }); + return { impl: impl as unknown as typeof globalThis.fetch, calls, spy: impl }; +} + +/** A `fetch` stand-in whose rejection carries the full URL, exactly as undici's does. */ +function fetchFailingWithUrlInMessage(url: string) { + return vi.fn(async () => { + throw new Error(`request to ${url} failed, reason: ECONNREFUSED`); + }) as unknown as typeof globalThis.fetch; +} + +/** + * Await a call that MUST be refused, and hand back the refusal. + * + * Not merely a type convenience: `promise.catch((e) => e as Err)` types as + * `Err | Resolved`, so a call that wrongly SUCCEEDS arrives at the assertions + * as a resolved value and fails on a missing `.code` — red, but pointing at the + * wrong thing. This turns "it resolved" into its own accurate failure. + */ +async function refusalOf(promise: Promise): Promise { + try { + await promise; + } catch (err) { + return err as ArtifactReferenceError; + } + throw new Error('expected the reference to be refused, but it resolved'); +} + +/** Write a file and return its path. */ +function writeFixture(name: string, text: string): string { + const dir = mkdtempSync(join(tmpdir(), 'os-artifact-src-')); + const file = join(dir, name); + writeFileSync(file, text); + return file; +} + +// ── Parsing: one variable, pin inside the fragment ─────────────────── + +describe('parseArtifactReference — one env var, SRI-style fragment pin', () => { + it('extracts the pin from the fragment and keeps it out of the request target', () => { + const sha = 'a'.repeat(64); + const parsed = parseArtifactReference(`https://cdn.example.com/app.json#sha256=${sha}`); + expect(parsed.kind).toBe('http'); + expect(parsed.expectedSha256).toBe(sha); + // A fragment is client-side by standard; the target carries no trace of + // it, which is the whole reason the pin can live there. + expect(parsed.target).toBe('https://cdn.example.com/app.json'); + expect(parsed.target).not.toContain('sha256'); + }); + + it('treats an absent fragment as unpinned (acceptance #2)', () => { + expect(parseArtifactReference('https://cdn.example.com/app.json').expectedSha256).toBeNull(); + expect(parseArtifactReference('file:///srv/app/objectstack.json').expectedSha256).toBeNull(); + }); + + it('normalizes an uppercase pin', () => { + const parsed = parseArtifactReference(`https://cdn.example.com/app.json#sha256=${'A'.repeat(64)}`); + expect(parsed.expectedSha256).toBe('a'.repeat(64)); + }); + + it('resolves a file: URL to a filesystem path', () => { + const parsed = parseArtifactReference('file:///srv/app/objectstack.json'); + expect(parsed.kind).toBe('file'); + expect(parsed.target).toBe('/srv/app/objectstack.json'); + }); + + it('REFUSES a fragment that is not a well-formed pin, rather than booting unverified', () => { + // The dangerous degradation: a truncated or misspelled pin silently + // meaning "no verification", with every log line reporting success. + for (const bad of ['#sha256=deadbeef', '#sha-256=' + 'a'.repeat(64), '#integrity', `#sha512=${'a'.repeat(64)}`]) { + const err = (() => { + try { parseArtifactReference(`https://cdn.example.com/app.json${bad}`); return null; } + catch (e) { return e as ArtifactReferenceError; } + })(); + expect(err, `fragment ${bad} must be refused`).toBeInstanceOf(ArtifactReferenceError); + expect(err!.code).toBe('OS_ARTIFACT_URL_INVALID'); + expect(err!.message).toContain('#sha256='); + } + }); + + it('refuses an unsupported scheme, naming the scheme', () => { + try { + parseArtifactReference('ftp://cdn.example.com/app.json'); + throw new Error('expected a refusal'); + } catch (e) { + expect(e).toBeInstanceOf(ArtifactReferenceError); + expect((e as ArtifactReferenceError).message).toContain("'ftp:'"); + } + }); + + it('refuses a relative value WITHOUT echoing it — a mistyped URL is still a credential', () => { + try { + parseArtifactReference('./dist/objectstack.json?token=s3cr3t-signature-value'); + throw new Error('expected a refusal'); + } catch (e) { + const message = (e as ArtifactReferenceError).message; + expect(message).toContain('value withheld'); + expectNoSecrets(message); + // Positive control: the message is genuinely about this variable, + // not an empty string that trivially "contains no secret". + expect(message).toContain(OS_ARTIFACT_URL_ENV); + } + }); + + it('refuses an empty value instead of silently falling back to the local artifact', () => { + expect(() => parseArtifactReference(' ')).toThrow(/is set but empty/); + }); +}); + +// ── Acceptance #6: secrets discipline ──────────────────────────────── + +describe('secrets discipline (acceptance #6)', () => { + it('redactArtifactUrl keeps scheme/host/path and drops userinfo, query and fragment', () => { + const redacted = redactArtifactUrl(`${PRESIGNED}#sha256=${'a'.repeat(64)}`); + expectNamesTheArtifact(redacted); // positive control + expectNoSecrets(redacted); + expect(redacted).toContain('?'); + expect(redacted).not.toContain('sha256='); + }); + + it('never throws on an unparsable value — it withholds it', () => { + expect(redactArtifactUrl('not a url ?sig=s3cr3t-signature-value')) + .toBe(''); + }); + + it('the scrubber strips a URL that arrives inside someone ELSE\'s error text', () => { + // This is the shape that leaks in practice: `fetch` rejects with the + // whole URL in the message and a refusal helpfully quotes it. + const scrub = makeArtifactUrlScrubber(PRESIGNED); + const raw = `request to ${PRESIGNED} failed, reason: ECONNREFUSED`; + // Positive control on the INPUT: the text really does carry the secret + // before scrubbing, so its absence afterwards is the scrubber's doing. + for (const token of SECRET_TOKENS) expect(raw).toContain(token); + const scrubbed = scrub(raw); + expectNoSecrets(scrubbed); + expect(scrubbed).toContain('ECONNREFUSED'); + }); + + it('strips a URL-shaped token the scrubber has never seen (the backstop)', () => { + const scrub = makeArtifactUrlScrubber('https://cdn.example.com/app.json'); + const scrubbed = scrub('redirected to https://other.example.net/x?sig=leaked-elsewhere then failed'); + expect(scrubbed).not.toContain('leaked-elsewhere'); + expect(scrubbed).toContain('then failed'); + }); + + it('the fetch-failure refusal carries no credential — on the real code path', async () => { + const err = await refusalOf(resolveArtifactReference(PRESIGNED, { + homeDir: home, + fetchImpl: fetchFailingWithUrlInMessage(PRESIGNED), + warn: () => {}, + })); + + expect(err).toBeInstanceOf(ArtifactReferenceError); + expect(err.code).toBe('OS_ARTIFACT_UNREACHABLE'); + expectNamesTheArtifact(err.message); // positive control + expectNoSecrets(err.message); + // The structured detail is what a JSON log line would carry. + expectNoSecrets(JSON.stringify(err.detail)); + }); + + it('the integrity refusal and the cache warning carry no credential either', async () => { + const body = artifactJson(); + const wrongPin = 'b'.repeat(64); + const warnings: string[] = []; + + // (a) mismatch on the fetch path + const mismatch = await refusalOf(resolveArtifactReference(`${PRESIGNED}#sha256=${wrongPin}`, { + homeDir: home, + fetchImpl: fetchServing(body).impl, + warn: (m) => warnings.push(m), + })); + expect(mismatch.code).toBe('OS_ARTIFACT_INTEGRITY_MISMATCH'); + expectNamesTheArtifact(mismatch.message); + expectNoSecrets(mismatch.message); + + // (b) the degraded cache-fallback warning + const pin = digestOf(body); + mkdirSync(artifactCacheDir(home), { recursive: true }); + writeFileSync(pinnedCachePath(home, pin), body); + await resolveArtifactReference(`${PRESIGNED}#sha256=${pin}`, { + homeDir: home, + fetchImpl: fetchFailingWithUrlInMessage(PRESIGNED), + warn: (m) => warnings.push(m), + }); + const warning = warnings.join('\n'); + expectNamesTheArtifact(warning); // positive control + expectNoSecrets(warning); + }); + + it('moves userinfo into an Authorization header — fetch cannot carry it in the URL', async () => { + // Measured, not assumed: undici raises "Request cannot be constructed + // from a URL that includes credentials" before a packet leaves, so a + // reference of this shape is unusable until the credential is moved. + const parsed = parseArtifactReference(PRESIGNED); + expect(parsed.target).not.toContain('svc-user'); + expect(parsed.target).not.toContain('hunter2-userinfo'); + expect(parsed.authorization).toBe( + `Basic ${Buffer.from('svc-user:hunter2-userinfo').toString('base64')}`, + ); + + const body = artifactJson(); + const { impl, spy } = fetchServing(body); + await resolveArtifactReference(PRESIGNED, { homeDir: home, fetchImpl: impl }); + const init = spy.mock.calls[0]![1] as any; + expect(init.headers.Authorization).toBe(parsed.authorization); + // And the request line itself is clean — an access log on the artifact + // host is a leak this process could never scrub afterwards. + expect(String(spy.mock.calls[0]![0])).not.toContain('hunter2-userinfo'); + }); + + it('the base64 form of userinfo is scrubbed too — a credential in a costume', () => { + const scrub = makeArtifactUrlScrubber(PRESIGNED); + const b64 = Buffer.from('svc-user:hunter2-userinfo').toString('base64'); + expect(scrub(`sending Authorization: Basic ${b64}`)).not.toContain(b64); + }); + + it('hands NO url downstream — the boot continues against a local path', async () => { + const body = artifactJson(); + const resolved = await resolveArtifactReference(PRESIGNED, { + homeDir: home, + fetchImpl: fetchServing(body).impl, + warn: () => {}, + }); + // The single strongest guarantee for #6: what the rest of the boot is + // handed cannot leak a URL, because it is not one. + expect(resolved.localPath.startsWith(home)).toBe(true); + expectNoSecrets(resolved.localPath); + expectNoSecrets(resolved.display); + expectNamesTheArtifact(resolved.display); + }); +}); + +// ── Acceptance #1: both schemes boot ───────────────────────────────── + +describe('resolveArtifactReference — both schemes (acceptance #1)', () => { + it('file:// is read in place — the mounted file IS the booted file', async () => { + const body = artifactJson(); + const file = writeFixture('objectstack.json', body); + const resolved = await resolveArtifactReference(pathToFileURL(file).href, { homeDir: home }); + expect(resolved.origin).toBe('file'); + expect(resolved.localPath).toBe(file); + expect(resolved.sha256).toBe(digestOf(body)); + expect((resolved.bundle as any).manifest.id).toBe('com.example.hotcrm'); + }); + + it('https:// is fetched and materialised locally, byte-identical', async () => { + const body = artifactJson(); + const resolved = await resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing(body).impl, + }); + expect(resolved.origin).toBe('remote'); + expect(readFileSync(resolved.localPath, 'utf8')).toBe(body); + }); + + it('fetches EXACTLY ONCE — a pin that verifies a response nothing boots verifies nothing', async () => { + const body = artifactJson(); + const { impl, calls } = fetchServing(body); + await resolveArtifactReference(`https://cdn.example.com/app.json#sha256=${digestOf(body)}`, { + homeDir: home, + fetchImpl: impl, + }); + expect(calls).toHaveLength(1); + // And the fragment was not sent to the server. + expect(calls[0]).toBe('https://cdn.example.com/app.json'); + }); + + it('unwraps a { schemaVersion, metadata } envelope for inspection', async () => { + const body = JSON.stringify({ schemaVersion: 2, metadata: COMPATIBLE_ARTIFACT }); + const resolved = await resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing(body).impl, + }); + expect((resolved.bundle as any).manifest.name).toBe('hotcrm'); + // The materialised bytes stay the PUBLISHED bytes — that is what the + // pin is computed over. + expect(readFileSync(resolved.localPath, 'utf8')).toBe(body); + }); +}); + +// ── Acceptance #2: unpinned means unverified, and failures are loud ── + +describe('unpinned references (acceptance #2)', () => { + it('performs no verification at all', async () => { + const body = artifactJson(); + const resolved = await resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing(body).impl, + }); + expect(resolved.expectedSha256).toBeNull(); + // The digest is still computed and reported — "not verified" is a + // statement about admission, not about knowing what booted. + expect(resolved.sha256).toBe(digestOf(body)); + }); + + it('fails the boot loudly on a fetch failure EVEN THOUGH a usable cached copy exists', async () => { + // The sharp version of "no cache-fallback logic". Cutting the network + // with an empty cache proves nothing — an implementation that HAD a + // fallback would refuse there too, for want of anything to fall back + // to. So both places a plausible implementation would look are planted + // with byte-identical content first: + // + // • the content-addressed pinned cache, and + // • the URL-keyed staging path, which is where a naive + // "just remember the last good copy of this URL" fallback would go. + // + // The refusal has to happen with both sitting on disk, because that is + // the situation the criterion is actually about. + const url = 'https://cdn.example.com/app.json'; + const body = artifactJson(); + mkdirSync(artifactCacheDir(home), { recursive: true }); + writeFileSync(pinnedCachePath(home, digestOf(body)), body); + writeFileSync(stagedArtifactPath(home, redactArtifactUrl(url)), body); + + const err = await refusalOf(resolveArtifactReference(url, { + homeDir: home, + fetchImpl: fetchFailingWithUrlInMessage(url), + })); + + expect(err).toBeInstanceOf(ArtifactReferenceError); + expect(err.code).toBe('OS_ARTIFACT_UNREACHABLE'); + }); + + it('fails loudly on an HTTP error status, naming the status', async () => { + const err = await refusalOf(resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing('nope', { status: 404 }).impl, + })); + expect(err.code).toBe('OS_ARTIFACT_UNREACHABLE'); + expect(err.message).toContain('HTTP 404'); + }); + + it('fails loudly when a file:// reference does not exist', async () => { + const err = await refusalOf(resolveArtifactReference('file:///definitely/not/here/objectstack.json', { + homeDir: home, + })); + expect(err.code).toBe('OS_ARTIFACT_UNREACHABLE'); + expect(err.message).toContain('must not invent an empty one'); + }); +}); + +// ── Acceptance #3: the pin, and the one cache fallback it permits ──── + +describe('pinned references (acceptance #3)', () => { + it('admits content whose digest matches', async () => { + const body = artifactJson(); + const resolved = await resolveArtifactReference( + `https://cdn.example.com/app.json#sha256=${digestOf(body)}`, + { homeDir: home, fetchImpl: fetchServing(body).impl }, + ); + expect(resolved.origin).toBe('remote'); + expect(resolved.sha256).toBe(resolved.expectedSha256); + }); + + it('refuses a mismatch naming BOTH the expected and the actual digest', async () => { + const body = artifactJson(); + const expected = 'c'.repeat(64); + const err = await refusalOf(resolveArtifactReference( + `https://cdn.example.com/app.json#sha256=${expected}`, + { homeDir: home, fetchImpl: fetchServing(body).impl }, + )); + + expect(err.code).toBe('OS_ARTIFACT_INTEGRITY_MISMATCH'); + // Both, not "it threw": an operator who is told only that it failed + // cannot tell a republished artifact from a compromised one. + expect(err.message).toContain(expected); + expect(err.message).toContain(digestOf(body)); + expect(err.detail.expected).toBe(expected); + expect(err.detail.actual).toBe(digestOf(body)); + }); + + it('does not poison the cache with content that failed verification', async () => { + const body = artifactJson(); + const expected = 'c'.repeat(64); + await resolveArtifactReference(`https://cdn.example.com/app.json#sha256=${expected}`, { + homeDir: home, + fetchImpl: fetchServing(body).impl, + }).catch(() => undefined); + expect(existsSync(pinnedCachePath(home, expected))).toBe(false); + expect(existsSync(pinnedCachePath(home, digestOf(body)))).toBe(false); + }); + + it('refuses a file:// mismatch too, naming both digests', async () => { + const body = artifactJson(); + const file = writeFixture('objectstack.json', body); + const expected = 'd'.repeat(64); + const err = await refusalOf(resolveArtifactReference( + `${pathToFileURL(file).href}#sha256=${expected}`, + { homeDir: home }, + )); + expect(err.code).toBe('OS_ARTIFACT_INTEGRITY_MISMATCH'); + expect(err.message).toContain(expected); + expect(err.message).toContain(digestOf(body)); + }); + + it('falls back to a cached copy on a fetch failure — with a loud warning', async () => { + const body = artifactJson(); + const pin = digestOf(body); + mkdirSync(artifactCacheDir(home), { recursive: true }); + writeFileSync(pinnedCachePath(home, pin), body); + + const warnings: string[] = []; + const resolved = await resolveArtifactReference( + `https://cdn.example.com/app.json#sha256=${pin}`, + { + homeDir: home, + fetchImpl: fetchFailingWithUrlInMessage('https://cdn.example.com/app.json'), + warn: (m) => warnings.push(m), + }, + ); + expect(resolved.origin).toBe('cache'); + expect(resolved.sha256).toBe(pin); + expect(warnings.join('\n')).toContain('running on cached content'); + }); + + it('refuses a cached copy whose content no longer matches the pin', async () => { + const pin = digestOf(artifactJson()); + mkdirSync(artifactCacheDir(home), { recursive: true }); + // A corrupt / tampered cache entry sitting at the content-addressed + // name. The NAME is not the authority — the bytes are re-hashed. + writeFileSync(pinnedCachePath(home, pin), artifactJson({ manifest: { id: 'com.evil' } })); + + const err = await refusalOf(resolveArtifactReference( + `https://cdn.example.com/app.json#sha256=${pin}`, + { + homeDir: home, + fetchImpl: fetchFailingWithUrlInMessage('https://cdn.example.com/app.json'), + warn: () => {}, + }, + )); + expect(err.code).toBe('OS_ARTIFACT_INTEGRITY_MISMATCH'); + expect(err.detail.source).toMatch(/^cache /); + }); + + it('refuses when the fetch fails and no cached copy exists', async () => { + const err = await refusalOf(resolveArtifactReference( + `https://cdn.example.com/app.json#sha256=${'e'.repeat(64)}`, + { + homeDir: home, + fetchImpl: fetchFailingWithUrlInMessage('https://cdn.example.com/app.json'), + warn: () => {}, + }, + )); + expect(err.code).toBe('OS_ARTIFACT_UNREACHABLE'); + }); + + it('a verified fetch populates the cache the fallback later reads', async () => { + const body = artifactJson(); + const pin = digestOf(body); + await resolveArtifactReference(`https://cdn.example.com/app.json#sha256=${pin}`, { + homeDir: home, + fetchImpl: fetchServing(body).impl, + }); + expect(readFileSync(pinnedCachePath(home, pin), 'utf8')).toBe(body); + }); +}); + +// ── Acceptance #4: the engines.protocol safety belt ────────────────── + +describe('engines.protocol validation (acceptance #4)', () => { + const withProtocol = (range: string) => + artifactJson({ ...COMPATIBLE_ARTIFACT, manifest: { ...COMPATIBLE_ARTIFACT.manifest, engines: { protocol: range } } }); + + it('refuses an artifact whose declared range excludes this runtime', async () => { + const body = withProtocol(`^${PROTOCOL_MAJOR - 1}`); + const err = await refusalOf(resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing(body).impl, + })); + + expect(err).toBeInstanceOf(ArtifactReferenceError); + expect(err.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(err.message).toContain(`^${PROTOCOL_MAJOR - 1}`); + // The refusal is about the two RELEASE AXES, so it has to prescribe + // both ways out rather than only "migrate your metadata". + expect(err.message).toContain(OS_ARTIFACT_URL_ENV); + expect(err.detail.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + }); + + it('refuses BEFORE anything is materialised — no half-booted state on disk', async () => { + const body = withProtocol(`^${PROTOCOL_MAJOR - 1}`); + const pin = digestOf(body); + await resolveArtifactReference(`https://cdn.example.com/app.json#sha256=${pin}`, { + homeDir: home, + fetchImpl: fetchServing(body).impl, + }).catch(() => undefined); + expect(existsSync(pinnedCachePath(home, pin))).toBe(false); + }); + + it('admits the current major', async () => { + const body = withProtocol(`^${PROTOCOL_MAJOR}`); + await expect(resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing(body).impl, + })).resolves.toMatchObject({ origin: 'remote' }); + }); + + it('admits an artifact that declares no range — grandfathering, never a false rejection', async () => { + const body = artifactJson({ manifest: { id: 'com.example.legacy', name: 'legacy', version: '1.0.0' }, objects: [] }); + await expect(resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing(body).impl, + })).resolves.toMatchObject({ origin: 'remote' }); + }); + + it('refuses malformed JSON rather than booting an empty platform', async () => { + const err = await refusalOf(resolveArtifactReference('https://cdn.example.com/app.json', { + homeDir: home, + fetchImpl: fetchServing('{ not json').impl, + })); + expect(err.code).toBe('OS_ARTIFACT_MALFORMED'); + }); +}); + +// ── Shared knobs ───────────────────────────────────────────────────── + +describe('resolveArtifactFetchTimeoutMs', () => { + it('honours only a positive numeric OS_ARTIFACT_FETCH_TIMEOUT_MS', () => { + expect(resolveArtifactFetchTimeoutMs({ OS_ARTIFACT_FETCH_TIMEOUT_MS: '5000' })).toBe(5000); + expect(resolveArtifactFetchTimeoutMs({ OS_ARTIFACT_FETCH_TIMEOUT_MS: '0' })).toBe(60_000); + expect(resolveArtifactFetchTimeoutMs({ OS_ARTIFACT_FETCH_TIMEOUT_MS: 'soon' })).toBe(60_000); + expect(resolveArtifactFetchTimeoutMs({})).toBe(60_000); + }); +}); + +describe('sha256Hex', () => { + it('matches what sha256sum prints for the same bytes', () => { + expect(sha256Hex(Buffer.from('hello'))).toBe( + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + ); + }); +}); diff --git a/packages/runtime/src/artifact-reference.ts b/packages/runtime/src/artifact-reference.ts new file mode 100644 index 0000000000..c11e341cd1 --- /dev/null +++ b/packages/runtime/src/artifact-reference.ts @@ -0,0 +1,678 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Artifact-pinned boot — `OS_ARTIFACT_URL` (#8368). + * + * The missing half of a deployment model where the runtime image and the app + * artifact are two independent release axes: a fixed runtime container plus one + * env var naming the artifact *by reference* is a running app, and upgrading + * the app is an env change plus a restart rather than an image rebuild. + * + * ## One variable, one value + * + * OS_ARTIFACT_URL=https://cdn.example.com/hotcrm-2.2.2.json fetched at boot + * OS_ARTIFACT_URL=file:///srv/app/objectstack.json read directly + * + * The optional integrity pin is SRI-style and lives **inside the URL + * fragment** — `…/hotcrm-2.2.2.json#sha256= ` + 64 hex chars. There is + * deliberately no separate `OS_ARTIFACT_SHA256` variable. That is not a + * stylistic choice: a fragment is client-side by standard and is never sent to + * the server, so the pin rides along with the reference — one value to copy, + * one value to rotate — without changing a single byte of what the artifact + * host sees. Splitting it into a second variable makes "URL updated, hash not" + * a reachable state; keeping it in the fragment makes that state unspellable. + * + * ## What this module refuses, and how loudly + * + * | condition | verdict | + * |-----------------------------------|------------------------------------------------| + * | no `#sha256=` fragment | no verification at all | + * | fetch/read failure, unpinned | refuse (orchestration retries) — no cache logic | + * | fetch failure, pinned, cache hit | serve the cache, loud warning | + * | hash mismatch (network or cache) | refuse, naming expected **and** actual | + * | `engines.protocol` excludes us | refuse (the safety belt of the two-axis split) | + * + * The cache fallback exists only on the pinned path, and the cached bytes are + * re-hashed on every read: the pin — not the filename, not the fact that some + * earlier boot wrote the file — is what admits a cached copy. An unpinned boot + * has nothing to authenticate a cached copy *with*, which is why acceptance #2 + * says "no cache-fallback logic" rather than "a smaller cache-fallback". + * + * ## Secrets discipline (acceptance #6) + * + * The reference may be a pre-signed URL, i.e. the credential IS the URL. Two + * structural defences, because a rule that depends on every future call site + * remembering to redact is a rule that leaks: + * + * 1. **Nothing downstream ever sees the URL.** A remote artifact is + * materialised to a local file and the boot continues against that path, + * so the reference does not reach `MetadataPlugin`, the banner, the + * metadata service's artifact-source record, or any HTTP surface that + * reports where the app came from. + * 2. **Every message this module produces is scrubbed**, including messages + * that originate in `fetch` — whose failures routinely carry the full URL, + * and which is the classic leak: a refusal that helpfully prints "could not + * fetch ". {@link makeArtifactUrlScrubber} strips the + * known credential-bearing tokens and then removes any surviving absolute + * URL from the text, so a leak needs a *new* carrier, not just a new call + * site. + * + * A third defence falls out of a WHATWG rule rather than a decision: `fetch` + * refuses outright to construct a request from a URL carrying userinfo, so a + * `https://user:token@host/app.json` reference cannot work at all unless the + * credential moves into a header. {@link parseArtifactReference} moves it to + * `Authorization: Basic`, which also keeps it out of the artifact host's access + * log — the one leak this process cannot scrub afterwards. + * + * Error codes here are SCREAMING_SNAKE per ADR-0112's casing rule but are + * deliberately **not** registered in `ERROR_CODE_LEDGER`: that ledger governs + * the code a failing *request* answers with, and every refusal below happens + * before the HTTP server binds — none of them can ever reach a response + * envelope. Registering one would create exactly the unemittable row the + * ledger's "retiring a code" section calls a defect. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkProtocolCompat } from '@objectstack/metadata-core'; + +/** The one environment variable this feature adds. */ +export const OS_ARTIFACT_URL_ENV = 'OS_ARTIFACT_URL'; + +/** Schemes an artifact reference may use. */ +const SUPPORTED_SCHEMES = ['https:', 'http:', 'file:'] as const; + +/** `#sha256=<64 hex>` — the only fragment shape an artifact reference may carry. */ +const SHA256_FRAGMENT = /^#sha256=([0-9a-fA-F]{64})$/; + +/** Default remote fetch timeout, overridable via `OS_ARTIFACT_FETCH_TIMEOUT_MS`. */ +export const DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS = 60_000; + +export type ArtifactReferenceKind = 'http' | 'file'; + +export interface ParsedArtifactReference { + kind: ArtifactReferenceKind; + /** + * What is actually requested — the reference with the fragment removed. + * For `file:` references this is the decoded filesystem path. + */ + target: string; + /** Lowercased hex digest from the fragment, or `null` when unpinned. */ + expectedSha256: string | null; + /** Log-safe rendering: userinfo dropped, query masked, fragment dropped. */ + redacted: string; + /** + * `Authorization` header value, when the reference carried userinfo. + * + * Present only for `http(s)` references — see the note at the point of + * construction for why userinfo cannot stay in the request URL. + */ + authorization?: string; +} + +export type ArtifactReferenceErrorCode = + | 'OS_ARTIFACT_URL_INVALID' + | 'OS_ARTIFACT_UNREACHABLE' + | 'OS_ARTIFACT_INTEGRITY_MISMATCH' + | 'OS_ARTIFACT_MALFORMED' + | 'OS_PROTOCOL_INCOMPATIBLE'; + +/** + * A refusal to boot from the referenced artifact. + * + * `message` is already scrubbed — it is safe to print, log and hand to an + * operator. Construct it through the helpers below rather than directly, so + * the scrubbing cannot be forgotten at a call site. + */ +export class ArtifactReferenceError extends Error { + override readonly name = 'ArtifactReferenceError'; + constructor( + readonly code: ArtifactReferenceErrorCode, + message: string, + readonly detail: Record = {}, + ) { + super(message); + } +} + +/** sha-256 of raw bytes, lowercase hex — the same digest `sha256sum` prints. */ +export function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * Log-safe rendering of an artifact reference. + * + * Keeps scheme, host and path — the part an operator needs to recognise which + * artifact was named — and drops the two places credentials actually live in a + * pre-signed URL: the userinfo and the query string. The fragment is dropped + * too; the pin is reported as its own field rather than inside a URL, so no + * caller has to decide which half of a URL is safe to print. + * + * Never throws: an unparsable reference cannot be selectively redacted, so it + * is replaced wholesale rather than echoed. + */ +export function redactArtifactUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + return ''; + } + const query = url.search ? '?' : ''; + if (url.protocol === 'file:') return `file://${url.pathname}${query}`; + return `${url.protocol}//${url.host}${url.pathname}${query}`; +} + +/** + * Build a scrubber that removes every credential-bearing token of `raw` from + * arbitrary text, then removes any absolute URL still standing in it. + * + * The second pass is what makes this hold against text this module did not + * write. `fetch` rejections name the URL (`TypeError: fetch failed` carries it + * on the cause; an HTTP error line built upstream carries it inline), and those + * strings are precisely what a refusal message wants to quote. Rather than + * trusting each call site to quote only safe parts, anything URL-shaped that + * survives token removal is replaced outright. + */ +export function makeArtifactUrlScrubber(raw: string): (text: string) => string { + const tokens = new Set(); + const add = (t: string | undefined | null) => { + // Short tokens would match unrelated substrings of a message; a real + // credential is never 3 characters. + if (t && t.length >= 4) tokens.add(t); + }; + add(raw); + try { + const url = new URL(raw); + add(url.username); + add(url.password); + add(url.search); + add(url.search.replace(/^\?/, '')); + for (const value of url.searchParams.values()) add(value); + const noHash = new URL(raw); + noHash.hash = ''; + add(noHash.toString()); + if (url.username !== '' || url.password !== '') { + // The derived form too: userinfo becomes `Authorization: Basic + // ` (see parseArtifactReference), and a base64 blob is a + // credential in a costume, not a redaction. + add(Buffer.from( + `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`, + ).toString('base64')); + } + // The userinfo-stripped target is deliberately NOT added as a token: + // the backstop below already replaces it, and with the *readable* + // redaction rather than a bare marker. Adding it here would trade a + // message an operator can act on for one that says only ``. + } catch { + // Unparsable: the whole string is the only token we can be sure of. + } + // Longest first, so a broad token is removed before its own substrings. + const ordered = [...tokens].sort((a, b) => b.length - a.length); + const safe = redactArtifactUrl(raw); + return (text: string): string => { + let out = text; + for (const token of ordered) out = out.split(token).join(''); + // Backstop: any absolute URL that survived is replaced wholesale. It + // can only have come from this reference (these messages describe one + // fetch), and guessing which query parameters of an unknown signing + // scheme are secret is exactly the judgement call that leaks. + return out.replace(/[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s'"`)]+/g, safe); + }; +} + +/** + * Parse `OS_ARTIFACT_URL` into its request target and optional integrity pin. + * + * Refuses, rather than degrading, on a fragment that is present but is not a + * well-formed `#sha256=` pin. A typo'd or truncated pin that silently meant + * "unverified" would be the worst possible failure mode for this feature: the + * operator believes the boot is pinned, every log line agrees that the boot + * succeeded, and nothing ever says the verification did not happen. + */ +export function parseArtifactReference(raw: string): ParsedArtifactReference { + const trimmed = raw.trim(); + if (trimmed === '') { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_URL_INVALID', + `${OS_ARTIFACT_URL_ENV} is set but empty. Unset it to boot from the local artifact, ` + + `or give it an https:// or file:// URL.`, + ); + } + + let url: URL; + try { + url = new URL(trimmed); + } catch { + // The value is not echoed: an unparsable string cannot be redacted + // field-by-field, and a mistyped pre-signed URL is still a credential. + throw new ArtifactReferenceError( + 'OS_ARTIFACT_URL_INVALID', + `${OS_ARTIFACT_URL_ENV} is not an absolute URL (value withheld — it may carry credentials). ` + + `Expected https://host/path/objectstack.json or file:///absolute/path/objectstack.json, ` + + `optionally pinned with #sha256=<64 hex chars>.`, + ); + } + + if (!(SUPPORTED_SCHEMES as readonly string[]).includes(url.protocol)) { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_URL_INVALID', + `${OS_ARTIFACT_URL_ENV} uses unsupported scheme '${url.protocol}'. ` + + `Supported schemes: ${SUPPORTED_SCHEMES.join(', ')}.`, + { scheme: url.protocol }, + ); + } + + let expectedSha256: string | null = null; + if (url.hash !== '') { + const match = SHA256_FRAGMENT.exec(url.hash); + if (!match) { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_URL_INVALID', + `${OS_ARTIFACT_URL_ENV} carries a fragment that is not an integrity pin. ` + + `The only supported fragment is '#sha256=<64 hex chars>'. ` + + `Refusing rather than booting unverified — a malformed pin that silently meant ` + + `'no verification' is the one outcome an operator can never detect.`, + { redacted: redactArtifactUrl(trimmed) }, + ); + } + expectedSha256 = match[1]!.toLowerCase(); + } + + const withoutFragment = new URL(trimmed); + withoutFragment.hash = ''; + + if (url.protocol !== 'file:' && (url.username !== '' || url.password !== '')) { + // `fetch` REFUSES a URL carrying userinfo outright — undici raises + // "Request cannot be constructed from a URL that includes credentials" + // before a single packet leaves, per the WHATWG Fetch spec. So a + // `https://user:token@host/app.json` reference is not merely + // untidy, it cannot work at all unless the credential is moved into a + // header. It is moved here, to `Authorization: Basic`, which is also + // where it belongs: a credential in the request line lands in the + // artifact host's access log, and this is the one place that can still + // decide otherwise. + const credentials = Buffer.from( + `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`, + ).toString('base64'); + withoutFragment.username = ''; + withoutFragment.password = ''; + return { + kind: 'http', + target: withoutFragment.toString(), + expectedSha256, + redacted: redactArtifactUrl(trimmed), + authorization: `Basic ${credentials}`, + }; + } + + if (url.protocol === 'file:') { + let filePath: string; + try { + filePath = fileURLToPath(withoutFragment); + } catch { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_URL_INVALID', + `${OS_ARTIFACT_URL_ENV} is a file: URL that does not name a local path ` + + `(${redactArtifactUrl(trimmed)}). Use file:///absolute/path/objectstack.json.`, + ); + } + return { + kind: 'file', + target: filePath, + expectedSha256, + redacted: redactArtifactUrl(trimmed), + }; + } + + return { + kind: 'http', + target: withoutFragment.toString(), + expectedSha256, + redacted: redactArtifactUrl(trimmed), + }; +} + +/** Where a resolved artifact's bytes came from. */ +export type ArtifactOrigin = 'remote' | 'file' | 'cache'; + +export interface ResolvedArtifactReference { + /** Local filesystem path the rest of the boot reads. Never a URL. */ + localPath: string; + /** Log-safe description of the reference, for banners and diagnostics. */ + display: string; + origin: ArtifactOrigin; + /** The pin, when the reference carried one. */ + expectedSha256: string | null; + /** Digest of the bytes actually booted — always computed. */ + sha256: string; + /** Parsed artifact, envelope already unwrapped. */ + bundle: unknown; +} + +export interface ResolveArtifactReferenceOptions { + /** ObjectStack home — the cache and staging directory live under it. */ + homeDir: string; + fetchTimeoutMs?: number; + /** Injectable for tests; defaults to the global `fetch`. */ + fetchImpl?: typeof globalThis.fetch; + /** Loud channel for the degraded (cache-fallback) path. */ + warn?: (message: string) => void; + /** Runtime protocol version to hand the handshake; defaults to this build's. */ + runtimeProtocolVersion?: string; +} + +/** `/artifacts` — verified cache and unpinned staging. */ +export function artifactCacheDir(homeDir: string): string { + return resolvePath(homeDir, 'artifacts'); +} + +/** Content-addressed cache path for a pinned artifact. */ +export function pinnedCachePath(homeDir: string, sha256: string): string { + return resolvePath(artifactCacheDir(homeDir), `sha256-${sha256.toLowerCase()}.json`); +} + +/** + * Staging path for an UNPINNED remote artifact. + * + * Keyed off the redacted reference so no credential material reaches a + * filename, and deliberately named differently from the pinned cache: the + * fallback lookup constructs only `sha256-.json` names, so a staged file + * is not reachable as a cache entry even by accident. It is overwritten on + * every boot and never read back. + */ +export function stagedArtifactPath(homeDir: string, redacted: string): string { + const key = createHash('sha256').update(redacted).digest('hex').slice(0, 32); + return resolvePath(artifactCacheDir(homeDir), `staged-${key}.json`); +} + +/** Write bytes to `target` atomically (temp file + rename). */ +function writeArtifactFile(target: string, bytes: Uint8Array): void { + mkdirSync(resolvePath(target, '..'), { recursive: true }); + const tmp = `${target}.tmp-${process.pid}`; + writeFileSync(tmp, bytes); + renameSync(tmp, target); +} + +/** + * Unwrap the `{ schemaVersion, metadata }` envelope `os build` may emit, so the + * handshake reads the same shape `loadArtifactBundle({ unwrapEnvelope: true })` + * hands the kernel. + */ +function unwrapEnvelope(parsed: any): any { + return parsed?.schemaVersion != null && parsed?.metadata !== undefined ? parsed.metadata : parsed; +} + +/** + * Validate the artifact's declared `engines.protocol` against this runtime + * (acceptance #4). + * + * `AppPlugin` runs the same handshake when it loads a bundle, and that check + * stays — but it fires during kernel Phase 1, after the datasource has + * connected, and its diagnostic names the *package*. On the artifact-pinned + * path the operator's mistake is about the *reference* ("this image cannot run + * that artifact version"), and the refusal is worth having before anything is + * connected, so it is raised here and names both. + * + * Absent and unparsable ranges are admitted, exactly as the shared handshake + * admits them — this is a safety belt against a genuine major break, never a + * new "must declare a range" requirement smuggled in at boot. + */ +export function assertArtifactProtocolCompatible( + bundle: unknown, + display: string, + runtimeProtocolVersion?: string, +): void { + const manifest = (bundle as any)?.manifest ?? bundle; + if (!manifest || typeof manifest !== 'object') return; + const result = checkProtocolCompat(manifest as any, runtimeProtocolVersion); + if (result.status !== 'incompatible') return; + throw new ArtifactReferenceError( + 'OS_PROTOCOL_INCOMPATIBLE', + `Refusing to boot ${display}: ${result.diagnostic.message} ` + + `The runtime image and the app artifact are independent release axes — ` + + `either point ${OS_ARTIFACT_URL_ENV} at an artifact built for protocol ` + + `${result.runtimeMajor}, or run a runtime image for protocol ${result.diagnostic.targetMajor ?? 'the artifact\'s'}.`, + { ...result.diagnostic }, + ); +} + +/** Read bytes for a `file:` reference. */ +async function readLocalArtifact( + ref: ParsedArtifactReference, + scrub: (t: string) => string, +): Promise { + try { + return await readFile(ref.target); + } catch (err: any) { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_UNREACHABLE', + `Cannot read the artifact named by ${OS_ARTIFACT_URL_ENV} (${ref.redacted}): ` + + `${scrub(String(err?.message ?? err))}. ` + + `Refusing to boot — a runtime told to serve a specific artifact must not invent an empty one.`, + { redacted: ref.redacted }, + ); + } +} + +/** Fetch bytes for an `http(s):` reference. Throws a scrubbed error on failure. */ +async function fetchRemoteArtifact( + ref: ParsedArtifactReference, + opts: ResolveArtifactReferenceOptions, + scrub: (t: string) => string, +): Promise { + const doFetch = opts.fetchImpl ?? globalThis.fetch; + const timeoutMs = opts.fetchTimeoutMs ?? DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await doFetch(ref.target, { + redirect: 'follow', + signal: controller.signal, + headers: { + Accept: 'application/json, text/plain;q=0.9, */*;q=0.5', + ...(ref.authorization ? { Authorization: ref.authorization } : {}), + }, + }); + if (!res.ok) { + // `res.statusText` and the status are safe; the URL is not, and is + // never interpolated here. + throw new ArtifactReferenceError( + 'OS_ARTIFACT_UNREACHABLE', + `HTTP ${res.status} ${res.statusText} fetching the artifact named by ` + + `${OS_ARTIFACT_URL_ENV} (${ref.redacted}).`, + { status: res.status, redacted: ref.redacted }, + ); + } + return new Uint8Array(await res.arrayBuffer()); + } catch (err: any) { + if (err instanceof ArtifactReferenceError) throw err; + throw new ArtifactReferenceError( + 'OS_ARTIFACT_UNREACHABLE', + `Cannot fetch the artifact named by ${OS_ARTIFACT_URL_ENV} (${ref.redacted}): ` + + `${scrub(String(err?.message ?? err))}.`, + { redacted: ref.redacted }, + ); + } finally { + clearTimeout(timer); + } +} + +/** Refusal carrying BOTH digests — the operator cannot act on "it did not match". */ +function integrityMismatch( + ref: ParsedArtifactReference, + expected: string, + actual: string, + source: string, +): ArtifactReferenceError { + return new ArtifactReferenceError( + 'OS_ARTIFACT_INTEGRITY_MISMATCH', + `Integrity check FAILED for the artifact named by ${OS_ARTIFACT_URL_ENV} (${ref.redacted}).\n` + + ` expected sha256: ${expected}\n` + + ` actual sha256: ${actual}\n` + + ` source: ${source}\n` + + `Refusing to boot. Either the artifact was republished under the same name ` + + `(pin the new digest) or the content is not what was published.`, + { expected, actual, source, redacted: ref.redacted }, + ); +} + +/** Parse the bytes, refusing loudly on anything that is not a JSON artifact. */ +function parseArtifactBytes(bytes: Uint8Array, ref: ParsedArtifactReference): unknown { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_MALFORMED', + `The artifact named by ${OS_ARTIFACT_URL_ENV} (${ref.redacted}) is not valid UTF-8 text.`, + { redacted: ref.redacted }, + ); + } + try { + return unwrapEnvelope(JSON.parse(text)); + } catch (err: any) { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_MALFORMED', + `The artifact named by ${OS_ARTIFACT_URL_ENV} (${ref.redacted}) is not valid JSON: ` + + `${String(err?.message ?? err)}.`, + { redacted: ref.redacted }, + ); + } +} + +/** + * Resolve `OS_ARTIFACT_URL` to a verified, local artifact file. + * + * The returned `localPath` is what the rest of the boot uses. For a remote + * reference that is deliberate and load-bearing twice over: it keeps the URL + * out of every downstream surface (acceptance #6), and it means the bytes that + * were hashed are the bytes that boot. Handing the URL onwards instead would + * re-fetch it later, and a pin that verifies one response while a second + * response is what actually boots verifies nothing at all. + */ +export async function resolveArtifactReference( + raw: string, + opts: ResolveArtifactReferenceOptions, +): Promise { + const ref = parseArtifactReference(raw); + const scrub = makeArtifactUrlScrubber(raw); + const warn = opts.warn ?? ((m: string) => console.warn(m)); + + // ── file: — the volume-mount workflow ──────────────────────────── + // Read in place. There is nothing to cache (the source IS local) and + // nothing to materialise, so the mounted file stays the booted file. + if (ref.kind === 'file') { + const bytes = await readLocalArtifact(ref, scrub); + const actual = sha256Hex(bytes); + if (ref.expectedSha256 && ref.expectedSha256 !== actual) { + throw integrityMismatch(ref, ref.expectedSha256, actual, ref.redacted); + } + const bundle = parseArtifactBytes(bytes, ref); + assertArtifactProtocolCompatible(bundle, ref.redacted, opts.runtimeProtocolVersion); + return { + localPath: ref.target, + display: ref.redacted, + origin: 'file', + expectedSha256: ref.expectedSha256, + sha256: actual, + bundle, + }; + } + + // ── http(s): — fetched at boot ─────────────────────────────────── + let bytes: Uint8Array; + let origin: ArtifactOrigin = 'remote'; + try { + bytes = await fetchRemoteArtifact(ref, opts, scrub); + } catch (fetchErr) { + // Acceptance #2: with no pin there is nothing to authenticate a cached + // copy with, so there is no fallback to attempt. Fail loudly and let + // container orchestration retry — a runtime that quietly serves last + // week's app because today's fetch flaked is the failure this refusal + // exists to prevent. + if (!ref.expectedSha256) throw fetchErr; + + // Acceptance #3: a pinned reference MAY fall back to a cached copy — + // but only one whose content still hashes to the pin, re-verified here + // rather than trusted because of where it sits. + const cachePath = pinnedCachePath(opts.homeDir, ref.expectedSha256); + if (!existsSync(cachePath)) throw fetchErr; + let cached: Uint8Array; + try { + cached = readFileSync(cachePath); + } catch { + throw fetchErr; + } + const cachedDigest = sha256Hex(cached); + if (cachedDigest !== ref.expectedSha256) { + // The cache is corrupt. Report the mismatch rather than the fetch + // failure: "your cache does not match the pin" is a different + // operator action from "the artifact host is down". + throw integrityMismatch(ref, ref.expectedSha256, cachedDigest, `cache ${cachePath}`); + } + warn( + `[artifact] ⚠ Could not fetch ${ref.redacted} — booting from the locally cached copy at ` + + `${cachePath}, which matches the pinned sha256 ${ref.expectedSha256}. ` + + `The artifact host is unreachable; this instance is running on cached content. ` + + `Cause: ${(fetchErr as Error)?.message ?? String(fetchErr)}`, + ); + bytes = cached; + origin = 'cache'; + } + + const actual = sha256Hex(bytes); + if (ref.expectedSha256 && ref.expectedSha256 !== actual) { + throw integrityMismatch(ref, ref.expectedSha256, actual, ref.redacted); + } + + const bundle = parseArtifactBytes(bytes, ref); + assertArtifactProtocolCompatible(bundle, ref.redacted, opts.runtimeProtocolVersion); + + // Materialise. A verified (pinned) artifact lands in the content-addressed + // cache, which is also what a later degraded boot may fall back to; an + // unpinned one lands in staging, which nothing ever reads back. + const localPath = ref.expectedSha256 + ? pinnedCachePath(opts.homeDir, ref.expectedSha256) + : stagedArtifactPath(opts.homeDir, ref.redacted); + if (origin !== 'cache') { + try { + writeArtifactFile(localPath, bytes); + } catch (err: any) { + throw new ArtifactReferenceError( + 'OS_ARTIFACT_UNREACHABLE', + `Fetched the artifact named by ${OS_ARTIFACT_URL_ENV} (${ref.redacted}) but could not ` + + `write it to ${localPath}: ${scrub(String(err?.message ?? err))}. ` + + `The boot needs a local copy so the verified bytes are the bytes that run.`, + { redacted: ref.redacted }, + ); + } + } + + return { + localPath, + display: ref.redacted, + origin, + expectedSha256: ref.expectedSha256, + sha256: actual, + bundle, + }; +} + +/** + * Read the artifact-fetch timeout from the environment. + * + * Shares `OS_ARTIFACT_FETCH_TIMEOUT_MS` with the metadata service's remote + * artifact source rather than inventing a second knob for the same concept — + * and honours only a positive numeric value, the same way that reader does. + */ +export function resolveArtifactFetchTimeoutMs( + env: Record = process.env, +): number { + const raw = Number(env.OS_ARTIFACT_FETCH_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 82e405d555..7e11452716 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -171,6 +171,32 @@ export { export { loadArtifactBundle, mergeRuntimeModule, isHttpUrl, readArtifactSource } from './load-artifact-bundle.js'; export type { LoadArtifactBundleOptions } from './load-artifact-bundle.js'; +// Artifact-pinned boot (#8368) — `OS_ARTIFACT_URL`, the SRI-style `#sha256=` +// fragment pin, and the verified local materialisation the boot reads. +export { + OS_ARTIFACT_URL_ENV, + DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS, + ArtifactReferenceError, + parseArtifactReference, + resolveArtifactReference, + resolveArtifactFetchTimeoutMs, + assertArtifactProtocolCompatible, + redactArtifactUrl, + makeArtifactUrlScrubber, + sha256Hex, + artifactCacheDir, + pinnedCachePath, + stagedArtifactPath, +} from './artifact-reference.js'; +export type { + ArtifactOrigin, + ArtifactReferenceErrorCode, + ArtifactReferenceKind, + ParsedArtifactReference, + ResolveArtifactReferenceOptions, + ResolvedArtifactReference, +} from './artifact-reference.js'; + // ── ObjectOS Cloud Runtime (artifact-fetching shared multi-tenant host) ─────── // Multi-tenant / cloud-operations code is NOT part of the framework // (ADR-0006). The MULTI-TENANT runtime — createObjectOSStack, the kernel