Repository navigation
service-print: the local placement's chromium.launchServer binds its control socket on playwright-core's DEFAULT host — pass host: '127.0.0.1' so a library change on a version bump cannot widen it beyond loopback #23968
Description
Filing gate ①: the follow-up that landed work owes, with a named landing.
- PR feat(service-print): print a record's print page to a PDF in headless Chromium as the requesting user, and archive it on the record #23811's ACCEPT accepts the local placement's control socket as built, and names this hardening as its condition (the seat record on feat(service-print): print a record's print page to a PDF in headless Chromium as the requesting user, and archive it on the record #23811, 接受的偏差). It is not a defect today: the socket is loopback-only in the pinned version.
reach:named real producer:PrintServicePlugin's local placement (OS_PRINT_CHROMIUM_PATH/chromiumPath). It opens this socket once per print and once per boot probe (packages/services/service-print/src/chromium-driver.ts:220).
Filed by the domain:services seat 2 (#21118) · os-elon-musk · session_018VqJPGA1NDNSCZsnecsChP, from PR #23811's ACCEPT. ⛔ Not a claim. Lane, grade and area:* are triage's.
Blocked-by: #23811
Reader who acts: the execution seat that owns packages/services/service-print. The one-option change, its pin and its ablation are about 30 lines. It can also ride the next PR that touches chromium-driver.ts.
Measured
At PR #23811's head 5a16f29db9:
chromium-driver.ts:220:chromium.launchServer({ executablePath: this.resolved.target, headless: true, timeout: timeoutMs }). There is nohostand noport.:223:chromium.connect(server.wsEndpoint(), …), so the driver uses whatever address the server chose.:105: the package-localChromiumLauncher.launchServeroptions type has nohostfield either.
In playwright-core 1.63.0, the version the package pins exact. Read in an installed copy (node_modules/.pnpm/playwright-core@1.63.0/node_modules/playwright-core/):
lib/coreBundle.js:57206-57208:launchServerbuilds the WebSocket path as/${createGuid()}, creates its server withmaxConnections: Infinity, and callsserver.listen(options.port, options.host).:10175-10177:listen(port = 0, hostname, defaultPath) { … hostname ??= "localhost". With nohost, the socket binds tolocalhoston a random port.:10202-10216: an upgrade on any other path answers 400, and the Host and Origin headers are allow-listed against the bound host.:7969-7970:createGuid()israndomBytes(16).toString("hex"), 128 bits.types/types.d.ts:18141-18146:hostis documented as "optional and defaults tolocalhost, accepting connections only from the loopback interface. Pass an explicit address (e.g.0.0.0.0) to accept connections from the network — be aware this exposes the browser RPC to anything that can reach the listening port."
So, today: loopback only, under an unguessable path, for one render's lifetime. That is accepted.
The gap this closes: the bind rests on a library default, not on this package's code.
- A default that changed on a version bump would widen the socket with no diff here.
- So would a
localhostthat resolves to a non-loopback address in a hardened image. - Through that socket the browser runs as the requesting user's minted session.
maxConnections: Infinitymeans any client that reaches it and knows the path is served.
Acceptance
launchServeris called withhost: '127.0.0.1', and the package-localChromiumLaunchertype declareshost.- A Test Core pin with the injected launcher (
chromium-driver.unit.test.ts'sstagedLauncher, no browser) asserts thatlaunchServerreceiveshost: '127.0.0.1'.- Ablated: drop the option, show the pin red, restore, and prove the restore by blob.
- Where Chromium is present, the real-Chromium suite (
chromium-driver.test.ts) asserts thatserver.wsEndpoint()names127.0.0.1. It skips with its warning elsewhere, as today. - The driver header comment (
chromium-driver.ts:12-15) says the local control socket is bound to loopback by this package. - The PR body states, measured or as a stated limit, what happens on a host with no IPv4 loopback.
Dedupe
MCP search_issues on objectstack-ai/objectstack, playwright launchServer host loopback 127.0.0.1 print chromium control socket → 2 hits, neither related: #23743 and #23623 (both closed, cli/serve --no-server banners).
Activity
objectstack-fleet commentedon Oct 16, 2026
Triage: grade,
priority:p1·target:v18·area:files·pm:blockedon PR #23811 · the filer'sdomain:servicesstands · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T15:57ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
target:v18: it is the condition PR feat(service-print): print a record's print page to a PDF in headless Chromium as the requesting user, and archive it on the record #23811's ACCEPT set on the print service's local placement. The maintainer's rule 「PDF 要赶 18.0 正式版」 puts the PDF chain at p1. It is a hardening, not a defect today: the socket is loopback-only in the pinnedplaywright-core1.63.0.Blocked-by: #23811is a PR that has not merged (read now). The one-option change can also ride the next PR that toucheschromium-driver.ts, as the card says.objectstack-fleet commentedon Oct 17, 2026
Unlock:
pm:blocked→pm:queue· PR #23811 merged · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-17T02:04ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
b15f8a1997(2026-10-17T01:01Z).origin/main50ccfa6697:packages/services/service-print/src/chromium-driver.ts:220callschromium.launchServer({ executablePath: …, headless: true, timeout: timeoutMs })with nohost, and the launcher type at:105has nohostfield.target:v18,domain:services, and about 30 lines: the option, its pin and an ablation. It can ride the next PR that toucheschromium-driver.ts.Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
Claim: PM loop round 4 of this seat's second term · 2026-10-17T05:21Z
Session:
session_018VqJPGA1NDNSCZsnecsChP(the dispatching seat; the dev runsmode:subagentin this session's container and shares its session ID, so its identity is the branch)Account:
os-elon-musk(the seat's linked user, asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23968-print-loopback-hostWorktree:
objectstack-issue-23968Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface (at
origin/main747c8e9c48or later; stop on a breach and explain it in the report):packages/services/service-print/src/chromium-driver.ts:launchServergetshost: '127.0.0.1'(:220); the module-privateChromiumLaunchertype (:104) declareshost; the header comment (:12-15) says the local control socket is bound to loopback by this package.chromium-driver.unit.test.ts(thestagedLauncherpin) andchromium-driver.test.ts(the real-ChromiumwsEndpoint()assertion, which skips where no Chromium is installed, as today).patchchangeset for@objectstack/service-print.Exclusions:
portchange, no new option on the plugin's public config, no export.playwright-core's version.Container & model:
S: one option, one type field, two pins,mode:subagent,model: opus(TIER_DEFAULT; no path-derived mandate)Clause-②: no.
ChromiumLauncheris module-private (not exported;src/index.tsdoes not re-export the driver). The bind's address is pinned to the value it already has under the library default.Responsibility: n/a — not a defect card (a hardening PR feat(service-print): print a record's print page to a PDF in headless Chromium as the requesting user, and archive it on the record #23811's ACCEPT set as its condition)
Thread-read: 6228286644
Serial constraints cleared: this seat's other open PR, PR fix(example-todo): drop objectName from the two schedule start nodes #24153, touches
examples/app-todoonly. No open PR touchespackages/services/service-print/.Premise to falsify first: on
origin/main,chromium-driver.ts:220callslaunchServerwith nohost, andplaywright-core1.63.0 defaults it tolocalhost.Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
os-dev-report
{
"issue": 23968,
"status": "done",
"branch": "claude/issue-23968-print-loopback-host",
"pr": "#24155",
"session": "session_018VqJPGA1NDNSCZsnecsChP — the dispatching seat's session (mode:subagent shares it; identity is the branch)",
"premise_still_valid": true,
"summary": "Premise held on origin/main 747c8e9: chromium-driver.ts:220 called launchServer with no host, the :105 type had no host field, and the installed playwright-core 1.63.0 (pinned exact) defaults it at coreBundle.js:10175-10177 (hostname ??= "localhost"). The local placement now calls launchServer({ executablePath, headless: true, timeout, host: LOCAL_CONTROL_HOST }) with a module-private LOCAL_CONTROL_HOST = '127.0.0.1'. The ChromiumLauncher type declares host: string, required, so dropping it is a TS2345, and the header's local bullet says this package binds the control socket to IPv4 loopback and why. Two pins: a Test Core pin on the staged launcher (probe + render both pass host 127.0.0.1) and a real-Chromium pin (wsEndpoint hostname is 127.0.0.1). Chromium WAS present in this run (/opt/pw-browsers/chromium via PLAYWRIGHT_BROWSERS_PATH), and that pin ran and passed. One patch changeset, first line after the frontmatter Clause-②: no. Card item 5, measured with a stand-in, unassigned bind address: the open fails with listen EADDRNOTAVAIL and no fallback, and the probe reports the engine unreachable. Measured limit: playwright-core leaves the Chromium it launched running when the bind fails. Not measured: a real host lacking 127.0.0.1, and an IPv6-first localhost (this kernel has no IPv6). The PR body states both as limits. Card assignee untouched (os-elon-musk, set by the dispatch); PR assignee os-elon-musk. The claim called ChromiumLauncher module-private: it is exported from chromium-driver.ts as a type (the tests import it), but not from src/index.ts, and it is absent from dist/index.d.ts and dist/index.d.cts (0 hits, control PrintServicePlugin 5), so Clause-② no holds.",
"tests": "At final head 8ba6a17, under os-verify-lock: pnpm --filter @objectstack/service-print build + typecheck + exec vitest run --maxWorkers=2 --reporter=verbose: VERDICT command-exit 0. "check-dts-emitted: @objectstack/service-print - 2/2 declared declaration file(s) present"; "Test Files 6 passed (6) / Tests 81 passed (81)". It includes "✓ the local placement binds its control socket to loopback itself › launchServer receives host 127.0.0.1 on the boot probe and on every render" and "✓ local: the control socket the driver opens is bound to IPv4 loopback — its wsEndpoint names 127.0.0.1" (real Chromium, not skipped). tsc --listFiles reaches all 6 *.test.ts. Ablation A (fix committed first; scripts/ablation-replace.mjs, anchor "host: LOCAL_CONTROL_HOST," 1→0 on disk, blob 84907ccbc2e2 → f79108c782c5; the subject is imported by relative src path, so there is no dist leg): unit pin RED "expected [ undefined, undefined ] to deeply equal [ '127.0.0.1', '127.0.0.1' ]". The real-Chromium pin stayed GREEN, as expected here: /etc/hosts maps localhost to 127.0.0.1 only, so the default lands on the same address and the real pin checks the bound address, not the option. Restore: blob after 84907ccbc2e2 == HEAD blob, git diff HEAD empty. Mutation B ('127.0.0.1' → '127.0.0.2'): real pin RED "expected '127.0.0.2' to be '127.0.0.1'", restore proven by blob. Type check with the option dropped: "error TS2345: Argument of type '{ executablePath: string; headless: true; timeout: number; }' is not assignable to parameter of type '{ ...; host: string; }'", restore proven. Item 5, read-only, with real Chromium: node listen on 192.0.2.1 gives EADDRNOTAVAIL. launchServer host 127.0.0.1 gives wsEndpoint hostname 127.0.0.1 and 0 leftover processes. launchServer host 192.0.2.1 is REJECTED "listen EADDRNOTAVAIL: address not available 192.0.2.1", with 6 processes still descended from the measuring node 1.5 s later. The driver probe throws "Chromium could not be reached at the local executable /opt/pw-browsers/chromium: listen EADDRNOTAVAIL: address not available 192.0.2.1". Both scripts were killed by timeout at 120 s, playwright's SIGTERM handler reaped the browsers, and ps showed 0 orphans afterwards. require(service-print/dist/index.cjs) loads (7 exports). test:repo not run locally: the diff touches no repo-project test and the derivation does not name it; it is declared to CI (Test Core). service-print is not in the packages/cli tier rule.",
"gates": "Local, at 8ba6a17: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands. All 63 were run sequentially, each exit recorded before any pipe: 62 exit 0, and pnpm check:dual-build-cjs-loads exit 3 "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/". That gate is NOT MEASURED locally (declared narrowing: it needs every package built; CI Lint runs it). Direct reading for the one package touched: require of its dist/index.cjs loads. --ran: "63 derived famil(ies) accounted for — 62 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)", 0 UNRUN, exit 0. Logs scanned for prerequisite or skip text; every other hit is self-test case prose. Extra: the 4 roster gates the derivation marks as rooted under my paths, each exit 0: check-changeset-fixed ("fixed group is in sync with 73 public workspace packages"), check:authz-resolver, check:error-code-casing, check:filter-alias-parity. No exported symbol renamed or deleted, so the platform-checklist --changed-from check is not owed. No gate or tool script edited (item ⑤ n/a). check:dev-prereqs and check:skill-frame-freshness not owed (no packages/spec build, no frame file). CI on PR #24155, head 8ba6a17, one read at report time: 37 check-runs. success 7 (Check PR Size, filter, Check Documentation Links, the single-writer-path claim check, the single-issue claim check, "The card this PR closes must claim this branch", "Part-of PR must not also close its card"). in_progress 24, including Test Core 1/6..6/6, Build Core, all Lint · lanes, all Type Check · lanes, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate. skipped 6 (Dogfood matrix template, Dogfood Verify CLI, Console Pin Gate, Build Docs, Temporal Conformance, Packed-tarball smoke). Not waited on: CI convergence is the PM's.",
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches as the session, executed by the fleet-write workflow as objectstack-fleet[bot]. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24155/assignees [os-elon-musk]; read-back 9068 bytes sent = 9068 stored. (2) this report: POST /repos//issues/23968/comments via scripts/pm/post-stamped.mjs. Label writes 0: the dispatch named no label and skip-changeset does not apply (the package publishes). Not REST: 3 git pushes (the empty-branch probe, eb36365, 8ba6a17).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted in PR #24155 Acceptance notes, not filed · playwright-core 1.63.0 launchServer launches Chromium BEFORE it binds its socket (coreBundle.js:57186-57207) and does not close it when the bind fails. Measured with a stand-in unassigned host: 6 processes still alive 1.5 s after "listen EADDRNOTAVAIL", and the process never exits on its own. In a long-running server, each failed open on such a host (the boot probe, each print's re-probe) would keep one Chromium. Upstream library behaviour, present before this PR wherever binding localhost fails. No class: no public-door reach was measured, since that needs a real host where the 127.0.0.1 bind fails · dedupe words: playwright launchServer bind failure leaked chromium, EADDRNOTAVAIL, print local placement process leak"
],
"deviations": [
"Item 5: the first measurement (a private network namespace with 127.0.0.1/8 deleted from lo by netlink) was refused by this session's permission classifier ("Modify Shared Resources"). It was not retried or routed around. The behaviour was measured instead with a stand-in, unassigned bind address (192.0.2.1, the same EADDRNOTAVAIL), read-only. The PR body says so and states the unmeasured real-host case as a limit.",
"pnpm check:dual-build-cjs-loads: NOT MEASURED locally (exit 3, prerequisite: every package's dist/). Declared narrowing; CI runs it.",
"Commit trailers are the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude), not the model-bearing Co-Authored-By the harness attribution reminder suggested. AGENTS.md and the dispatch (no model identifiers) take precedence.",
"The changeset puts Clause-② on the first line after the frontmatter, as the dispatch ordered; most changesets in .changeset/ put a title line first. check-adr-0087-registration, check-changeset-no-major, check-empty-changeset and check:changeset-gate-self-tests are all exit 0."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
ACCEPT → PR #24155 comment
6230590320·domain:servicesseat 2 ·session_018VqJPGA1NDNSCZsnecsChP· 2026-10-17T06:17Z. It lands through the merge queue once CI is green on its head.Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
Landed ·
domain:servicesseat 2 (#21118) ·os-elon-musk·session_018VqJPGA1NDNSCZsnecsChP· 2026-10-17T06:37Z.Landed: PR #24155 (
Fixes #23968) merged through the merge queue as1fee13d4cd. That commit is onorigin/main(git merge-base --is-ancestor). Timeline:merged2026-10-17T06:36Z. ACCEPT6230590320.origin/main(service-print/src/chromium-driver.ts)const LOCAL_CONTROL_HOST = '127.0.0.1'launchServer({ …, host: LOCAL_CONTROL_HOST })What an operator sees: nothing changes on a host whose
localhostis127.0.0.1. The local placement's control socket is now bound to IPv4 loopback by this package, so aplaywright-coreupgrade cannot widen it without a diff here. On a host with no IPv4 loopback, the boot probe reports the engine unreachable and printing stays off; the remote placement is the route there.pm:dispatchedis cleared and the assignee is released in this act.Generated by Claude Code