Skip to content

test(cli): refuse a built-entrypoint spawn whose child resolves commands from src/ - #12558

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-11464-run-js-node-env-guard
Aug 26, 2026
Merged

test(cli): refuse a built-entrypoint spawn whose child resolves commands from src/#12558
os-litant merged 2 commits into
mainfrom
claude/issue-11464-run-js-node-env-guard

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #11464

A third, negative rule in scripts/check-cli-test-child-env.mjs: a packages/cli/test spawn of the built entrypoint bin/run.js may not hand its child a NODE_ENV that sends @oclif/core's command lookup back to src/. Guard only — no baseline, no repairs, and no test file is touched.

The census, re-derived — not taken from the card and not from the claim

The card body says the only remaining bin/run.js spawners are invocation-loudness.e2e.test.ts and serve-node-env-production-default.e2e.test.ts. The claim comment corrects that to six, naming helpers/serve-process.ts and login-json-noninteractive.e2e.test.ts. Both figures are wrong, in different directions. Re-derived AST-first against origin/main @ 1a6855226, by resolving every argv binding rather than grepping for the path:

file names bin/run.js SPAWNS it
serve-mcp-capability-collision.e2e.test.ts yes yes
serve-mcp-stdio-answers.e2e.test.ts yes yes
serve-node-env-production-default.e2e.test.ts yes yes
serve-stdio-stdout-purity.e2e.test.ts yes yes
helpers/serve-process.ts in comments only (43, 311) no — CLI is ../../bin/run-dev.js, spawned through tsx
login-json-noninteractive.e2e.test.ts in one comment (32) no — CLI is ../bin/run-dev.js, spawned through tsx
invocation-loudness.e2e.test.ts BIN bound at 52 no — used only at expect(...).toContain(BIN) (133)
plugin-commands.test.ts string assertion on pkg.bin no
serve-organizations-host-resolution.e2e.test.ts in a comment no

Four spawners, not six and not two. The gate reports the same four independently, by different code over the same tree: 4 spawn(s) of the built CLI.

Two consequences worth stating, because both were load-bearing in the dispatch:

The live violation count: 2, in one unfenced file

All four spawns are written childEnv({ ..., NODE_ENV: undefined, ...env }), where env is the enclosing helper's parameter. Resolved one hop to what those helpers are actually called with:

serve-mcp-capability-collision     boot({ OS_DATABASE_URL })                       clean
serve-mcp-stdio-answers            boot({ OS_DATABASE_URL }) x2                    clean
serve-stdio-stdout-purity          boot({ OS_DATABASE_URL }) x2                    clean
serve-node-env-production-default  probeOriginCheck({})                            clean
                                   probeOriginCheck({ NODE_ENV: 'development' })   :400  VIOLATION
                                   probeOriginCheck({ NODE_ENV: 'test' })          :410  VIOLATION

Both are irreducible, which is why they are declared rather than repaired. That file varies the child's NODE_ENV as its independent variable and asserts what serve's trusted-origin gate does under each value. The two set legs cannot be written any other way: keeping bin/run.js reroutes to src/ by the very mechanism this rule refuses; switching them to bin/run-dev.js pins development before argv is parsed, which makes the test leg unreachable and which that file's own header forbids for the unset leg; and setting NODE_ENV after oclif has resolved the command is not reachable from outside the child. So they are two DELIBERATE_REROUTE entries carrying that reason, keyed per it() block and pinned in both directions — a stale entry fails.

⚠️ A decision is open on top of this and is reported to the PM rather than taken here. What the entries do not license is that file's silence: its header argues at length that only the shipped, built entrypoint reaches the gate, and never says that two of its three legs therefore execute src/commands. That is comment truthfulness, the same class as #12498 one file over — not something a registry entry fixes, and not this card's declared surface.

Why the rule is written negative

Per the claim's ruling, and following the shape of the rule #12530 landed in this same file: the rule states the property that must hold, never the helper that must be called. A built-entrypoint spawn's child must be readably outside development/test. NODE_ENV: undefined is what the population happens to write; production is equally green; a plain object literal that says so is green with no helper involved at all — pinned by a case. A rule spelled "must call childEnv({ NODE_ENV: undefined })" would stop meaning anything the day someone wraps the choke point.

Silence reds too, and that is the card's headline. vitest exports NODE_ENV=test on its worker and childEnv() deliberately does not strip it, so a spawner that names bin/run.js and simply says nothing about NODE_ENV lands in the rerouted leg by inheritance. The failure does not require anyone to set the variable on purpose. Rule 2 is already satisfied by a declared env there, so this had to be its own rule.

The mechanism, from @oclif/core@4.13.3lib/util/util.js:66 defines isProd() as a negated membership test over ['development', 'test'], and the TypeScript path lookup is skipped only when that is true:

child NODE_ENV resolved commandsDir
unset packages/cli/dist/commands
production packages/cli/dist/commands
development packages/cli/src/commands
test packages/cli/src/commands

Why this rule follows one hop where rule 1 refuses to

Rule 1 is file-anchored because it cannot chase a value built three functions away, and its header says so. Rule 3 cannot make that trade: every built-entrypoint spawn in the population puts the overriding spread after the declaration, so without the hop all four are unreadable and the rule reports four findings that say only "this scan gave up" — a gate resolving to zero readable sites inside its own population, which is the vacuity this file refuses everywhere else. So the spread resolves one hop, same file, and the finding is reported at the caller, where the value was chosen.

One hop, with every limit named in the header rather than left to be discovered: a caller that forwards its own parameter, a caller argument that is not an object literal, a caller literal carrying its own spread, and a helper with no same-file call sites are each reported as unreadable — never as green.

One correction the battery forced, worth naming: a helper call with no readable overrides (childEnv() bare) was first classified as "inherits". That is an assumption about how a specific helper behaves — knowledge this rule deliberately refuses to have — so it now reports as unreadable instead. Both are findings; only one of them is honest about what was measured.

Proving the guard can say no

Two ablation legs on serve-node-env-production-default.e2e.test.ts, each with a restore trap on absolute paths, each confirmed on disk before the gate was run, each restored and proven by blob hash rather than by an exit code. No build and no dist/ is involved — this gate reads source — so there is no rebuild leg to report.

HEAD blob 1f0d3957e34c608ab1293b53045d684b5af84b42.

Leg A — plant the explicit pair. The unset leg rewritten to probeOriginCheck({ NODE_ENV: 'development' }). Mutation confirmed on disk by counting both texts: injected 2, probeOriginCheck({}) 0. Blob moved to adfee3d0. Gate exits 1:

❌  check:cli-test-child-env -- 1 spawn(s) of the BUILT CLI let the child resolve commands from src/:
  packages/cli/test/serve-node-env-production-default.e2e.test.ts:390
    [it("NODE_ENV unset: the localhost trusted-origin convenience gate is CLOSED (regression pin)")]
    spawn  probeOriginCheck({ NODE_ENV: 'development' })
    the child is handed a NODE_ENV of development or test, so oclif resolves commands
    from src/ -- which cancels the bin/run.js spawn

Leg B — the inheritance case, which is the one that matters. The explicit NODE_ENV: undefined deleted. Mutation confirmed on disk: occurrence count 1 to 0. Blob moved to 3881b036. Gate exits 1, and names the inherited reason:

  packages/cli/test/serve-node-env-production-default.e2e.test.ts:389  [it("NODE_ENV unset: ...")]
    the child's NODE_ENV is not declared at this call, so the vitest worker's
    NODE_ENV=test reaches it and oclif resolves commands from src/

Note that leg B reports one finding and not three: the two declared legs stay declared, which is the site-scoped registry working rather than a file-wide mute.

Restore, proven both times: working blob back to 1f0d3957e34c608ab1293b53045d684b5af84b42 — non-empty on both sides and equal — plus git diff HEAD over the target empty, and the gate green again through the same entry point.

The durable version of both legs is in the self-test, out of process, where an exit status can actually be observed.

Site keying, re-read against current main

Per the claim's last ruling: enclosingFunctionName / callbackSiteName were re-read at f0c9ffabe. Both violation sites are plain it('title', async () => ...) — an identifier callee with a string-literal title — which is exactly the shape #12531 made work, so both key distinctly and neither reads (top-level).

⚠️ The coupling, reported rather than assumed: the two DELIBERATE_REROUTE keys are produced by that same helper, so they contain the it() titles verbatim. The open item on #12545 concerns it.skip / it.each / describe.each, whose callees are property accesses or calls — a different branch of the walk, which neither site touches. Should that work nonetheless change how a plain it() is named, these two keys move and the gate says so loudly (a stale entry fails) rather than going quiet. No rule message here depends on the part #12545 is about to change.

Self-test: 92 to 132 cases, none weakened

Ruling 3 was extend-never-weaken. Every removed line in the diff is a signature or a destructuring plumbing line; no pre-existing case was deleted, relaxed, or re-scoped. The 40 added cases split into membership, the rule, the hop, the registry, independence, and two out-of-process legs — including, as its own case, that a file which merely names bin/run.js stays out of the population, which is the census error this card was dispatched with.

Live-tree anti-vacuity pins were added to match rule 2's: the population count is asserted rather than merely printed (an empty finding list reads identically whether every spawn is sound or the entrypoint resolution stopped resolving anything), and the four-file population is pinned as a set so a file joining or leaving has to be seen by a PR.

Verification — all at deba323a4, the final commit, on a clean tree

TREE_AT=deba323a4 dirty=[]

check result
pnpm lint (eslint . --no-inline-config, whole repo, not narrowed) run through the shared verify lock: VERDICT command-exit 0 · held 57s · waited 177s
pnpm check:cli-test-child-env self-test 132 cases pass; live tree green
check:agent-test-spelling · check:bash32-floor · check:cli-command-ids · check:cross-package-test-inputs · check:entry-guard · check:parse-guard · check:pnpm-filter-targets · check:nul-bytes · check-ci-filter-parity.mjs · check-cross-package-test-inputs.mjs all exit 0
scripts/pm/bare-root-worklist.mjs --self-test · pnpm check:pm-dispatch-gates exit 0 — the two convention-triggered families a gate-script edit incurs, which no path derivation names

Gate list derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack against the real change set (1 path), not from a hand-written list. Exit codes were captured before any pipe.

⚠️ typecheck is reported as NOT APPLICABLE rather than as a green. The only edited file is scripts/check-cli-test-child-env.mjs, a .mjs module. The root tsconfig.json sets no allowJs, and tsc --listFiles over the root program returns 0 hits for that filename — so no tsc program reads it, and any "typecheck passed" line would be true and empty.

No changeset

One repo gate script. No test file and no published source is touched, so this PR releases nothing — skip-changeset.


Generated by Claude Code

claude added 2 commits August 26, 2026 10:21
…nds from src/

Adds a third, NEGATIVE rule to check-cli-test-child-env: a packages/cli/test
spawn of bin/run.js may not hand its child a NODE_ENV of development or test,
which is what makes @oclif/core reroute the command lookup from dist/ to src/.
Silence reds too -- vitest exports NODE_ENV=test on its worker and childEnv()
deliberately does not strip it, so the pair forms by inheritance.

Self-test 92 -> 132 cases; no existing case weakened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review August 26, 2026 10:49
@os-litant
os-litant added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 1b7e3d2 Aug 26, 2026
34 checks passed
@os-litant
os-litant deleted the claude/issue-11464-run-js-node-env-guard branch August 26, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR 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 a packages/cli test from spawning bin/run.js and a ts-path-enabling NODE_ENV at once — the pair silently cancels

2 participants