Skip to content

test(cli): stop spawned os serve children inheriting the vitest worker's TEST - #11340

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11267-vitest-test-env-leak
Aug 23, 2026
Merged

test(cli): stop spawned os serve children inheriting the vitest worker's TEST#11340
os-zhuang merged 2 commits into
mainfrom
claude/issue-11267-vitest-test-env-leak

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11267

What leaked, and what it switched off

Any packages/cli e2e test that spawns a real os serve and builds the child's
environment with { ...process.env, … } carries the vitest worker's own
environment into the child. Vitest sets TEST=true on that worker
unconditionally, independent of NODE_ENV — measured here, in this repo's
runner: TEST="true" VITEST="true" VITEST_WORKER_ID="0" VITEST_POOL_ID="1" VITEST_MODE="RUN" NODE_ENV="test".

better-auth 1.7.1 reads TEST directly, at the installed version:

// @better-auth/core/dist/env/env-impl.mjs:36constisTest=()=>nodeENV==="test"||toBoolean(env.TEST);// better-auth/dist/context/create-context.mjs:210skipOriginCheck: options.advanced?.disableOriginCheck!==void0
? options.advanced.disableOriginCheck
: isTest() ? true : false,

So the spawned child had better-auth's own origin/CSRF validation switched off
entirely — one layer below anything serve.ts or plugin-auth decide, and
independent of whatever NODE_ENV the test set on the child.

Proving it mattered — the part that is actually the work

Unsetting a variable is trivial; a fix nobody can distinguish from a no-op is
worthless. So the claim is measured on a real boot, same fixture, same code,
the env family the only difference. Probe: POST /api/v1/auth/sign-in/email
carrying Origin: https://evil.example.com, no cookie, no Sec-Fetch-* — the
shape validateFormCsrf forces an origin check for. The origin is not
localhost, so it is untrusted under every branch of serve.ts's
trusted-origin assembly, including the isDevhttp://localhost:* convenience
that bin/run-dev.js always turns on.

child envanswer
{ ...process.env, … } — the shape in this directory today401 INVALID_EMAIL_OR_PASSWORD — origin ACCEPTED, validation never ran
the vitest family stripped403 INVALID_ORIGIN — validation ran and rejected
only TEST stripped, VITEST* left in place403 INVALID_ORIGIN

The third row is the isolation for that probe: TEST alone is what
better-auth reads.

⚠️ Correction — VITEST is not hygiene either, and CI proved it

The first revision of this PR claimed the VITEST* entries were stripped as
hygiene because "nothing in os serve reads them". That was false. CI found
the counterexample, and it is recorded here rather than quietly patched over:

// packages/services/service-settings/src/local-crypto-provider.ts:133constdetectMode=(env: EnvMap): CryptoMode=>{if(env.VITEST||env.NODE_ENV==='test')return'test';if(env.NODE_ENV==='production')return'production';return'development';};

While a spawned child still inherited VITEST=true, its crypto layer sat in
test mode — ephemeral key, never touches disk, never refuses — whatever
posture the rest of the boot was in. So serve-node-env-production-default,
whose entire subject is that an unset NODE_ENV means production, was
production for auth and test for crypto. The moment it stopped leaking, it
refused to boot without a stable key. That red is the gate working, and it
is the same defect class as the TEST leak one gate over: a security-relevant
gate — here, stable encryption-key enforcement — softened by a variable
inherited from the test runner rather than by anything the code under test
decided.

The fix is to supply what production posture demands, exactly as that fixture
already supplies OS_AUTH_SECRET for the sibling gate — never to put VITEST
back. E2E_SECRET_KEY is now a runServe() default and is set explicitly by
the spawners that roll their own env.

The failure was also order-dependent, which is worse than red. Development
mode persists a minted key to $HOME/.objectstack/dev-crypto-key. Measured:
with that file absent the production boot refuses; with it present — written by
any earlier dev-mode boot in the same run — the same boot succeeds. Under
vitest's parallel workers that ordering is not deterministic. The explicit key
removes both halves: no child writes a key file, and no child depends on one.
Verified with a pristine HOME: 11 files, 36 tests green, no key file
created
.

Both of the first two rows are now committed as real boots in
serve-process-child-env.e2e.test.ts, so the repair stays distinguishable from
a no-op on every CI pass rather than only in this description. The leg that
builds the env the old way is deliberately a bare ...process.env spread and is
marked ⛔ do-not-clean-up in the file header; it doubles as the canary on the
dependency.

The fix

helpers/serve-process.ts exports childEnv(overrides): this process's
environment minus the vitest worker family, plus the overrides. The strip is a
class, not a list — TEST exactly, plus anything matching VITEST/VITEST_*
— so a runner variable added to that namespace tomorrow is caught without anyone
rediscovering the trap first. VITEST_WORKER_ENV_KEYS names the five vitest 4
exports today and is what the pin asserts against. Overrides are applied after
the strip, so a test that genuinely wants one of these set can still say so.

runServe() builds through it, and so do the four spawners in this directory
that roll their own child env — including #11268's pin, which had to unset
TEST by hand right at the spawn site. That hand-written line is gone; the
comment explaining why stayed.

NODE_ENV is deliberately not in the family. The worker exports
NODE_ENV=test too, but every spawner here already pins the child's NODE_ENV
explicitly, so stripping it would change which entrypoint those tests resolve
through rather than remove a leak. That is #11317, and it is not touched here.

Per-file: which spawners were exposed, and which were not

runServe() covers 8 importers; 3 more os serve spawners in the same
directory rolled their own env. All 11 are listed — an honest "unaffected" is
worth more than an inflated count.

filereaches better-auth?beforeafter
helpers/serve-process.ts— (the harness)bare ...process.envbuilds through childEnv()
serve-process-child-env.e2e.test.tsyes — this is the pindid not existnew: 4 structural + 2 real boots
serve-mcp-capability-collision.e2e.test.tsyes — signs in for real to mint a keyleakedchildEnv(); still green
serve-mcp-stdio-answers.e2e.test.tsyes — same sign-inleakedchildEnv(); still green
serve-stdio-stdout-purity.e2e.test.tsyes — same sign-inleakedchildEnv(); still green
serve-node-env-production-default.e2e.test.tsyes — origin-check pinalready unset TEST by handchildEnv(); hand-written line removed
serve-app-runtime-hooks.e2e.test.tsno — asserts onEnable ranleakedfixed via helper; no behaviour claim
serve-boot-diagnostics.e2e.test.tsno — asserts a boot WARNleakedfixed via helper; no behaviour claim
serve-no-artifact.e2e.test.tsno — asserts boot diagnosticsleakedfixed via helper; no behaviour claim
serve-organizations-host-resolution.e2e.test.tsno — asserts host resolutionleakedfixed via helper; no behaviour claim
serve-organizations-mount-failure.e2e.test.tsno — asserts mount refusalleakedfixed via helper; no behaviour claim
artifact-pinned-boot.e2e.test.tsno — OS_MIGRATE_AND_EXIT, never servesleakedchildEnv(); hygiene only

The three sign-in spawners are the interesting row, and they stay green
not by luck, and worth stating because the naive reading says enabling origin
validation should have broken them. origin-check.mjs's validateFormCsrf
returns without validating anything when the request has no cookie, no
Sec-Fetch-*, and no origin/referer header — which is exactly the shape of
a bare Node fetch(). So those three exercise better-auth's sign-in handler for
real while never reaching the origin branch. They were exposed to the leak and
are fixed by this PR; they simply never made an assertion the leak could
falsify.

Reverse-verified

The strip line in childEnv() was ablated (if (isVitestWorkerKey(key)) continue; replaced by a marker; both directions confirmed on disk by
grep -c, restored under an EXIT trap). Predicted direction before running:
3 red / 3 green. Observed exactly that — the two structural assertions, and the
behaviour leg flipping 403 → 401, i.e. the untrusted origin accepted again:

× drops every variable in the vitest worker family
AssertionError: childEnv() still carries TEST: expected true to be false
× drops the whole VITEST namespace, not just the five names known today
AssertionError: expected [ 'TEST', 'VITEST', …(3) ] to deeply equal []
× through childEnv(): the untrusted origin is REJECTED
AssertionError: expected 401 to be 403
Tests 3 failed | 3 passed (6)

No rebuild step is involved in that ablation and none is owed: the helper is a
relative intra-package import (./helpers/serve-process.js), resolved from
source by vitest, never through a package exports field into dist/. Restore
was confirmed on disk and re-run green (6/6).

Verification

Run at d0a619c4, the final commit.

  • pnpm --filter @objectstack/cli typecheck — OK
  • All 11 files that can observe the change — 36 tests, all green, re-run with HOME pointed at an empty directory so a clean runner's missing ~/.objectstack/dev-crypto-key is reproduced rather than papered over by this container's own
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, clean
  • Derived at the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack; all green, including check:type-check-debt --re-measure against a fully built closure: "33 ledger entries re-measured in 240.2s, 1897 raw tsc errors total, none above its recorded number."

No changeset — skip-changeset

@objectstack/cli is publishable, but its files whitelist is
["dist","README.md","CHANGELOG.md"] and this diff is 100% under
packages/cli/test/. Not one published byte changes, so the PR releases
nothing and has nothing to describe in release notes.

Generated by Claude Code


Generated by Claude Code

…ker's TEST
Any `packages/cli` e2e test that spawns a real `os serve` and builds the
child env with `{ ...process.env, … }` carries the vitest WORKER's
environment into the child. Vitest sets `TEST=true` on that worker
unconditionally, independent of `NODE_ENV`, and better-auth 1.7.1 reads
`TEST` directly:
@better-auth/core/dist/env/env-impl.mjs:36
const isTest = () => nodeENV === "test" || toBoolean(env.TEST);
better-auth/dist/context/create-context.mjs:210
skipOriginCheck: … isTest() ? true : false,
so the child had better-auth's own origin/CSRF validation switched OFF,
one layer below anything `serve.ts` or `plugin-auth` decide.
`helpers/serve-process.ts` now exports `childEnv()`, which drops `TEST`
and the whole `VITEST*` namespace before the caller's overrides go on,
and `runServe()` builds through it. The four `os serve` spawners in this
directory that roll their own child env are swept onto it too, including
#11268's pin, which had to unset `TEST` by hand.
Measured, same fixture and same probe, the env family the only
difference — POST /api/v1/auth/sign-in/email with
`Origin: https://evil.example.com`:
{ ...process.env, … } 401 INVALID_EMAIL_OR_PASSWORD (origin ACCEPTED)
childEnv({ … }) 403 INVALID_ORIGIN
only TEST stripped 403 INVALID_ORIGIN
`TEST` alone is load-bearing; the `VITEST*` entries are hygiene. Both
rows are pinned as real boots in `serve-process-child-env.e2e.test.ts`,
so the repair stays distinguishable from a no-op.
Part of #11267
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 23, 2026
@github-actions

github-actionsBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via auth.login (sdk))
  • content/docs/api/index.mdx(via /api/v1/auth/sign-in/email (route))
  • content/docs/getting-started/your-first-project.mdx(via /api/v1/auth/sign-in/email (route))
  • content/docs/permissions/authentication.mdx(via auth.login (sdk), /api/v1/auth/sign-in/email (route))
What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d39569239b46f6ef68e8af438a6e78ccb62d6c92packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8d1d74c476da30dedce01cce13a4fcacfc5bf755 — the merge of head d0a619c458a2af44f00bf740b4b564bd6f8f5fe9 into base d39569239b46f6ef68e8af438a6e78ccb62d6c92, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8d1d74c476da30dedce01cce13a4fcacfc5bf755 && git checkout 8d1d74c476da30dedce01cce13a4fcacfc5bf755
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d39569239b46f6ef68e8af438a6e78ccb62d6c92 d0a619c458a2af44f00bf740b4b564bd6f8f5fe9 && git checkout -B drift-repro d39569239b46f6ef68e8af438a6e78ccb62d6c92 && git merge --no-ff d0a619c458a2af44f00bf740b4b564bd6f8f5fe9
node scripts/docs-audit/affected-docs.mjs --json d39569239b46f6ef68e8af438a6e78ccb62d6c92

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d39569239b46f6ef68e8af438a6e78ccb62d6c92 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…EST no longer leaks
CI caught a claim in the previous commit that was simply false. Its
header said the `VITEST*` entries were stripped "as hygiene — nothing in
`os serve` reads them today". Something does:
packages/services/service-settings/src/local-crypto-provider.ts:133
const detectMode = (env: EnvMap): CryptoMode => {
if (env.VITEST || env.NODE_ENV === 'test') return 'test';
if (env.NODE_ENV === 'production') return 'production';
return 'development';
};
While a spawned child still inherited `VITEST=true`, its crypto layer
sat in `test` mode — ephemeral key, never touches disk, never refuses —
whatever posture the rest of the boot was in. So the
`serve-node-env-production-default` pin, whose entire subject is that an
unset `NODE_ENV` means PRODUCTION, was production for auth and test for
crypto. Stopping the leak made it production for both, and it refused to
boot without a stable key. That red is the gate working.
Same defect class as the `TEST` leak one gate over: a security-relevant
gate softened by a variable inherited from the test runner rather than
by anything the code under test decided. So the fix is to supply what
production posture demands, exactly as that fixture already supplies
`OS_AUTH_SECRET` for the sibling gate — never to put `VITEST` back.
Measured, single variable, same tree and same build, `HOME` pointed at
an empty directory to match a clean runner:
previous commit 1 failed | 2 passed
with only that file's edit reverted 3 passed
and the failure was ORDER-DEPENDENT, which is worse than red: with
`$HOME/.objectstack/dev-crypto-key` absent the production boot refuses,
and with it present — written by any earlier dev-mode boot in the same
run, since development mode persists a minted key — the same boot
succeeds. Under vitest's parallel workers that ordering is not
deterministic.
`E2E_SECRET_KEY` is now a `runServe()` default and is set explicitly by
the spawners that roll their own env, which removes both halves: no
child writes a key file, and no child depends on one. Verified with a
pristine `HOME`: 11 files, 36 tests, green, and no key file created.
Part of #11267
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

e2e tests that spawn a real os serve under vitest inherit TEST=true, which makes better-auth silently skip origin/CSRF validation regardless of NODE_ENV

2 participants

@os-zhuang@claude