Skip to content

test(cli): read the bound port back in the two direct-spawn serve e2e files - #12565

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12548-child-env-port-readback
Aug 26, 2026
Merged

test(cli): read the bound port back in the two direct-spawn serve e2e files#12565
os-litant merged 1 commit into
mainfrom
claude/issue-12548-child-env-port-readback

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12548

Two e2e files in packages/cli/test spawn os servedirectly — so #12525's read-back,
which lives inside runServe(), never reaches them. Both now read the port the child
actually bound out of its own ready banner and refuse a mismatch, using the helpers
PR #12546 landed. ⛔ No second implementation, ⛔ no change to os serve's auto-shift rule
(#11113 pins the production half; dev auto-shift is correct product behaviour), ⛔ neither
file routed through runServe().

⚠️ The two files are not equally exposed, and the difference is measured, not assumed.


1. serve-process-child-env.e2e.test.ts — the priority file

It is the only file in this directory where a drifted port lets a foreign process answer a
SECURITY assertion
. probeOrigin() POSTs to /api/v1/auth/sign-in/email with an untrusted
Origin and asserts the origin-check posture — 403 INVALID_ORIGIN against
401 INVALID_EMAIL_OR_PASSWORD. Until this PR that request went to the port the harness
asked for. Its children spawn through bin/run-dev.js, which pins NODE_ENV=development
before argv is parsed, so serve.ts's portAutoShiftAllowed is TRUE: a taken port is not an
error, it is a hop to the next free one, and the boot then SUCCEEDS.

Reproduced end to end on this container, against the real os serve

An HTTP server was made to hold the requested port and answer every request
401 {"code":"INVALID_EMAIL_OR_PASSWORD", …}:

impostor holds port 39883
OLD gate settled : true
child ACTUALLY bound: {"state":"bound","port":39884}
OLD harness answer: 401 {"code":"INVALID_EMAIL_OR_PASSWORD","iAm":"A NEIGHBOURING AGENT DEV SERVER, not os serve"}
⇒ the pre-#11267 leg asserts status !== 403 and code === INVALID_EMAIL_OR_PASSWORD: IT PASSES — GREEN, from the IMPOSTOR
NEW harness refusal: PORT DRIFT on `os serve (probe)`: this harness asked for port 39883 and the child BOUND port 39884 — read back from the child's own ready banner.

⭐ Note which leg goes green: the pre-#11267 control leg, whose whole job is to prove the
TEST leak accepts an untrusted origin, is satisfied by a stranger. That is a false green
on the assertion this file exists to make.

The gate settles on having SEEN the port, not on a ready marker

The old gate was /Press Ctrl\+C to stop|Server is ready/. Server is ready is the banner's
HEAD, printed one console.error ahead of the API: row that carries the port, while
boundPortFromBanner() keys on the TAIL — so a head-keyed gate can resolve with the port
still unknown, portDriftError() then answers null, and the comparison compares nothing.
Whether it did would depend on how the pipe happened to chunk. So the gate is now
readyVerdict(), which returns { settled: false } for no-banner and only ever hands back
a port the child itself named; the fetch addresses that port, not the requested one (they
are provably the same number on that line — a mismatch was refused before reaching it).

A cheap unit pin holds the gate itself: readyVerdict('\n ✓ Server is ready\n', …) must be
{ settled: false }.

Proof it can say no — and that the proof is load-bearing

A third real boot was bought deliberately: holdPort() holds the requested port, the child
drifts, and the harness is asserted to reject with PORT DRIFT. ⛔ Nothing is simulated.

Ablation, to show that arm is not decoration — the refusal was disabled in place
(portDriftError(output, WHAT, requestedPort)… , undefined), which the helper documents
as "no expectation stated, no verdict to give"):

HEAD blob: c0fe962b681bfc38a173d84bb6c5f7efd95d81f3
before: deleted-text hits = 1, injected-text hits = 0
after : deleted-text hits = 0, injected-text hits = 1
mutated blob: 85f1e0eab6fbb047da7f4b3f6404ea8c82cba39b
MUTATION CONFIRMED ON DISK
ablated vitest exit=1
× … > THE LOAD-BEARING ARM: a child that DRIFTS off a held port is REFUSED, not probed 8138ms
Test Files 1 failed (1)
Tests 1 failed | 7 passed (8)
--- restore proof ---
restored blob: c0fe962b681bfc38a173d84bb6c5f7efd95d81f3
RESTORE CONFIRMED — bytes identical to HEAD

Predicted direction before running: RED, on exactly one test. Observed: exactly that.
⚠️ No rebuild leg is reported because none exists here: vitest loads the mutated file from
source and bin/run-dev.js runs the CLI from src/ through tsx, so nothing in the mutation's
resolution path goes via a dist/. The mutation is evidenced by blob hash instead, in both
directions. (A first attempt at this ablation exited 127 — a bad runner path, not a red gate;
it was re-run through pnpm exec vitest and only that second run is reported.)


2. serve-app-anchored-optional-import.e2e.test.ts — the sibling, and a measured correction

Same blind direct spawn; the read-back is wired in beside the existing portContentionError().
⚠️ But the card's "lower exposure" is, measured on 52a982388, nil — and the PR says so
rather than letting a silent call read as an instrument:

  • The fixture driver only console.errors its marker and registers nothing, so serve walks
    on to ✗ Cluster driver "redis" is not registered and the child exits 1 at ~5.6s, having
    never called listen()
    . The undeclared leg dies earlier still.
  • ⇒ no child this file spawns ever prints a ready banner, boundPortFromBanner() answers
    no-banner on all three runs, and portDriftError() returns null. The same measurement
    retires the existing portContentionError() to insurance: these children spawn through
    bin/run-dev.js, so auto-shift is open and a taken port never produces the bind failure that
    helper reads for.
  • ⇒ this file's assertions all read the child's own output and it issues no HTTP request,
    so a drift cannot corrupt them.

⛔ It is deliberately not given the priority file's gate: no child here prints a banner, so
gating resolution on one would hang out the timeout. Both refusals stay as insurance for the
day this fixture registers a real driver and boots to a banner. The file's header now carries
that measurement so the next drift sweep does not re-file it.

⚠️Fork, reported rather than picked silently — see the report to the PM seat: whether that
inert port apparatus should stay, or whether the fixture should be made to reach a listening
server, is a separate call and a separate card.


Verification — everything below ran at 177c0d1db, which is this branch's head

pnpm --filter '@objectstack/cli...' build VERDICT command-exit 0 · held the lock 303s
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 \
test/serve-process-child-env.e2e.test.ts \
test/serve-app-anchored-optional-import.e2e.test.ts \
test/serve-port-readback.e2e.test.ts Test Files 3 passed (3) · Tests 27 passed (27)

serve-port-readback.e2e.test.ts is included as the control on the helpers being consumed.
Every heavy run went through scripts/pm/os-verify-lock.sh; the verdicts above are the lock's
own VERDICT lines, not a bare $?.

Gates, each read from the gate's own verdict line (all exit 0):

gateverdict
check:nul-bytesOK (scanned 6925 text file(s) … no raw ASCII control bytes)
check:cli-test-child-env35 spawner source(s) among 97 … all 41 spawn call(s) declare their child's env
check:engine-double-contractOK — 416 pinned, 133 in the DEBT ledger, 2 exempt
check:where-matcher303 matcher(s) discovered, 303 answer … none new
check:query-options-erasureratchet holds: 67 unswept non-test site(s) … none new
check:cross-package-test-inputsOK: 20 package(s) read outside themselves, all declared
check:test-source-aliasOK — 72 packages with tests scanned

The family list was re-derived from the real change set, not from the dispatch:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → exit 0, 18 matched
families over the 2 changed paths.

Type reading.⚠️packages/cli's typecheck is tsc --noEmit over a tsconfig whose
include is ["src"], so it covers none of packages/cli/test and is not claimed as
coverage here. The two changed files were compiled directly against that same config with
include widened to them: exit 0, and membership proved with --listFiles (240 files in
the program; both changed files and test/helpers/serve-process.ts present by absolute path).

Lint — a declared narrowing, with its evidence.pnpm lint (eslint . --no-inline-config)
is a repo-wide scan CI owns; it was narrowed here to the changed files, and the narrowing is a
measurement rather than a skip on three counts:

  1. The population is the change set read from git diff --name-only 52a982388 — 2 paths — not
    a guess about what matters.
  2. The count comes from ESLint's own --format json output: 2 files linted, 0 errors,
    0 warnings
    , exit 0. Neither file was ignored by the config.
  3. Untouched files are invariant under this diff because eslint.config.mjsnever enables
    type-aware linting for any file
    (no parserOptions.project, no typed
    @typescript-eslint rules) — its own header states and measures this — so no file's verdict
    can depend on another file's contents.

Test-only diff (2 files under packages/cli/test/, no package source, no user-visible change)
skip-changeset.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 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 dd4fc6c9d8f29b822e64a254b8943f66edbfc467packageMentionDocs.

@os-litantos-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 11:14
@os-litant
os-litant enabled auto-merge August 26, 2026 11:28
@os-litant
os-litant added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 9afc5d8Aug 26, 2026
37 checks passed
@os-litant
os-litant deleted the claude/issue-12548-child-env-port-readback branch August 26, 2026 11:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

1 participant

@os-litant