Uh oh!
There was an error while loading. Please reload this page.
feat(cli,runtime): artifact-pinned boot — OS_ARTIFACT_URL with an SRI-style fragment pin (#8368) - #8526
Conversation
Boot a stack from a published artifact by reference — one env var, with the optional integrity pin SRI-style inside the URL fragment (no companion OS_ARTIFACT_SHA256). Fetched (https) or read directly (file), verified, protocol-checked and materialised locally before the boot continues. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
…d boot (#8368) Unit coverage for reference parsing, the fragment pin, integrity verification, the pinned-only cache fallback, the protocol handshake and secrets redaction; a fake-driver suite for the boot migration policy; and an end-to-end suite that boots `os serve` in an empty directory with no project checkout. Two assertions are written against a specific way of being wrong: the unpinned fetch-failure case plants a usable cache first (so "no cache-fallback logic" can actually fail), and every credential-absence assertion carries a positive control proving the captured text is the text that would have carried it. `refusalOf()` replaces `.catch((e) => e as ArtifactReferenceError)` so a call that wrongly succeeds fails as itself rather than on a missing property — which also keeps the runtime TEST_DEBT ledger at its recorded 227 instead of raising it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 32 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8368
Boot a stack from a published artifact by reference: a fixed runtime image
plus one environment variable is a running app, and upgrading the app is an env
change plus a restart rather than an image rebuild.
One variable, not two
The issue title says
OS_ARTIFACT_URL (+ OS_ARTIFACT_SHA256); the issuebody says there is deliberately no separate
OS_ARTIFACT_SHA256. The bodywas implemented. Only one environment variable exists, and the integrity pin is
SRI-style inside the URL fragment.
The fragment placement is mechanical, not stylistic: 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 a byte of
what the artifact host sees. A second variable would make "URL updated, hash
not" a reachable state. This shape makes it unspellable.
Nothing found while reading contradicts the body, so this was not escalated.
Where the code landed
The dispatch keyed serialization on
packages/cli/src/commands/serve.ts, andthat file is touched — but the mechanism itself landed in
packages/runtime:packages/runtime/src/artifact-reference.tspackages/cli/src/utils/artifact-boot-migration.tspackages/cli/src/commands/serve.tspackages/cli/src/commands/start.tspackages/cli/src/utils/schema-migrate.tsFetching and booting an artifact is open-framework mechanism, so it belongs in
the framework rather than in the CLI. No entitlement check was added — walled
tenancy postures stay entitled through
@objectstack/organizationsregardlessof how the artifact arrives.
Precedence, and why it beats OS_ARTIFACT_PATH
--artifact>OS_ARTIFACT_URL>OS_ARTIFACT_PATH>dist/objectstack.jsonBeating
OS_ARTIFACT_PATHis not a nicety, it is what makes acceptance #1reachable at all: the official runtime image sets
ENV OS_ARTIFACT_PATH=/srv/app/objectstack.json, so on a container carrying noapp that variable is always set and always points at a file that does not
exist. Without this precedence the feature would refuse with "the artifact
named by OS_ARTIFACT_PATH does not exist" exactly where it was designed to be
used. The e2e suite sets that variable on every child specifically to hold this.
The reference also wins over an
objectstack.config.tsin the workingdirectory: naming a published artifact is an explicit instruction, and a
deployed app must not depend on which directory the process happens to stand in.
Acceptance criteria
artifact-pinned-boot.e2e.test.ts— realos servechild in a provably empty dirartifact-reference.test.ts— refusal asserted with a usable cache plantedartifact-reference.test.ts+ e2eengines.protocolvalidated, refuses loudlyartifact-reference.test.ts+ e2eartifact-boot-migration.test.tsA correction to the card on #4
engines.protocolwas already enforced for artifact boots —AppPlugin.initruns
assertProtocolCompaton every bundle it loads. So "an incompatibleartifact fails the boot" was true before this change, and a test asserting only
a non-zero exit would have proved nothing about this PR. What is new is where
the refusal happens: at reference resolution, before the artifact boot is even
announced and before any datasource connects, naming the reference rather than
the package. The e2e assertion is written to require that placement, and
Ablation C below confirms it discriminates.
An unplanned finding: fetch refuses userinfo
fetchwill not construct a request from a URL carrying userinfo — undiciraises "Request cannot be constructed from a URL that includes credentials"
before a packet leaves. A
https://user:token@host/app.jsonreference wastherefore unusable. The credential is now moved into an
Authorization: Basicheader, which is also where it belongs: a credential in the request line lands
in the artifact host's access log, and this is the last point that can decide
otherwise. Found by the secrets test failing on its positive control.
Secrets discipline (#6)
Two structural defences, because a rule that depends on every future call site
remembering to redact is a rule that leaks:
local file under
home/artifactsand the boot continues against that path,so the reference reaches neither the banner, nor
MetadataPlugin, nor themetadata service's artifact-source record, nor any log line. This 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 (asserted).
fetch— which routinely carry the whole URL, and which is the classic leak: a
refusal that helpfully prints the pre-signed URL. Known credential tokens are
removed and then any surviving absolute URL is replaced, so a leak needs a
new carrier rather than just a new call site.
Every absence assertion is paired with a positive control asserting the same
captured text contains the host and path of the same URL — otherwise
not.toContain(secret)would pass against an empty capture or a code path thatnever ran.
Reverse verification
Predictions were written down before any ablation ran
(three ablations, each re-applied to a committed tree and reverted from the
commit afterwards).
Ablation A — delete the integrity check on the remote path
refuses a file:// mismatch tooexpect(code).not.toBe(0)The last row is a genuine miss, recorded rather than smoothed over. With
verification removed the mismatched artifact simply boots successfully, so
the failure surfaces at the exit code before the positive control is reached.
The directional claim (the secret assertions are not what carries this test)
held; the specific prediction did not.
Ablation B — give the unpinned path a cache fallback
Predicted exactly one red:
fails the boot loudly ... EVEN THOUGH a usable cached copy exists. Measured: exactly one red.A first, crude version of this ablation produced three reds — two of them
artifacts of the sloppy ablation rather than evidence about the tests. It was
redone faithfully (a URL-keyed "remember the last good copy" fallback, the
plausible wrong implementation), and the test's plant was improved to write
both places such an implementation would look.
Second-order measurement, to check the claim that this test is sharp rather
than merely red: with Ablation B still in place, the planted cache was deleted.
The test went green again — confirming it discriminates because of the
plant, not because the network was cut. That is the difference between this
test and one that cannot fail.
Ablation C — make the protocol refusal a no-op
expect(code).not.toBe(0)toContain('Cannot boot from OS_ARTIFACT_URL')This is the ablation that matters most, and it confirms the e2e protocol test
measures this PR's contribution rather than riding on the pre-existing handshake.
Verification
pnpm --filter @objectstack/runtime test— 152 files, 2357 tests, all passpnpm --filter @objectstack/cli test— 119 files, 1294 tests, all passpnpm --filter @objectstack/runtime typecheck,pnpm --filter @objectstack/cli typecheck— cleancheck:type-check-debt(re-measure OK, 33 entries, none above its recorded number),
check:type-check-coverage,check:nul-bytes,check:error-code-casing,check:startup-registry-verdict,check:cross-package-test-inputs,check:query-options-erasure,check:changeset-gate-self-tests,check:doc-anchors,check:doc-authoring,check:adr-anchors,check:empty-changeset,check:objectui-changeset,check:release-notes,check:published-files— all pass. ESLint clean on every changed file.check:type-check-debtdid go red mid-way: the new runtime test file added 29raw tsc errors to
TEST_DEBT. The ledger was not raised. All 29 came fromone pattern —
.catch((e) => e as ArtifactReferenceError)types asError | Resolved— replaced by arefusalOf()helper that also makes thetests stricter (a call that wrongly succeeds now fails as itself). Re-measured
back to exactly the recorded 227.
Notes
registered in
ERROR_CODE_LEDGER: every refusal here happens before the HTTPserver binds, so none can reach a response envelope, and registering one would
create exactly the unemittable row the ledger calls a defect.
kernel:ready(Phase 3) — after every plugin'sstart(), so schema sync has run, and before Phase 4 opens the socket. "Refuseto boot" is literal: the port never binds. It is scoped to the artifact-pinned
path; every other boot keeps the standing production policy untouched.
needs_confirmdrift is applied alongsidesafe, matchingos migrate apply's own boundary. Re-deriving that boundary here would be asecond opinion about which changes are dangerous.
OS_PACKAGE_REF/ registryresolution / signature enforcement / entitlements; multi-tenant fleet and
hostname routing; cloud-side adoption (objectstack-ai/cloud#1292 — this card
is the upstream and does not wait on it).
serve.ts's cloud-connected marketplace arm wasread and deliberately left alone so that card stays independently reviewable.
Generated by Claude Code