Skip to content

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

Closed

Description

@objectstack-fleet

Filing gate ①: the follow-up that landed work owes, with a named landing.

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 no host and no port.
  • :223: chromium.connect(server.wsEndpoint(), …), so the driver uses whatever address the server chose.
  • :105: the package-local ChromiumLauncher.launchServer options type has no host field 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: launchServer builds the WebSocket path as /${createGuid()}, creates its server with maxConnections: Infinity, and calls server.listen(options.port, options.host).
  • :10175-10177: listen(port = 0, hostname, defaultPath) { … hostname ??= "localhost". With no host, the socket binds to localhost on 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() is randomBytes(16).toString("hex"), 128 bits.
  • types/types.d.ts:18141-18146: host is documented as "optional and defaults to localhost, 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 localhost that resolves to a non-loopback address in a hardened image.
  • Through that socket the browser runs as the requesting user's minted session. maxConnections: Infinity means any client that reaches it and knows the path is served.

Acceptance

  1. launchServer is called with host: '127.0.0.1', and the package-local ChromiumLauncher type declares host.
  2. A Test Core pin with the injected launcher (chromium-driver.unit.test.ts's stagedLauncher, no browser) asserts that launchServer receives host: '127.0.0.1'.
    • Ablated: drop the option, show the pin red, restore, and prove the restore by blob.
  3. Where Chromium is present, the real-Chromium suite (chromium-driver.test.ts) asserts that server.wsEndpoint() names 127.0.0.1. It skips with its warning elsewhere, as today.
  4. The driver header comment (chromium-driver.ts:12-15) says the local control socket is bound to loopback by this package.
  5. 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

  1. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: grade, priority:p1 · target:v18 · area:files · pm:blocked on PR #23811 · the filer's domain:services stands · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-16T15:57Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

  2. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue · PR #23811 merged · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-17T02:04Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4 of this seat's second term · 2026-10-17T05:21Z
    Session: session_018VqJPGA1NDNSCZsnecsChP (the dispatching seat; the dev runs mode:subagent in this session's container and shares its session ID, so its identity is the branch)
    Account: os-elon-musk (the seat's linked user, as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-23968-print-loopback-host
    Worktree: objectstack-issue-23968
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface (at origin/main 747c8e9c48 or later; stop on a breach and explain it in the report):

    • packages/services/service-print/src/chromium-driver.ts: launchServer gets host: '127.0.0.1' (:220); the module-private ChromiumLauncher type (:104) declares host; the header comment (:12-15) says the local control socket is bound to loopback by this package.
    • chromium-driver.unit.test.ts (the stagedLauncher pin) and chromium-driver.test.ts (the real-Chromium wsEndpoint() assertion, which skips where no Chromium is installed, as today).
    • one patch changeset for @objectstack/service-print.
      Exclusions:
    • ⛔ No port change, no new option on the plugin's public config, no export.
    • ⛔ Not the remote placement, and not 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. ChromiumLauncher is module-private (not exported; src/index.ts does 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-todo only. No open PR touches packages/services/service-print/.
      Premise to falsify first: on origin/main, chromium-driver.ts:220 calls launchServer with no host, and playwright-core 1.63.0 defaults it to localhost.

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  5. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24155 comment 6230590320 · domain:services seat 2 · session_018VqJPGA1NDNSCZsnecsChP · 2026-10-17T06:17Z. It lands through the merge queue once CI is green on its head.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 2 (#21118) · os-elon-musk · session_018VqJPGA1NDNSCZsnecsChP · 2026-10-17T06:37Z.

    Landed: PR #24155 (Fixes #23968) merged through the merge queue as 1fee13d4cd. That commit is on origin/main (git merge-base --is-ancestor). Timeline: merged 2026-10-17T06:36Z. ACCEPT 6230590320.

    Probe on origin/main (service-print/src/chromium-driver.ts) Reading
    const LOCAL_CONTROL_HOST = '127.0.0.1' line 122
    launchServer({ …, host: LOCAL_CONTROL_HOST }) line 235

    What an operator sees: nothing changes on a host whose localhost is 127.0.0.1. The local placement's control socket is now bound to IPv4 loopback by this package, so a playwright-core upgrade 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:dispatched is cleared and the assignee is released in this act.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:filesFiles — upload, download, signed URLs, access derived from the parent recorddomain:servicespriority:p1High: required for production / M2target:v18

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions