Skip to content

test(cli,scripts): enforce the childEnv() convention over packages/cli/test spawners - #11599

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11341-childenv-spawner-gate
Aug 24, 2026
Merged

test(cli,scripts): enforce the childEnv() convention over packages/cli/test spawners#11599
os-zhuang merged 2 commits into
mainfrom
claude/issue-11341-childenv-spawner-gate

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11341

childEnv() in packages/cli/test/helpers/serve-process.ts keeps the vitest worker's TEST / VITEST* family out of a spawned CLI child. Until now it was a convention: the next author writing an e2e test in that directory reaches for { ...process.env, ... } because that is what the neighbouring files still look like, and nothing failed when they did.

This lands the enforcement. It does not repair the existing violations — those are baselined, and burning the baseline down is follow-up work, one card per neighbourhood, tracked on #11596.

The rule, stated exactly

A finding is a bulk process.env reference in a spawner file under packages/cli/test/**.

  • Spawner file — imports a process-creating API from node:child_process (spawn, spawnSync, exec, execSync, execFile, execFileSync, fork; named, namespace or default) or Worker from node:worker_threads. Worker is on the roster with no instance in the population, deliberately: the class has to close for the next spelling, not the last one.
  • Bulk referenceprocess.env used as a whole value: a spread, an Object.assign / Object.entries source, an argument, an alias. Reading one variable off it is never flagged, so childEnv({ HOME: process.env.HOME }) stays green. Writing to it (process.env = saved) is not a copy and is not flagged either.

Both halves are load-bearing. The spawner filter is what keeps an ordinary in-process save/restore from being flagged; the bulk/member distinction is what keeps the correct repair shape green. A gate that flagged every ...process.env in the package would be carved out into uselessness.

Measured on origin/main at 2dc0a770b: 82 sources in the population, 28 of them spawners, and zero bulk references outside a spawner file. So the filter costs no recall today and its precision claim is a measurement rather than a hope.

Why the site is not anchored to the spawn CALL

The sharper-looking rule — find the env: option of a spawn call and look inside it — was written, measured against this population, and rejected. It cannot see the one site in the directory that matters most: serve-process-child-env.e2e.test.ts builds its leaked environment in leakedEnv(), hands it to probeOrigin(env), and the spawn's env is a function parameter. Chasing that needs same-file interprocedural data flow, and every hop it cannot follow is a silent zero. The file-anchored rule has no such hop.

Edges, both directions, named rather than implied

False positive, by construction: a spawner file that copies the whole environment for a reason that is not a child. Zero instances today beyond the two declared below; a new one is a DELIBERATE entry carrying its reason.

False negatives, three, each measured:

  1. A spawn with no env option at all inherits process.env verbatim — the same leak in a purer form, and this gate is silent on it. 8 such call sites exist today. Out of scope deliberately: whether a tsx-on-a-probe-script unit test owes the childEnv() convention is a question about the convention's reach, not about this defect. Filed as finding: 8 packages/cli/test spawners pass no env at all, so the child inherits the vitest worker environment verbatim — the purer form of #11341's leak, and the new gate is silent on it #11595.
  2. A bulk copy built in a non-spawner file and handed to a spawner. Contrived rather than accidental, but it is a hole and it is named.
  3. A bulk copy laundered through something no scan can see as process.env.

The two registries, and why they are different kinds

DELIBERATE (in the gate) is a declaration registry — the sites that copy the whole environment on purpose, pinned in both directions. A stale entry fails. That is the carve-out discipline the choke point documents, expressed where a machine can hold it: serve-process-child-env.e2e.test.ts's header says "Do not clean it up ... that would delete the only evidence in the repo that the leak does anything", and a header is a request. Two entries today:

sitewhy
helpers/serve-process.ts::childEnvthe choke point itself — it reads the whole environment in order to strip the runner family, so a bulk read here is the repair
serve-process-child-env.e2e.test.ts::leakedEnv#11267's pin leg, the pre-fix recipe kept executable so the repair stays distinguishable from a no-op

scripts/cli-test-child-env.baseline.json is a ratchet — 18 latent leaks across 17 files, as per-file counts rather than a path list (a path list silences the whole file, so a new leak rides an existing entry in silence — the #4251 lesson the slot-lookup ratchet paid for). Shrink-only in both dimensions, closed to new entries, and a ceiling left above reality fails as stale.

Population coverage: the two files #11441 names

#11441 reports that the hand-built worklist preceding this gate was two files short. A gate has no worklist, so both are ordinary members of the enumerated population, and both are in the baseline:

packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts:160 [runServeFrom]
packages/cli/test/serve-host-fallback-base.e2e.test.ts:187 [(top-level)]

Two self-test cases pin that by name, because "the derivation covers them" is exactly the kind of claim that rots silently. #11441 itself stays open for triage to grade — nothing here changes its state. #11464 is a different defect and is out of scope here; it stays open too.

generate-skill.e2e.test.ts and generate-agent-retired.e2e.test.ts are baselined and untouched — they belong to #11071's neighbourhood, in flight in a parallel seat this same round.

Anti-vacuity: the self-test can fail, shown four ways

54 cases. The positive control runs out of process through the real entry point, because "exits non-zero" is the claim and no process can observe its own exit status:

  • a spawner with a bare spread exits non-zero and names the site;
  • the childEnv() form through the same entry point exits zero;
  • a legitimate non-spawner bulk copy stays green, and the same body reds once the file spawns — so the filter is demonstrably what made it green;
  • every member-read spelling stays green; prose and string payloads are not code;
  • the carve-out is site-scoped: the same file reds for a bulk copy in a different function;
  • the ratchet fails in both directions, and a missing DELIBERATE site fails;
  • all four refusals (missing population root, no sources, zero spawners, unreadable source) are each paired with a tree that still returns a verdict, so "refuses unconditionally" cannot satisfy the battery.

Ablation against the live tree, each leg confirmed on disk before the reading was taken and restored under a trap:

mutationgate
fresh violation in a clean spawner fileexit=1 — 1 file builds a child's environment from the whole of process.env
second violation added to an already-baselined fileexit=1 — 2 bulk reference(s), ceiling 1
a baselined violation repairedexit=1 — 1 stale baseline entry
the DELIBERATE pin "cleaned up"exit=1 — 1 DELIBERATE site no longer present

The tree was restored and the gate returned to green after each.

No changeset

Nothing published changes. The diff is scripts/**, .github/workflows/lint.yml and the root package.json (private, @objectstack/spec-monorepo) — no workspace package source, no packages/cli/test file. skip-changeset applies.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (19 families) at acf5f14e, and the whole union re-run on that head after the last commit, plus check:nul-bytes. All green, each quoted from the gate's own verdict line:

✓ check:cli-test-child-env: 28 spawner source(s) among 82 under packages/cli/test/**;
no new bulk process.env copy reaches a spawned child (18 baselined in 17 file(s),
SHRINK-ONLY; 2 deliberate site(s) still pinned).
✓ check-cli-test-child-env self-test: 54 cases pass
OK check-ratchet-remedy-authority: 126 scripts swept; 8 mark the expanding remedy
MAINTAINER-ONLY, 5 turn it down outright. Control corpus: 22 hand-classified
scripts, set-equality audited both ways.
✓ check:parse-guard: 140 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
✓ check:entry-guard: 141 scripts/ file(s) — every entry guard goes through invoked-as.mjs.
OK: 16 package(s) read outside themselves, all declared (cross-package-test-inputs)
✓ check-required-contexts: 6 required context name(s) pinned across 2 workflow(s)
✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s)
✓ check-step-collectors: 348 `run:` steps across 26 workflow(s)
check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 319.6s,
1897 raw tsc error(s) total, none above its recorded number.
✓ check-nul-bytes --self-test: 75 assertions over a temp git repo

check:type-check-debt first refused rather than failing (--re-measure cannot run: 56 workspace dependencies have no built type entry point on disk) — a property of a fresh worktree, not of this diff. Re-run to a real verdict after turbo run build --filter='./packages/*' --filter='./packages/*/*'; the line above is that run.

Farm-wide pnpm lint (eslint . --no-inline-config) also run in full rather than narrowed: VERDICT command-exit 0 · held the lock 79s.

The gate itself registers in check-ratchet-remedy-authority's control corpus as refused — the sweep classified it that way on its own before the entry was written, which is the classification being confirmed rather than asserted.


Generated by Claude Code

…t spawners
`childEnv()` (packages/cli/test/helpers/serve-process.ts) is the choke point
that keeps the vitest worker's `TEST`/`VITEST*` family out of a spawned CLI
child. It has been a CONVENTION only: the next author writing an e2e test in
that directory reaches for `{ ...process.env, ... }` because that is what the
neighbouring files still look like, and nothing fails when they do.
Twice measured, in two subsystems, one week apart: an inherited `TEST=true`
switched better-auth's origin/CSRF validation off inside a spawned `os serve`
(#11267), and an inherited `VITEST` put the settings crypto provider in its
never-refuses posture (#11352). Neither showed up as a red test -- the failure
mode is a security assertion that cannot go red for the reason it exists.
check-cli-test-child-env.mjs makes the convention an invariant. A finding is a
BULK `process.env` reference (used as a whole value, as opposed to reading one
variable off it) in a SPAWNER file (one that imports a process-creating API).
Both halves are load-bearing: the spawner filter is what keeps an ordinary
in-process save/restore from being flagged, and the bulk/member distinction is
what keeps `childEnv({ HOME: process.env.HOME })` green.
The 18 leaks that predate the gate are baselined shrink-only; the ratchet
refuses new members outright and fails on a stale ceiling. The two sites that
copy the environment ON PURPOSE -- the choke point, and #11267's pin leg that
proves the leak does something -- are DELIBERATE entries pinned in BOTH
directions, so "cleaning up" that evidence now fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
… and its ratchet
The baseline had no owner named anywhere in the tree, so a reader hitting a
stale-ceiling failure had nothing to follow. #11596 tracks the burn-down;
#11595 is the sibling gap this gate deliberately does not cover (a spawn
passing no env option at all).
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

ci/cddependenciesPull requests that update a dependency filesize/xlskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: nothing stops the next packages/cli/test spawner from re-introducing the bare ...process.env child env

2 participants

@os-zhuang@claude