Uh oh!
There was an error while loading. Please reload this page.
fix(service-settings): select crypto posture from the deployment signal, never the test runner - #11448
Conversation
…al only
`detectMode` read `env.VITEST` — a test-RUNNER variable — as a vote for
`'test'` posture. `'test'` is the branch that takes an ephemeral key, never
touches disk, and never refuses to boot, so a runner variable decided whether
the fail-loud production gate ran at all. Runner variables are inherited by
every process the runner spawns, so a real `os serve` spawned from a vitest
worker with `{ ...process.env }` booted with production auth and test crypto.
The read is deleted rather than narrowed. Its documented purpose — in-process
unit tests get test posture — is preserved, because vitest sets BOTH variables
on the same worker (vitest 4.1.10, `prepareVitest()`:
`process.env.VITEST = "true"; process.env.NODE_ENV ??= "test";`, repeated as
`NODE_ENV: process.env.NODE_ENV || "test"` in each worker's env). In-process the
two spellings are indistinguishable; they differ only for an INHERITING child,
which is the defect.
`crypto-posture-deployment-signal.test.ts` pins both halves against a COPY of
the real worker environment rather than a hand-written fixture, so it pins
whatever the runner actually exports.
Part of #11352
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4Closes the class the crypto-posture defect belonged to rather than only its one member. `check:runner-env-posture` scans every `src` tree under packages/, apps/ and examples/ and refuses `TEST`, `VITEST`/`VITEST_*` and `JEST_WORKER_ID`. `NODE_ENV` is deliberately not banned: it describes the DEPLOYMENT, and a deployment may declare itself a test deployment — a runner may not declare it on the deployment's behalf. Two variables, two subsystems, one week, the same shape: `TEST` leaking into better-auth's origin check, `VITEST` leaking into crypto posture. Nothing mechanical stops the third. Comments and string/template/regex literals are masked through the shared `js-comment-mask.mjs`, so the fixed file's header can keep QUOTING the banned line — deleting the explanation is how a defect like this comes back. The bracket pass exists because the self-test caught its absence: with literals masked, `env['VITEST']` vanished and the gate reported a confident zero about the first spelling an author would reach for if the dotted one were rejected. An array literal naming the family (what the code that STRIPS these variables must do) is still not a read. Verified both directions on real source, not only on self-test fixtures: restoring the pre-fix line to `local-crypto-provider.ts` makes the gate exit 1 naming `local-crypto-provider.ts:186 VITEST` — one finding, and none of the six prose mentions of `VITEST` in that file's own header. Part of #11352 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
…vMap
`pnpm --filter @objectstack/service-settings typecheck` reported
TS2339 "Property 'VITEST' does not exist on type '{ NODE_ENV: string; }'":
spreading the `EnvMap`-typed base into an unannotated object literal dropped
the index signature, so the anti-vacuity assertion that the leak is still in
the map did not compile. Annotating the literal keeps it.
Also adds the changeset for the behaviour change.
Part of #11352
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d9789803b6557bd91960ff49f6e18fa29492a803 && git checkout d9789803b6557bd91960ff49f6e18fa29492a803
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e63348c984a77b6a2463e95d428e7db78406fc2e 1b86bcb713a6d1c8546a981cc8cfeaefefa0f7b8 && git checkout -B drift-repro e63348c984a77b6a2463e95d428e7db78406fc2e && git merge --no-ff 1b86bcb713a6d1c8546a981cc8cfeaefefa0f7b8
node scripts/docs-audit/affected-docs.mjs --json e63348c984a77b6a2463e95d428e7db78406fc2e |
`bare-root-worklist --self-test` reddened on this branch: the new gate's SCANNED_ROOTS holds three bare single-segment words, so it joined the invisible bare-root species unjudged. That meta-gate is asking exactly the question this card is about — a guard that quietly stops guarding — so the verdict is recorded rather than the question silenced. REFUSE-UNSPELLABLE on all three roots, measured, not estimated: packages 1757 of 5049 (35%) examples 150 of 240 (63%) apps 0 of 35 (0%) Unspellable rather than merely wide, and the distinction is the `src` SEGMENT: the true population is `packages/**/src/**`, and `collapseHint` reduces that to `packages`. So the only declaration the idiom can express also claims every manifest, changelog, fixture and the 2658 test files this gate deliberately skips. Its nearest neighbour `check:authz-resolver` is REFUSE-WIDE at a similar 39% because ITS population really is every non-test source under the root; this one is not. The apps leg is the load-bearing one: at 0 of 35 a subtree declaration would not be imprecise but FALSE — pasting this gate into every apps card to reach nothing. `apps` stays in SCANNED_ROOTS deliberately so an apps package that grows a src tree is covered the day it lands. Data only: pure insertion, no logic in the meta-gate touched, and `check:runner-env-posture` itself is unchanged — it is the deliverable, not the thing to narrow. node scripts/pm/bare-root-worklist.mjs --self-test OK self-test: 37 live row(s), 34 unreachable as spelled, 34 recorded verdict(s) — none stale, none missing. Part of #11352 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11352
The defect
packages/services/service-settings/src/local-crypto-provider.tsselected its crypto posture like this:'test'there is not a softer flavour of'production'. It is the branch that takes an ephemeral key, never touches disk, and never refuses to boot. The refusal is the gate — so that one line let a test-runner variable decide whether a security gate ran at all.Runner variables are inherited. Vitest sets
TEST,VITEST,VITEST_MODE,VITEST_WORKER_IDandVITEST_POOL_IDon its worker, and every process that worker spawns with{ ...process.env }receives them. So anos serveboot that merely happened to haveVITESTin its environment silently took an ephemeral key, wrote nothing to disk, and stopped refusing to boot without a stable key.Zone 2 — the dispatch's three assumptions, measured
(1)
detectModeis the only posture-selection site — CONFIRMED, whole-tree. EveryVITESToccurrence in tracked files (git grep -n VITEST -- . ':!**/dist/**' ':!pnpm-lock.yaml') is one of: this file (2 — one code, one prose),packages/cli/test/**(the harness that strips the family),examples/app-showcase/test/**(same),scripts/check-test-source-alias.mjs(a config-filename list). Widened toVITEST_WORKER_ID|VITEST_POOL_ID|JEST_WORKER_ID|npm_lifecycle_eventand to a bareenv.TESTacrosspackages/,apps/,examples/excluding tests: one product site, this one. Nothing else needed changing, so nothing else was changed.(2) The deployment signal is
NODE_ENV— CONFIRMED, and it is the repo's only one.packages/metadata-protocol/src/seed-loader.tsstates it in prose ("NODE_ENVis this repo's ONE established environment source"), Prime Directive #9 lists it as a third-party exception, anddiscovery.zod.ts/seed-loader.tsboth fold it. NoOS_*deployment switch exists. Re-derived onorigin/main: since #11113 / PR #11268,serve.tsitself defaultsprocess.env.NODE_ENVto'production'when unset, and its comment names "service-settings' crypto-key mode" as one of the gates that must observe that default — this change is what makes that sentence true.(3) "There may be no reproducible spawn-leak path left today" — FALSIFIED.
packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts:160spawns a realos servewith a bare...process.env, deliberately bypassingchildEnv()(its header explains why: the helper always setscwd).serve-host-fallback-base.e2e.test.ts:187does the same for a probe script. Neither is named in #11341's enumerated worklist — both landed after it was measured. Filed as #11441 (sub-issue of #11341); not fixed here, per the dispatch.The fix
The
VITESTread is deleted, not narrowed.Its documented purpose — in-process unit tests get test posture, so they neither mint a key file in
$HOMEnor fail on a machine without one — is preserved rather than dropped, because vitest sets both variables on the same worker. Measured in vitest 4.1.10's own source,prepareVitest():and repeated in the env handed to each worker:
NODE_ENV: process.env.NODE_ENV || "test". An in-process test therefore already satisfiesNODE_ENV === 'test'and lands on the first line withoutVITESTparticipating at all.In-process the two spellings are indistinguishable. They differ only for an inheriting child — which is precisely the defect.
Clause ② — this changes accept/reject, in the tightening direction
A process that boots with
NODE_ENV=production, noOS_SECRET_KEY/OS_DEV_CRYPTO_KEY, no persisted key file, noOS_CRYPTO_AUTOKEY— and a runner variable in its environment — now refuses to start where it previously booted on an ephemeral key.needs:contract-reviewis on the PR and it stays draft.Measured — before
Provider-level posture matrix, real construction, no mocks (
node --import tsx, each case an explicit env map,OS_HOMEat an empty temp dir):Line 2 is the card: the same deployment, the same missing key, and the refusal is simply gone.
…and end to end, on a real
os servebootNot only at the provider. The same pair, driven through the shipped entrypoint (
packages/cli/bin/run.js, soserve.tscomes fromdist) againstexamples/app-crm, child envNODE_ENV=production+VITEST/TEST/VITEST_WORKER_ID/VITEST_POOL_ID/VITEST_MODEpresent, every key variable unset,HOMEandOS_HOMEat an empty temp dir:Both legs were rebuilt, because a spawned server resolves
@objectstack/service-settingsthroughexportsto itsdist— an unrebuilt ablation here stays silently green. Each leg's artifact was verified on disk withnode scripts/ablation-dist-preflight.mjs @objectstack/service-settings 'env.VITEST'(--absenton the fix and restore legs): marker present in 2 built files on the BEFORE leg, marker absent from all 6 built files on the AFTER and restore legs. Source restored byte-identical (git hash-object7525e844eb32c6163c53f29d6f00286269a9d4e8 both sides).bin/run-dev.js, which hard-setsprocess.env.NODE_ENV = 'development'on line 27 — so both legs booted in development posture and the "after" leg printed "generated a new AES-256-GCM key and persisted it" instead of refusing. The deployment signal under test had been overwritten by the entrypoint. Redone throughbin/run.jswithpackages/clibuilt, which is the run quoted above.Measured — after: the refusal is back, and in-process posture did not move
packages/services/service-settings/src/crypto-posture-deployment-signal.test.ts(22 cases) pins both halves against a copy of the real worker environment —{ ...process.env }, runner variables and all — rather than a hand-written fixture, because that copy is literally what a spawned child receives. One deliberate mutation per case:NODE_ENV=production+ the worker's own env (VITEST present) ⇒ throws/Refusing to start in production/, and no key file is minted.keySource === 'ephemeral', disk untouched — in-process posture intact, carried byNODE_ENV=test.TEST·VITEST·VITEST_WORKER_ID·VITEST_POOL_ID·VITEST_MODE·JEST_WORKER_ID×production·development·test: none of the 18 combinations moves the answer.Ablation — prediction written before the run, signature matched exactly
Subject resolution first: the pin imports
./local-crypto-provider.js, a relative specifier, so vitest resolves the sibling.tssource and no rebuild leg is required for this ablation (@objectstack/service-settingsappears 0 times in the file).Predicted, in writing, before running: 3 red — "a deployment in production posture REFUSES…" (expected function to throw), "NODE_ENV=production is unchanged by VITEST" (
booted:ephemeralvsrefused:…), "NODE_ENV=development is unchanged by VITEST" (booted:ephemeralvsbooted:generated-file); everything else green, including the whole table for the five non-VITESTtokens.Observed:
On-disk confirmation in both directions with
grep -Fon literals, and restore proven byte-identical:mutant=1baseline is not contamination: the new header quotes the removed line, andgrep -Fmatches substrings. The deltas are what carry the signal (+1/−1 on the mutant, −1/+1 on the clean), and the gate below proves the quote is not read as code.The guard — closing the class, not the instance
scripts/check-runner-env-posture.mjs/pnpm check:runner-env-posture, wired intolint.yml. It refusesTEST,VITEST/VITEST_*andJEST_WORKER_IDanywhere in product source (everysrctree underpackages/,apps/,examples/; test files and test directories excluded, where naming the family is the job).NODE_ENVis deliberately not banned. That is the whole rule: a deployment may declare itself a test deployment; a runner may not declare it on the deployment's behalf.Comments and string/template/regex literals are masked through the shared
js-comment-mask.mjs, so the fixed file's header can keep quoting the banned line — deleting the explanation is how a defect like this comes back.Reverse control, on real source rather than self-test fixtures — a zero-hit is not evidence on its own:
One finding, and none of the six prose mentions of
VITESTin that same file's header.The bracket pass exists because the self-test caught its absence: with literals masked,
env['VITEST']vanished entirely and the gate reported a confident zero about the first spelling an author would reach for if the dotted one were rejected. An array literal naming the family — what the code that strips these variables must write — is still not a read. 34 self-test cases, including the wiring pins, so unwiring the gate reddens here rather than going quiet.Docs
No hand-written doc enumerates how crypto posture is selected, so nothing became false. Checked rather than assumed:
content/docs/protocol/kernel/config-resolution.mdxandpackages/services/service-settings/README.mdboth describe the gate asNODE_ENV=productionand stay accurate; a whole-treegit grep VITESTreturns no.md/.mdxhit at all. What did enumerate it is the module header ("test/VITEST→ ephemeral"), and that is repaired in this PR — docs-drift's symbol-anchor green is not evidence either way here.Verification
Gate union derived with
node scripts/pm/dispatch-gates.mjs, no hand-supplied paths, re-derived after the changeset existed. 30 gates, every one to a real verdict, exit captured before any pipe — all 0, includingcheck:cross-package-test-inputs,check:entry-guard,check:parse-guard,check:test-source-alias,check:type-check-coverage,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:published-files,check:nul-bytes,docs-audit/check-affected-docs, and the newcheck:runner-env-posture(which discovered its own wiring).pnpm --filter @objectstack/service-settings typecheck— green (it caught a real TS2339 in the new pin first, fixed inc3b2e7a).ESLint, narrowed and declared:
0 errors, 2 warningsover the 5 changed files, the 2 being "ignored file" notices forlint.ymlandpackage.json. The narrowing is a measurement, not a skip — (i) scope read from eslint's own config via--print-configper file, (ii) file count read from--format json(results.length === 5), (iii)eslint.config.mjsenables no type-aware linting anywhere (parserOptions.project/projectServiceoccurrences: 0, and the config says so in prose at line 326), so this diff cannot move any untouched file's verdict.check:type-check-debt --re-measurewas not run locally: it needs the whole workspace built and re-runstscper ledger entry.service-settingsappears in neither theDEBTnor theTEST_DEBTledger — it is one of the 65 fully type-checked packages — so this diff has no ledger entry to move, and the structural half (check:type-check-coverage, the half a new test file moves) ran green. CI runs the farm regardless.Green-gate union re-run at
c3b2e7a5d3— the final commit on this branch, tree clean — so the 30/30 above is a reading of the tree this PR actually proposes, not of an earlier one.Generated by Claude Code