Skip to content

fix(service-print): bind the local placement's browser-server control socket to 127.0.0.1 - #24155

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23968-print-loopback-host
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23968-print-loopback-host

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23968
Clause-②: no

What

The print service's local placement (OS_PRINT_CHROMIUM_PATH / chromiumPath) starts a playwright-core browser server for every render and every boot probe, then connects to it. Its chromium.launchServer call passed no host, so the control socket's bind rested on playwright-core's default (localhost in the pinned 1.63.0). It now passes host: '127.0.0.1', the hardening PR #23811's ACCEPT set as its condition.

  • packages/services/service-print/src/chromium-driver.ts
    • launchServer({ executablePath, headless: true, timeout, host: LOCAL_CONTROL_HOST }), where LOCAL_CONTROL_HOST = '127.0.0.1' is a module-private constant (not exported).
    • The ChromiumLauncher type declares host: string on launchServer's options. It is required, so dropping the option from the call is also a type error (measured below: TS2345).
    • The header comment's local bullet now says the control socket is bound to IPv4 loopback by this package and why, and that a host without that address cannot open it.
  • One patch changeset for @objectstack/service-print.

Not touched: the port, the remote placement, the plugin's options and environment variables, the exports, the playwright-core version.

Premise, checked on origin/main 747c8e9c48 before any edit

  • chromium-driver.ts:220 called launchServer({ executablePath: …, headless: true, timeout: timeoutMs }) with no host, and the launcher type at :105 had no host field.
  • playwright-core is pinned exact at 1.63.0. In the installed copy, lib/coreBundle.js:10175-10177 reads listen(port = 0, hostname, defaultPath) { … hostname ??= "localhost", and :57206-57208 shows launchServer calling server.listen(options.port, options.host).
  • :10181-10193: wsEndpoint() is built from server.address(), the bound socket's own address, so it names where the socket really listens.
  • :8978-8986 (computeAllowedHosts): an explicit 127.0.0.1 keeps the Host and Origin allow-list on, with the same loopback set localhost got.

Pins

  1. Test Core (chromium-driver.unit.test.ts, no browser). stagedLauncher now also returns its launchServer mock. A new test runs the boot probe and then a render, and asserts the host option of each launchServer call equals ['127.0.0.1', '127.0.0.1'].
  2. Real Chromium (chromium-driver.test.ts, inside the existing describe.skipIf(!CHROMIUM) block). A local-placement test drives the shipped driver through a launcher that wraps the real playwright-core chromium and records each started server's wsEndpoint(). It asserts that endpoint's hostname is 127.0.0.1. Where no Chromium is installed it skips with the file's existing warning, as before.
    • Chromium was present in this run: /opt/pw-browsers/chromium, found through PLAYWRIGHT_BROWSERS_PATH. The test ran and passed.

Ablation

The fix was committed first. Each mutation went through scripts/ablation-replace.mjs, which checks the anchor count on disk, inside a trap that restores from HEAD. Each restore was proven by blob: after restore, the blob was 84907ccbc2e2, equal to the HEAD blob, and git diff HEAD was empty.

  • A: drop the option. The anchor host: LOCAL_CONTROL_HOST, went from 1 hit to 0 on disk, blob 84907ccbc2e2 to f79108c782c5.
    • The unit pin went red: expected [ undefined, undefined ] to deeply equal [ '127.0.0.1', '127.0.0.1' ].
    • The real-Chromium pin stayed green. That was expected: this host's /etc/hosts maps localhost to 127.0.0.1 only, so the library default lands on the same address. The real pin checks where the socket is bound, and the unit pin is the one that holds the explicit option.
    • tsc (same mutation, separate run): error TS2345: Argument of type '{ executablePath: string; headless: true; timeout: number; }' is not assignable to parameter of type '{ executablePath: string; headless: boolean; timeout: number; host: string; }'.
  • B: rebind to another loopback address. '127.0.0.1' became '127.0.0.2', to show the real pin reads the bound address. The real-Chromium pin went red: expected '127.0.0.2' to be '127.0.0.1'.

A host with no IPv4 loopback (card item 5)

Measured, by a stand-in. The kernel answers EADDRNOTAVAIL when a bind address is not assigned to any local interface. A host with no IPv4 loopback gives that answer for 127.0.0.1. I used an address this host does not have, 192.0.2.1 (TEST-NET-1), in place of the missing 127.0.0.1, with the real Chromium. No network configuration was changed.

  • chromium.launchServer({ …, host: '192.0.2.1' }) rejects with listen EADDRNOTAVAIL: address not available 192.0.2.1. It does not fall back to any other address.
  • The shipped driver's probe, the path PrintServicePlugin.start() takes, throws Chromium could not be reached at the local executable /opt/pw-browsers/chromium: listen EADDRNOTAVAIL: address not available 192.0.2.1.
  • The rest is read from code, not measured: start() logs that reason as printing is off until this is fixed — … (print-service-plugin.ts:94-97) and status() answers unavailable. A print is refused with SERVICE_UNAVAILABLE / 503 once its re-probe fails (print-service.ts:250-276).
  • A limit, measured. playwright-core launches Chromium before it binds the socket (coreBundle.js:57186-57207), and a failed bind does not close that browser.
    • 1.5 s after the rejection, 6 processes were still descended from the measuring node process, and the process did not exit on its own.
    • timeout stopped it at 120 s. playwright's own SIGTERM handler then cleaned up the browsers, and no orphan was left.
    • In a long-running server on such a host, each failed open would therefore leave one Chromium running: one for the boot probe, and one for each print's re-probe.
    • The same was true before this change on any host where binding localhost fails. This PR does not change that library behaviour.

Stated limits, not measured here:

  • A real host without 127.0.0.1. I tried to set one up by deleting the address inside a private network namespace, and this session's permission classifier refused that step.
  • A host whose localhost resolves to ::1 first. This kernel has no IPv6: binding ::1 answers EAFNOSUPPORT.
    • Node's listen binds the first address a name resolves to, so the old default would have bound [::1] on such a host. The socket moves to 127.0.0.1 there.
    • The driver still connects, because it dials whatever wsEndpoint() names.
  • How rare such a host is. Linux assigns 127.0.0.1/8 to a loopback device when it comes up (net/ipv4/devinet.c; a fresh network namespace here bound 127.0.0.1 too). So a host lacks it only when someone removed it.

Tests (at the final head 8ba6a17be0)

  • pnpm --filter @objectstack/service-print build && … typecheck && … exec vitest run --reporter=verbose exited 0.
    • check-dts-emitted: @objectstack/service-print - 2/2 declared declaration file(s) present.
    • 6 test files, 81 tests passed, including both new pins.
  • node -e "require('…/service-print/dist/index.cjs')" loads, with 7 exports.

Gates (local, at 8ba6a17be0)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands. I ran all of them sequentially and recorded each exit code before any pipe.
    • 62 exited 0.
    • pnpm check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET: it reads every package's dist/, and only this package's closure is built here. That makes it NOT MEASURED locally. Lint & Repo Gates runs it in CI. The require() of this package's CJS build above is the direct reading for the one package touched.
  • --ran reconciliation: 63 derived famil(ies) accounted for — 62 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3), 0 unrun.
  • The 4 roster gates whose rosters sit under these paths were also run, each with exit 0: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity.
  • CI's check-runs are not in yet as this PR opens; the os-dev report on the card carries them.

Acceptance notes

  • playwright-core leaves a launched Chromium running when its bind fails (measured above). This is library behaviour, it already existed before this PR, and it is reachable only on a host where the bind fails. No public-door reach was measured on a real deployment, so it is noted here and not filed. Carrier: none.
  • "Module-private". The claim called ChromiumLauncher module-private. Strictly, the module exports it as a type (export type { ChromiumLauncher, … }, used by the two test files). It is not re-exported from src/index.ts, and it is absent from dist/index.d.ts and dist/index.d.cts (0 hits; the control, PrintServicePlugin, has 5). So making host required changes no published type, and Clause-②: no holds.

Generated by Claude Code

…0.0.1

The local placement's chromium.launchServer call passed no host, so its
control socket's bind followed playwright-core's default (localhost).
Pass host: '127.0.0.1' explicitly, declare host on the module-private
launcher type, and say so in the driver header.

Pins: the staged launcher asserts launchServer receives host 127.0.0.1
on the boot probe and on a render; the real-Chromium suite asserts the
started server's wsEndpoint names 127.0.0.1.

Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 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 6dede7a51acd11539a50a2427f14bd6ac360627a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fb5fb3adf9a73332eb99ae157dca0a98bfcfa1be — the merge of head 8ba6a17be0875524dbab9f32185f13d8789e056a into base 6dede7a51acd11539a50a2427f14bd6ac360627a, 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 fb5fb3adf9a73332eb99ae157dca0a98bfcfa1be && git checkout fb5fb3adf9a73332eb99ae157dca0a98bfcfa1be
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6dede7a51acd11539a50a2427f14bd6ac360627a 8ba6a17be0875524dbab9f32185f13d8789e056a && git checkout -B drift-repro 6dede7a51acd11539a50a2427f14bd6ac360627a && git merge --no-ff 8ba6a17be0875524dbab9f32185f13d8789e056a

node scripts/docs-audit/affected-docs.mjs --json 6dede7a51acd11539a50a2427f14bd6ac360627a

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 2 · session_018VqJPGA1NDNSCZsnecsChP · 2026-10-17T06:16Z

Reviewed against GitHub at head 8ba6a17be0, from the diff and the dev's report on #23968. Clause-②: no. So this is the seat's own review of record.

Form

  • Draft, base main. The first line is Fixes #23968, then Clause-②: no.
  • 4 files, +72 / −9, with one patch changeset for @objectstack/service-print.
  • check-governed-merges.mjs --pr 24155: NOT governed.

The change. The local placement calls launchServer with host: LOCAL_CONTROL_HOST ('127.0.0.1').

  • The type: ChromiumLauncher declares host as required, so dropping it is a type error (TS2345, measured).
  • The header comment says this package binds the control socket to IPv4 loopback, and why.
  • The claim's correction, accepted: ChromiumLauncher is exported from chromium-driver.ts for the tests, not from src/index.ts. It has 0 hits in dist/index.d.ts and dist/index.d.cts, against 5 for PrintServicePlugin as the control. So Clause-②: no holds.

Pins

  • Test Core: the staged launcher asserts host: '127.0.0.1' on the boot probe and on a render. The option dropped reds it ("expected [ undefined, undefined ] …"); the restore is proven by blob.
  • Real Chromium: wsEndpoint() names 127.0.0.1, read from the bound socket. It ran here (Chromium present), and the '127.0.0.2' mutation reds it. The option drop leaves it green, because this host's localhost is 127.0.0.1. That is why the Test Core pin carries the option, as the dev says.

Card item 5 (a host with no IPv4 loopback). Measured with an unassigned stand-in address: the open fails listen EADDRNOTAVAIL with no fallback, and the boot probe reports the engine unreachable. A real host lacking 127.0.0.1, and an IPv6-first localhost, are stated as limits in the PR and the changeset. Accepted.

Out-of-scope finding: Acceptance notes. playwright-core 1.63.0 launches Chromium before binding and leaves it running when the bind fails. That is upstream behaviour, present before this PR wherever binding fails, with no public-door reach measured.

Gates and CI

  • The package passes 6 files and 81 tests. Build, typecheck and check-dts-emitted pass.
  • CI at 8ba6a17be0: 0 failures. Test Core (1/6) was still running at this review.

ACCEPT. The seat marks the PR ready and enables auto-merge through the fleet relay once CI is green on this head. It lands through the merge queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 06:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 06:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 1fee13d Oct 17, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23968-print-loopback-host branch October 17, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

2 participants