diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f550536..b4b9c2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Historical entries retain their original delivery coordinates. ## Unreleased +## 0.18.83 + +Prefer a provisioned Lightpanda for LinkedIn profile reads while Chromium stays authoritative for the remaining transports. + +- `createBrowserSession` accepts a browser-engine selection: `"auto"` resolves to Lightpanda only for cookie-yielding realms with a provisioned binary, `"chrome"` keeps the existing lane, and `"lightpanda"` requires both eligibility and provisioning. +- The LinkedIn profile transport defaults to `"auto"` for its qualified reads — identity, personal stats, connections, and organizations — and keeps cookie values out of process arguments by seeding through the driver's stdin batch. +- Chromium fallback is limited to a recognized Lightpanda protocol incompatibility raised before navigation inside the original deadline; provider rejections, authentication failures, and cleanup failures never fall back. +- Contact reads stay on Chromium because their overlay harvests live `network requests` bindings, which Lightpanda has not been proven to report. Article, comment, post, feed, search, and every other provider read also keep the unqualified Chromium default. + ## 0.18.82 Publish observed, subject-bound `posts.publish` and `media.publish` operations diff --git a/README.md b/README.md index fb890c83..a7b232ca 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Install [Bun 1.3.14](https://bun.sh/docs/installation) if needed, then install GhostGet and read a public page: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.82/hraness-ghostget-0.18.82.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.83/hraness-ghostget-0.18.83.tgz ghostget read https://example.com ``` @@ -48,9 +48,9 @@ which always names the latest published release. Upgrading from Wrench? Read the The optional Agent Skill teaches your agent when and how to use GhostGet: ```sh -npx skills add hraness/ghostget#v0.18.82 +npx skills add hraness/ghostget#v0.18.83 # With Bun instead: -bunx skills add hraness/ghostget#v0.18.82 +bunx skills add hraness/ghostget#v0.18.83 ``` Start a new agent session, then ask: “Use GhostGet to read https://example.com @@ -182,7 +182,7 @@ firewall. ## Built-in provider catalog -This v0.18.82 source tree supports executable actions for 21 services: Beeper, +This v0.18.83 source tree supports executable actions for 21 services: Beeper, Bluesky, ClasificadosOnline, Facebook, Facebook Groups, Facebook Marketplace, GitHub, Gmail, Hacker News, Instagram, iMessage, LinkedIn, Reddit, Substack, Threads, TikTok, Twitch, WebMCP Registry, WhatsApp, X, and YouTube. LinkedIn @@ -266,7 +266,7 @@ For that same released coordinate, install GhostGet in an agent or application that owns its own model, planning, tool loop, approvals, and interface: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.82/hraness-ghostget-0.18.82.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.83/hraness-ghostget-0.18.83.tgz ``` ```ts diff --git a/dist/apple-photos-client.js b/dist/apple-photos-client.js index 69ceef43..fb9af6ff 100644 --- a/dist/apple-photos-client.js +++ b/dist/apple-photos-client.js @@ -1,7 +1,7 @@ // @bun import { GHOSTGET_VERSION -} from "./index-41extnj8.js"; +} from "./index-xgd7hzj2.js"; import { canonicalJson, sha256 diff --git a/dist/beeper-client.js b/dist/beeper-client.js index af6469a8..70191b94 100644 --- a/dist/beeper-client.js +++ b/dist/beeper-client.js @@ -4,7 +4,7 @@ import { } from "./index-26yq8q16.js"; import { GHOSTGET_VERSION -} from "./index-41extnj8.js"; +} from "./index-xgd7hzj2.js"; import { canonicalJson, canonicalJsonSha256Matches, diff --git a/dist/index-41extnj8.js b/dist/index-xgd7hzj2.js similarity index 62% rename from dist/index-41extnj8.js rename to dist/index-xgd7hzj2.js index 3c411554..91264b18 100644 --- a/dist/index-41extnj8.js +++ b/dist/index-xgd7hzj2.js @@ -1,5 +1,5 @@ // @bun // src/version.ts -var GHOSTGET_VERSION = "0.18.82"; +var GHOSTGET_VERSION = "0.18.83"; export { GHOSTGET_VERSION }; diff --git a/docs/publishing.md b/docs/publishing.md index 1ed627c7..d41558b7 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -250,12 +250,12 @@ delivery proceeds through a new source-qualified version. ## Install the canonical release -These commands require the matching published immutable v0.18.82 release. +These commands require the matching published immutable v0.18.83 release. For the CLI: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.82/hraness-ghostget-0.18.82.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.83/hraness-ghostget-0.18.83.tgz ghostget --version ghostget doctor --json ``` diff --git a/kb/launch/social-kit.md b/kb/launch/social-kit.md index e27bcd00..0e1ce0be 100644 --- a/kb/launch/social-kit.md +++ b/kb/launch/social-kit.md @@ -57,7 +57,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.82. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.83. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -115,7 +115,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.82. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.83. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -173,7 +173,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.82. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.83. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -197,7 +197,7 @@ GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands o The plan is for GhostGet to stay small. Your agent does the thinking, and GhostGet runs only actions someone has reviewed. Each new service arrives as reviewed actions with their own previews. -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.82. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.83. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -224,8 +224,8 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - Sometimes a post goes through but the answer gets lost on the way back. GhostGet writes down every send before it leaves and never sends it again on its own until it knows what happened. - Anything beyond a read starts as a preview that shows the service, the account, and exactly what will be sent. Your agent can prepare it. Nothing is sent until someone confirms that exact preview. - GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands on your Mac or Linux machine and need to read the web and use the accounts you already have. -- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.82. -- Latest release: v0.18.82. https://ghostget.com/blog/introducing-ghostget/ +- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.83. +- Latest release: v0.18.83. https://ghostget.com/blog/introducing-ghostget/ ## Beats @@ -251,4 +251,4 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - claimsTotal: 248. verification/claims.json, every claim - claimsEvidenced: 229. verification/claims.json, status evidenced - claimsConfigReadback: 15. verification/claims.json, layer configuration-readback -- status: Latest release: v0.18.82. package.json version +- status: Latest release: v0.18.83. package.json version diff --git a/kb/plans/lightpanda-semantic-capture.md b/kb/plans/lightpanda-semantic-capture.md index 28570502..f1551065 100644 --- a/kb/plans/lightpanda-semantic-capture.md +++ b/kb/plans/lightpanda-semantic-capture.md @@ -1,7 +1,7 @@ --- type: plan area: browser-verification -status: completed +status: in-progress --- # Prefer provisioned Lightpanda for public semantic capture @@ -86,26 +86,162 @@ Qualification, bounded to read-only evidence: writes were issued, and the auth realms were verified unchanged afterward. See Result for the outcome. +## Engine-aware contained sessions and the LinkedIn default + +The follow-on integration adds a `BrowserEngineSelection` +(`"chrome" | "lightpanda" | "auto"`) option to `createBrowserSession`, +reusing the full custody contract — artifact roots, socket directory, +task-owned proxy, cleanup journal, stdin cookie seeding, operation +deadlines, and recovery handles — rather than a parallel session path. +`"auto"` resolves to Lightpanda only when the auth realm yields explicit +cookies (`cookie-source`, `cookies-file`, or `browser-profile` with an +attached `cookieSource` and no `storageState`) **and** a validated +Lightpanda 1.0.0 binary is provisioned; everything else resolves to +Chromium. Automatic fallback to Chromium fires only on a recognized +`LightpandaCompatibilityError` raised before navigation, within the +original deadline. Provider rejections such as a LinkedIn 401 never +fall back. + +Lightpanda global arguments keep `--config`, `--session`, +`--content-boundaries`, `--max-output`, `--action-policy`, and the plain +task-owned `--proxy` URL; Chromium-only flags (`--profile`, `--state`, +`--headed`, `--executable-path`, `--allowed-domains`, launch `--args`) +stay off. Cookies seed through the driver's stdin batch so values never +enter `argv`. First-party context after import opens the reviewed +origin's `/robots.txt` (the same realm page Chromium uses pre-cookie) +rather than a signed-in root that redirects to `/feed` and can +challenge a fresh automated session. + +`createLinkedInProfileBrowserTransport` threads `engine` through +`LinkedInWebExecutionOptions` and defaults to `"auto"`, making +Lightpanda-first the default for its qualified funnel: identity probe, +personal-profile stats, connections, and organization reads. Contact +reads pin Chromium at their call site: the overlay harvests live +`network requests` bindings, and Lightpanda's well-formed empty response +has not been proven to report in-flight requests. Article, comment, +post, feed, and search transports keep the unqualified Chromium +default. An explicit `engine: "chrome"` pins the old lane. + +### Live qualification evidence for the LinkedIn lane + +- Adapter probe (`browser-profile` + live `cookieSource`): two + consecutive green runs on real auth — exact same-account subject from + `/voyager/api/me`, 915,578-byte profile HTML, 857,941-byte connections + HTML, stable session, healthy containment, complete cleanup, both auth + realms byte-identical. +- Integrated `createBrowserSession` run: `auto` resolved to Lightpanda, + launched, seeded cookies via stdin batch, opened `/robots.txt`, + closed, and preserved the realm. The identity read returned + `provider-response-401`. +- Discriminator: with the **same** freshly acquired cookie snapshot, + a plain HTTP `GET /feed/` returned a 302 authwall bounce while + Lightpanda returned the same 401 — and the previously-green adapter + then reproduced the same 401. The Chrome session itself had become + invalid in the interval; all session-path 401s are dead-auth noise, + not an engine or integration defect. No engine makes a dead session + succeed. +- `network requests` returned a well-formed empty list in Lightpanda, + but whether it reports in-flight requests is unproven — the contact + overlay's request-binding harvest stays on Chromium until observed. + Synthetic fixtures covered cookie import fidelity. + +### Cleanup admission under a durable publisher + +An independent review found the first integration could not survive the +production authenticated-read path: cleanup admission publishes a durable +resource identity, and the live control-witness binder requires +`engine: "chrome"` plus `browserLaunched: true`. A CDP-attached Lightpanda +session reports `engine: "lightpanda"` and `browserLaunched: false`, so it +could never bind. The remediation: + +- The control-witness bind is now engine-gated (`chrome` only). Lightpanda + sessions publish `prepared` then `launch-intent` and stay unbound. +- Launch-intent quiescence is engine-agnostic: it checks the daemon's + exact `active` flag and session/socket identity pins, so an + active-while-settling Lightpanda session retries inside the bounded + convergence window instead of hard-failing the strict Chrome parser. +- `auto` with a publisher runs a throwaway preflight before the durable + `prepared` publish — spawn, `open about:blank`, one stdin cookie import, + `close`, serve-child reap, and a daemon-observed `inactive` check on + unpublished roots. A protocol incompatibility resolves to Chromium + before any durable identity exists; a post-publish compat failure can + never register a second identity and fails closed. +- Executable resolution is gated on the selected engine, so a stale + `LIGHTPANDA_PATH` cannot break unrelated Chromium sessions. + +A live run through `createBrowserSession` under a publisher against the +real driver and a cookies-file realm completed `prepared` → +`launch-intent` publication, `journal-quiescent`, and both root-removal +journal entries, with every private root deleted. A second live run with +no provisioning env resolved `auto` to Chromium, confirming the graceful +degradation leg. + +A second review round hardened the preflight itself: + +- The inactivity proof now runs only after a fully successful probe — + a probe failure already decided the outcome, and running it could wrap + the compat error in an `AggregateError` the `instanceof` fallback check + cannot see. Quiescence proof uses the production + `convergeBrowserCleanupResourceProof` (10s bounded window, settling + retries, exact identity pins) instead of an ad-hoc 2s poll, with inner + command timeouts clamped to the remaining operation deadline. +- A daemon that can never prove the closed session inactive is now + classified as a protocol incompatibility — settling-class failures + become a bare `LightpandaCompatibilityError` so `auto` still resolves + to Chromium, while the unproven throwaway roots are preserved rather + than deleted. Identity, boundary, and malformed-output faults stay + fail-closed. +- Proxy creation is tracked through `networkProxyCreation.pending` and + closed late in teardown, mirroring the contained-session path, so a + deadline abort cannot leak a live loopback listener. +- The owned serve-child reap uses a dedicated 6s bound because + `lightpanda.close()`'s own worst case (~1s SIGKILL grace + 3s exit + wait) exceeds the generic 2s resource-teardown bound. +- `close()` sets `acknowledged` only after the reap succeeds, so a reap + failure stays open and retryable through the launch-intent recovery + instead of being masked. + +A third review round verified every remediation in code and found one +remaining minor defect, now fixed: the LinkedIn transport's one-shot +`initialBatchPending` rewrite flag was consumed by any command, +including stdin-free lifecycle invokes such as the preflight's +quiescence `session info` probe. A Chromium fallback after that probe +would have navigated to the signed-in root directly instead of warming +the realm page first. The flag now clears only when an invocation +carries a stdin batch. + +Note: cookie replay into a contained browser is not novel risk — the +Chromium lane already seeds the same acquired cookies for +`browser-profile + cookieSource` auths. The session invalidation window +overlapped other signed-in-browser lane work, so attribution to the +Lightpanda reads is not supported by the evidence. + ## Result - Delivered and verified: Lightpanda-first public semantic capture in Ghostget `v0.18.79` and Direct `v0.7.29`, released, mirrored byte-exact on npm, and promoted to production. -- LinkedIn authenticated Lightpanda read: **qualified for the identity - read only.** One `/voyager/api/me` probe through the isolated - Lightpanda session returned the exact same-account subject bound to - `linkedin-main` (`subjectMatches: true`), with imported cookies, - healthy containment, complete cleanup, and both auth realms - byte-identical afterward. This contradicts the adapter note's earlier - finding for that realm: a bound `cookie-source` handoff is accepted - where whole-profile reuse is impossible. One read is not provider-wide - qualification — profile, connections, and RSC contact reads each - still need per-operation evidence before a Lightpanda transport can - be offered. +- LinkedIn authenticated Lightpanda reads: **qualified for identity, + personal stats, connections, and organization reads.** Live adapter + evidence covered identity, profile HTML, and connections HTML on real + auth; unit tests cover engine selection, fallback, custody, and + seeding. The profile transport defaults to `"auto"` — Lightpanda + first for cookie-yielding realms with a provisioned binary, Chromium + otherwise. Contact reads stay on Chromium pending live + network-request-observation evidence. This contradicts the adapter + note's earlier finding for that realm: a bound `cookie-source` + handoff is accepted where whole-profile reuse is impossible. +- Residual live gap: the integrated session path (including durable + cleanup admission) is now mechanically green — a publisher-enabled + Lightpanda session published, journaled, and removed every private + root against the real driver. The identity read still lands inside + the dead-session window, so one provider-200 run through the + integrated path is pending re-authentication; `engine: "chrome"` + remains the documented escape lane. - Chromium remains mandatory for visual evidence, attached or - profile-backed sessions, connected accounts, and every authenticated - provider read by default. Lightpanda authentication stays disabled - unless a per-provider qualification like the one above passes. + profile-backed sessions, connected accounts, and the unqualified + LinkedIn transports (article, comment, post, feed, search) and all + other providers by default. ## Durable memory @@ -123,3 +259,30 @@ Qualification, bounded to read-only evidence: non-HTTP(S) target allowed, and batch `--allowed-domains` filtering is not wired for the Lightpanda global arguments — the task-owned proxy enforces containment instead. +- When an authenticated read fails, prove the session is alive before + suspecting the engine: replay the same acquired cookies over plain + HTTP. Identical rejection across transports means dead auth, not an + engine defect — and three consecutive session-path 401s cost real + qualification time before this was checked. +- Post-cookie first-party context should be a cheap realm page + (`/robots.txt`), never a signed-in root: heavyweight landings can + challenge fresh automated sessions before any in-page request runs. +- A CDP-attached browser has no daemon-launched process for the + controlled cleanup witness — `session info` reports + `browserLaunched: false` and a non-Chrome engine even while its owned + serve child runs. Quiescence must be proven at the launch-intent + phase from daemon-observed inactivity after close plus serve-child + reap, and an `auto` selection with a publisher must preflight + compatibility before the durable identity exists, because a retry's + fresh identity can never register afterward. +- Keep executable resolution gated on the selected engine: validating an + unrelated engine's environment lets a stale `LIGHTPANDA_PATH` break + Chromium sessions that never asked for it. +- A one-shot command wrapper that keys on batch stdin must clear its + armed flag only when a batch actually arrives: stdin-free lifecycle + invokes (`session info`, preflight probes) otherwise consume the + rewrite before the navigation it protects. +- On the Lightpanda lane, `session.runBatch` puts each command on + process argv while the Chromium lane sends one stdin payload — cookie + values must always take the `dependencies.runBatch` stdin channel, and + future callers must not seed cookies through `session.runBatch`. diff --git a/package.json b/package.json index 17f3f939..a75ce7e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/ghostget", - "version": "0.18.82", + "version": "0.18.83", "description": "GhostGet gives your AI agent named web actions: read a page, archive one media item, or use a connected account, without credentials or a browser to steer.", "license": "MIT", "type": "module", diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 518a681d..33fb9c48 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -1220,7 +1220,7 @@ describe("npm publication contract", () => { (MAX_UNPACKED_BYTES + MAX_PACKED_ENTRIES * 1_023 + 1_024) / 512, ) * 512, ); - expect(MAX_PACKAGE_TAR_BYTES).toBe(25_150_976); + expect(MAX_PACKAGE_TAR_BYTES).toBe(25_179_136); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -1430,10 +1430,10 @@ describe("npm publication contract", () => { expect(budget).toContain("785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - scope: "0.18.82 invoke --execute authorized publication over the documentation and WebMCP release", - npmVersion: "11.19.0", nodeVersion: "24.20.0", zlibVersion: "1.3.2.1-motley-42c2f19", - archiveSha256: "59451cd8ec83a409969d46785f3400c09507c8b6e3d2aff03294537a50191b50", - packedBytes: 12_244_064, unpackedBytes: 24_496_816, entryCount: 638, + scope: "0.18.83 engine-aware contained sessions over the invoke --execute release", + npmVersion: "11.19.0", nodeVersion: "24.20.0", zlibVersion: "1.2.12", + archiveSha256: "34a0261f74428b18798b6be57ab39bf72e68e45f000a20fa55d6d88272505082", + packedBytes: 12_237_548, unpackedBytes: 24_524_887, entryCount: 638, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -1449,8 +1449,8 @@ describe("npm publication contract", () => { expect(budget).toContain("24,024,705 + 353 + 65 = 24,025,123 unpacked"); expect(budget).toContain("12,152,562 + 12,387 + 4,096 = 12,169,045 packed"); expect(budget).toContain("24,093,786 + 353 + 65 = 24,094,204 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_260_547); - expect(MAX_PACKED_BYTES).toBe(12_244_064 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_254_031); + expect(MAX_PACKED_BYTES).toBe(12_237_548 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -1571,7 +1571,7 @@ describe("npm publication contract", () => { expect(budget).toContain("47684b3e2eb5cf3ed07fbb520aade8c7251d993f75262fbf1af627d9081a1a5f"); expect(budget).toContain("23,688,277 + 353 + 65 = 23,688,695"); expect(budget).toContain("23,759,283 + 353 + 65 = 23,759,701"); - expect(MAX_UNPACKED_BYTES).toBe(24_497_234); + expect(MAX_UNPACKED_BYTES).toBe(24_525_305); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -1604,7 +1604,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(24_496_816 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(24_524_887 + 353 + 65); expect(budget).toContain("22,794,052 + 65 = 22,794,117"); expect(budget).toContain("c482efe748f880e3717727d6d39fd92a68953e6eea766642b329ba47ae772d80"); expect(budget).toContain("22,759,423 + 65 = 22,759,488"); @@ -1640,8 +1640,8 @@ describe("npm publication contract", () => { expect(packageArtifactBudget).toEqual({ entryCount: { min: 638, max: 638 }, fileCount: { min: 638, max: 638 }, - packedBytes: { min: 1_600_000, max: 12_260_547 }, - unpackedBytes: { min: 9_000_000, max: 24_497_234 }, + packedBytes: { min: 1_600_000, max: 12_254_031 }, + unpackedBytes: { min: 9_000_000, max: 24_525_305 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index af71205f..4de2d007 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2491,16 +2491,25 @@ // 59451cd8ec83a409969d46785f3400c09507c8b6e3d2aff03294537a50191b50. Retain the // reviewed allowances: 12,244,064 + 12,387 + 4,096 = 12,260,547 packed; // 24,496,816 + 353 + 65 = 24,497,234 unpacked. +// The 0.18.83 release adds the engine-aware contained-session lane and the +// LinkedIn profile-transport default over the reviewed 0.18.82 release. +// Two clean npm 11.19.0 packs on Node 24.20.0 (zlib 1.2.12) with +// --ignore-scripts on darwin arm64 were byte-identical at 638 +// files/entries, 12,237,548 packed bytes and 24,524,887 unpacked bytes; +// archive SHA-256 +// 34a0261f74428b18798b6be57ab39bf72e68e45f000a20fa55d6d88272505082. Retain the +// reviewed allowances: 12,237,548 + 12,387 + 4,096 = 12,254,031 packed; +// 24,524,887 + 353 + 65 = 24,525,305 unpacked. export const repairPackageMeasurement = Object.freeze({ - scope: "0.18.82 invoke --execute authorized publication over the documentation and WebMCP release", + scope: "0.18.83 engine-aware contained sessions over the invoke --execute release", command: "npm pack --ignore-scripts", npmVersion: "11.19.0", nodeVersion: "24.20.0", - zlibVersion: "1.3.2.1-motley-42c2f19", + zlibVersion: "1.2.12", platform: "darwin-arm64", - archiveSha256: "59451cd8ec83a409969d46785f3400c09507c8b6e3d2aff03294537a50191b50", - packedBytes: 12_244_064, - unpackedBytes: 24_496_816, + archiveSha256: "34a0261f74428b18798b6be57ab39bf72e68e45f000a20fa55d6d88272505082", + packedBytes: 12_237_548, + unpackedBytes: 24_524_887, entryCount: 638, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, diff --git a/skills/ghostget/SKILL.md b/skills/ghostget/SKILL.md index 1a24ba6e..78f3182d 100644 --- a/skills/ghostget/SKILL.md +++ b/skills/ghostget/SKILL.md @@ -136,8 +136,16 @@ unsupported Lightpanda protocol method before target navigation, after complete Lightpanda cleanup and within the original timeout. A navigated page is never replayed. Explicit Lightpanda selection, security denials, identity failures, and cleanup failures never trigger that retry. Existing -HTTP and typed provider routes are unchanged; authenticated browser capture -continues to use Chromium until its exact auth handoff is qualified. +HTTP and typed provider routes are unchanged. + +Authenticated browser sessions take the same engine choice internally. The +LinkedIn profile read transport (identity, personal stats, connections, and +organization reads) prefers a provisioned Lightpanda for cookie-yielding +account realms, imports session cookies through the driver's input channel, +and still selects Chromium for profile-backed, storage-state, and other +non-cookie realms or when no binary is provisioned. Contact reads keep +Chromium while their live network-request observation remains unqualified, +as does every other authenticated provider flow. ## Author a portable provider diff --git a/skills/ghostget/references/install.md b/skills/ghostget/references/install.md index f411aab2..c9cdc5c8 100644 --- a/skills/ghostget/references/install.md +++ b/skills/ghostget/references/install.md @@ -17,14 +17,14 @@ If Bun is missing, stop and direct the user to the official [Bun installation guide](https://bun.sh/docs/installation). Do not switch package managers or pipe an unreviewed installer into a shell. -This reference is authored for the exact v0.18.82 release coordinate. Use it +This reference is authored for the exact v0.18.83 release coordinate. Use it only from the matching release-bound Agent Skill after its canonical archive and immutable GitHub Release exist. If the coordinate is not public, stop instead of substituting `main`, another tag, or a different package version. Install that exact release and verify a public-page read: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.82/hraness-ghostget-0.18.82.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.83/hraness-ghostget-0.18.83.tgz ghostget read https://example.com ``` @@ -47,21 +47,21 @@ for the requested workflow; an unconnected provider or missing media tool does not prevent a public-page read, and overall provider readiness can be false on a fresh installation. -The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.82` is an optional npm +The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.83` is an optional npm mirror only after verified registry publication. Canonical installation does not wait for registry publication. When upgrading from Wrench, use `ghostget` for new commands and `GHOSTGET_STATE_HOME` for an explicit state root. Existing state is selected in place; do not rename, copy, or delete a state directory as part of the upgrade. -The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.82/docs/ghostget-migration.md) +The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.83/docs/ghostget-migration.md) explains the retained state aliases and durable protocol names. Do not clone the repository merely to run the CLI. Importing the SDK is a separate project dependency and does not install a global command: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.82/hraness-ghostget-0.18.82.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.83/hraness-ghostget-0.18.83.tgz ``` `ghostget adapter sync-bundled` upgrades exact bundled baselines, including an diff --git a/src/beeper-client-types.ts b/src/beeper-client-types.ts index 0432da44..89365818 100644 --- a/src/beeper-client-types.ts +++ b/src/beeper-client-types.ts @@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{ implementation: Readonly<{ producer: Readonly<{ package: "@hraness/ghostget"; - version: "0.18.82"; + version: "0.18.83"; }>; officialCli: Readonly<{ implementation: "github.com/beeper/cli"; diff --git a/src/browser.test.ts b/src/browser.test.ts index ae2c6aa6..6295e90a 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -44,6 +44,7 @@ import { parseLastJsonWithExactLaunchHashes, PreservedBrowserArtifactsError, profilePath, + proveLaunchIntentAgentBrowserCleanupResourceQuiescent, provePinnedAgentBrowserCleanupResourceAbsentRootQuiescence, provePreparedAgentBrowserCleanupResourceQuiescent, refreshBrowserCleanupResourceQuiescence, @@ -55,6 +56,7 @@ import { type BrowserCleanupResourceIdentityV2, } from "./browser"; import type { BrowserRecipe, GhostgetManifest } from "./model"; +import { LightpandaCompatibilityError } from "./lightpanda-browser"; import { OperationDeadline, type OperationDeadlineClock, @@ -4755,4 +4757,1748 @@ describe("browser process isolation helpers", () => { rmSync(directory, { recursive: true, force: true }); } }); + + test("routes cookie-yielding sessions through Lightpanda only when provisioned", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const provisionedExecutables: string[] = []; + const driverCommands: string[][] = []; + const driverGlobals: string[][] = []; + let chromeCommands = 0; + let serveClosed = 0; + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + dependencies: { + runCommand: () => { + chromeCommands += 1; + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [{ + name: "session", + value: "private-cookie-value", + domain: "example.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + sameSite: "Lax" as const, + expires: 0, + }], + warnings: [], + }), + createLightpandaDependencies: (executable) => { + provisionedExecutables.push(executable); + return { + run: (globalArguments, command) => { + driverGlobals.push([...globalArguments]); + driverCommands.push([...command]); + return Promise.resolve({ ok: true }); + }, + runBatch: (globalArguments, commands) => { + driverGlobals.push([...globalArguments]); + for (const command of commands) driverCommands.push([...command]); + return Promise.resolve(); + }, + close: () => { + serveClosed += 1; + return Promise.resolve(); + }, + }; + }, + }, + }); + expect(provisionedExecutables).toEqual([realpathSync(stub)]); + await session.close(); + await session.cleanup(); + expect(chromeCommands).toBe(0); + const verbs = driverCommands.map((command) => command[0]); + expect(verbs).toEqual(["open", "cookies", "open", "close"]); + expect(driverCommands[0]).toEqual(["open", "about:blank"]); + expect(driverCommands[2]).toEqual(["open", "https://example.com/robots.txt"]); + const globals = driverGlobals[0] ?? []; + expect(globals).toContain("--proxy"); + expect(globals[globals.indexOf("--proxy") + 1]).toBe("http://127.0.0.1:43124"); + expect(globals).not.toContain("--allowed-domains"); + expect(globals).not.toContain("--executable-path"); + expect(globals).not.toContain("--args"); + expect(serveClosed).toBeGreaterThanOrEqual(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("keeps non-cookie realms and unprovisioned auto sessions on Chromium", async () => { + const chromeBatches: string[][] = []; + let lightpandaFactories = 0; + const baseDependencies = { + runCommand: (command: readonly string[], options: { readonly stdin?: string }) => { + if (command.includes("batch")) { + chromeBatches.push([...command]); + const batch = JSON.parse(options.stdin ?? "[]") as readonly unknown[]; + return Promise.resolve({ + stdout: `${JSON.stringify(batch.map(() => ({ success: true, data: null })))}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ stdout: "{\"success\":true}\n", stderr: "", exitCode: 0 }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ cookies: [], warnings: [] }), + createLightpandaDependencies: () => { + lightpandaFactories += 1; + throw new Error("Lightpanda must not be provisioned for this session"); + }, + }; + const profileDirectory = mkdtempSync(join(tmpdir(), "io-profile-")); + const storageStateAuth: GhostgetAuth = { + schemaVersion: 1, + id: "state", + kind: "browser-profile", + profile: realpathSync(profileDirectory), + trustUnfilteredEgress: true, + cookieSource: "chrome", + storageState: "/tmp/ghostget-missing-state.json", + }; + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + // A browser-profile realm that pairs its cookie source with storage + // state cannot transfer the storage into Lightpanda; auto stays on + // Chromium even when a Lightpanda binary is provisioned. + const stateSession = await createBrowserSession(manifest, storageStateAuth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + dependencies: baseDependencies, + }); + await stateSession.close(); + await stateSession.cleanup(); + expect(lightpandaFactories).toBe(0); + expect(chromeBatches).not.toHaveLength(0); + const stateIndex = chromeBatches[0]?.indexOf("--state") ?? -1; + expect(chromeBatches[0]?.[stateIndex + 1]).toBe("/tmp/ghostget-missing-state.json"); + + // Without provisioning the same cookie-yielding realm stays on Chromium. + const unprovisioned = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + dependencies: baseDependencies, + }); + await unprovisioned.close(); + await unprovisioned.cleanup(); + expect(lightpandaFactories).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(profileDirectory, { recursive: true, force: true }); + } + }); + + test("rejects explicit Lightpanda without provisioning or cookie-yielding auth", async () => { + const storageStateAuth: GhostgetAuth = { + schemaVersion: 1, + id: "state", + kind: "browser-profile", + profile: "/tmp/ghostget-missing-profile", + trustUnfilteredEgress: true, + }; + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const missing = await rejectionMessage(createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "lightpanda", + environment: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + })); + expect(missing).toContain("LIGHTPANDA_PATH"); + const ineligible = await rejectionMessage(createBrowserSession(manifest, storageStateAuth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "lightpanda", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + })); + expect(ineligible).toContain("yields explicit cookies"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("falls back to Chromium only for a pre-navigation Lightpanda incompatibility", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + for (const beforeNavigation of [true, false]) { + let chromeCommands = 0; + let serveClosed = 0; + const attempt = createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + dependencies: { + runCommand: (_command, options) => { + chromeCommands += 1; + if (_command.includes("batch")) { + const batch = JSON.parse(options.stdin ?? "[]") as readonly unknown[]; + return Promise.resolve({ + stdout: `${JSON.stringify(batch.map(() => ({ success: true, data: null })))}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ stdout: "{\"success\":true}\n", stderr: "", exitCode: 0 }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ cookies: [], warnings: [] }), + createLightpandaDependencies: () => ({ + run: (_globals, command) => { + if (command[0] === "close") return Promise.resolve({ closed: true }); + return Promise.reject(new LightpandaCompatibilityError(beforeNavigation)); + }, + close: () => { + serveClosed += 1; + return Promise.resolve(); + }, + }), + }, + }); + if (beforeNavigation) { + const session = await attempt; + expect(chromeCommands).toBeGreaterThan(0); + expect(serveClosed).toBeGreaterThanOrEqual(1); + await session.close(); + await session.cleanup(); + } else { + const failure = await rejectionValue(attempt); + expect(failure).toBeInstanceOf(LightpandaCompatibilityError); + expect(chromeCommands).toBe(0); + expect(serveClosed).toBeGreaterThanOrEqual(1); + } + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("publishes a Lightpanda session as launch-intent and cleans it through the unbound quiescence proof", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + type PublishedCleanupResource = Parameters< + NonNullable< + Parameters[2]["publishCleanupResource"] + > + >[0]; + const published: PublishedCleanupResource[] = []; + const cleanupEvents: string[] = []; + const inspectedSessions: string[] = []; + const driverCommands: string[][] = []; + let serveClosed = 0; + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "lightpanda", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + publishCleanupResource: Object.assign( + (resource: PublishedCleanupResource) => { + if (published.length > 0) { + expect(browserCleanupResourceExtends( + published[published.length - 1]!, + resource, + )).toBeTrue(); + expect(resource.session).toBe(published[0]!.session); + } + published.push(resource); + }, + { + markBrowserCleanupQuiescent: (): void => { + cleanupEvents.push("journal-quiescent"); + }, + markBrowserCleanupRootRemoved: ( + _resource: PublishedCleanupResource, + rootName: "artifacts" | "socket", + ): void => { + cleanupEvents.push(`journal-${rootName}`); + }, + }, + ), + dependencies: { + runCommand: (command) => { + // Lightpanda sessions never publish a control witness, so no + // cdp-url inspection may run; session info only answers after the + // owned serve child was reaped, reporting the session inactive. + expect(command.includes("cdp-url")).toBeFalse(); + if (command.includes("info")) { + const sessionIndex = command.indexOf("--session"); + inspectedSessions.push(command[sessionIndex + 1] ?? ""); + const resource = published[0]; + if (resource === undefined) { + throw new Error("session inspection ran before publication"); + } + return Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session: resource.session, + socketDir: resource.socketDirectory, + version: null, + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [{ + name: "session", + value: "private-cookie-value", + domain: "example.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + sameSite: "Lax" as const, + expires: 0, + }], + warnings: [], + }), + createLightpandaDependencies: () => ({ + run: (_globals, command) => { + driverCommands.push([...command]); + return Promise.resolve({ ok: true }); + }, + runBatch: (_globals, commands) => { + for (const command of commands) driverCommands.push([...command]); + return Promise.resolve(); + }, + close: () => { + serveClosed += 1; + return Promise.resolve(); + }, + }), + }, + }); + await session.close(); + await session.cleanup(); + expect(published.map((resource) => resource.kind === "agent-browser-session-v2" + ? resource.phase + : "legacy")).toEqual([ + "prepared", + "launch-intent", + ]); + expect(cleanupEvents).toEqual([ + "journal-quiescent", + "journal-artifacts", + "journal-socket", + ]); + expect(inspectedSessions.length).toBeGreaterThanOrEqual(2); + expect(serveClosed).toBeGreaterThanOrEqual(1); + const recovered = parseBrowserRecoveryHandle(session.recoveryHandle!); + expect(existsSync(recovered.socketDirectory)).toBeFalse(); + expect(existsSync(recovered.artifactsDirectory)).toBeFalse(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("converges a Lightpanda cleanup while the daemon supervisor is still restarting", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + type PublishedCleanupResource = Parameters< + NonNullable< + Parameters[2]["publishCleanupResource"] + > + >[0]; + const published: PublishedCleanupResource[] = []; + let sessionInfoCalls = 0; + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "lightpanda", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + publishCleanupResource: Object.assign( + (resource: PublishedCleanupResource) => { + published.push(resource); + }, + { + markBrowserCleanupQuiescent: (): void => {}, + markBrowserCleanupRootRemoved: (): void => {}, + }, + ), + dependencies: { + runCommand: (command) => { + if (command.includes("info")) { + sessionInfoCalls += 1; + const resource = published[0]; + if (resource === undefined) { + throw new Error("session inspection ran before publication"); + } + // The daemon keeps reporting the just-closed CDP session active + // while its supervisor restarts; only after the owned serve + // child is reaped does it settle inactive. + const active = sessionInfoCalls <= 2; + return Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: active + ? { + active: true, + namespace: null, + pid: 43125, + runtime: { + browserLaunched: false, + effectiveLaunch: { + browserLaunched: false, + engine: "lightpanda", + launchHash: null, + }, + engine: "lightpanda", + }, + runtimeError: null, + session: resource.session, + socketDir: resource.socketDirectory, + version: "0.32.3", + } + : { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session: resource.session, + socketDir: resource.socketDirectory, + version: null, + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [], + warnings: [], + }), + createLightpandaDependencies: () => ({ + run: () => Promise.resolve({ ok: true }), + runBatch: () => Promise.resolve(), + close: () => Promise.resolve(), + }), + cleanupLifecycle: { + sleep: () => Promise.resolve(), + }, + }, + }); + await session.close(); + await session.cleanup(); + expect(sessionInfoCalls).toBeGreaterThanOrEqual(4); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("auto resolves to Chromium through an unpublished preflight when the Lightpanda driver is incompatible", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + type PublishedCleanupResource = Parameters< + NonNullable< + Parameters[2]["publishCleanupResource"] + > + >[0]; + const published: PublishedCleanupResource[] = []; + const cleanupEvents: string[] = []; + let browserClosed = false; + let daemonLive = true; + let lightpandaFactories = 0; + let chromeLaunched = false; + const launchHash = "18446744073709551615"; + const exactLaunchHashJson = (value: unknown): string => + JSON.stringify(value).replaceAll( + `"launchHash":"${launchHash}"`, + `"launchHash":${launchHash}`, + ); + const lifecycle = { + effectiveLaunch: { + browserLaunched: true, + engine: "chrome", + launchHash, + }, + launched: false, + relaunchedBrowser: false, + restartedBackground: false, + restoreStatus: "not_configured", + reused: true, + saveStatus: "not_attempted", + } as const; + const sessionInfoFor = ( + session: string, + socketDir: string, + ): Record => ({ + success: true, + data: browserClosed && !daemonLive + ? { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session, + socketDir, + version: null, + } + : { + active: true, + namespace: null, + pid: process.pid, + runtime: { + backgroundPid: process.pid, + browserLaunched: !browserClosed, + compatibilityStatus: "current", + effectiveLaunch: browserClosed + ? { + browserLaunched: false, + engine: "chrome", + launchHash: null, + } + : lifecycle.effectiveLaunch, + engine: "chrome", + launchHash: browserClosed ? null : launchHash, + lifecycle: browserClosed + ? { + ...lifecycle, + effectiveLaunch: { + browserLaunched: false, + engine: "chrome", + launchHash: null, + }, + } + : lifecycle, + namespace: null, + pageCount: browserClosed ? 0 : 1, + restoreCheckFn: null, + restoreCheckText: null, + restoreCheckUrl: null, + restoreKey: null, + restoreLoadedPath: null, + restoreSave: "auto", + restoreSavedPath: null, + restoreStatus: "not_configured", + restoreStatusDetail: null, + restoreValidationPending: false, + saveStatus: "not_attempted", + session, + socketDir, + }, + runtimeError: null, + session, + socketDir, + version: "0.32.3", + }, + }); + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + publishCleanupResource: Object.assign( + (resource: PublishedCleanupResource) => { + published.push(resource); + }, + { + markBrowserCleanupQuiescent: (): void => { + cleanupEvents.push("journal-quiescent"); + }, + markBrowserCleanupRootRemoved: ( + _resource: PublishedCleanupResource, + rootName: "artifacts" | "socket", + ): void => { + cleanupEvents.push(`journal-${rootName}`); + }, + }, + ), + dependencies: { + runCommand: (command, options) => { + if (command.includes("batch")) { + chromeLaunched = true; + const batch = JSON.parse( + options.stdin ?? "[]", + ) as readonly unknown[]; + return Promise.resolve({ + stdout: `${JSON.stringify(batch.map(() => ({ + success: true, + data: null, + })))}\n`, + stderr: "", + exitCode: 0, + }); + } + const sessionIndex = command.indexOf("--session"); + const socketDir = + options.environment.AGENT_BROWSER_SOCKET_DIR ?? ""; + if (command.includes("info")) { + const requested = command[sessionIndex + 1] ?? ""; + if (requested !== published[0]?.session) { + // The unpublished preflight identity is always reported + // inactive once its owned serve child is reaped. + return Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session: requested, + socketDir, + version: null, + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: `${exactLaunchHashJson(sessionInfoFor( + requested, + socketDir, + ))}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("cdp-url")) { + return Promise.resolve({ + stdout: `${exactLaunchHashJson({ + success: true, + data: { + cdpUrl: "ws://127.0.0.1:43125/devtools/browser/exact-test", + lifecycle, + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("close")) browserClosed = true; + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [], + warnings: [], + }), + createLightpandaDependencies: () => { + lightpandaFactories += 1; + return { + run: () => Promise.resolve({ ok: true }), + runBatch: (_globals, commands) => { + if ( + commands.some((command) => command[0] === "open") + ) { + return Promise.reject(new LightpandaCompatibilityError(true)); + } + return Promise.resolve(); + }, + close: () => Promise.resolve(), + }; + }, + cleanupLifecycle: { + ownerStatus: () => daemonLive + ? "exact-live-owner" + : "different-or-dead", + terminateOwner: () => { + daemonLive = false; + }, + cdpEndpointStatus: () => Promise.resolve("unavailable"), + sleep: () => Promise.resolve(), + }, + }, + }); + await session.close(); + await session.cleanup(); + expect(chromeLaunched).toBeTrue(); + expect(lightpandaFactories).toBe(1); + expect( + published.every((resource) => + resource.session === published[0]?.session + ), + ).toBeTrue(); + expect(published.map((resource) => resource.kind === "agent-browser-session-v2" + ? resource.phase + : "legacy")).toEqual([ + "prepared", + "launch-intent", + "controlled", + ]); + expect(cleanupEvents).toEqual([ + "journal-quiescent", + "journal-artifacts", + "journal-socket", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("auto resolves to Chromium when the daemon keeps the failed preflight session active", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + type PublishedCleanupResource = Parameters< + NonNullable< + Parameters[2]["publishCleanupResource"] + > + >[0]; + const published: PublishedCleanupResource[] = []; + let chromeLaunched = false; + let lightpandaFactories = 0; + let preflightArtifactsDirectory: string | null = null; + let preflightSocketDirectory: string | null = null; + const launchHash = "18446744073709551615"; + const exactLaunchHashJson = (value: unknown): string => + JSON.stringify(value).replaceAll( + `"launchHash":"${launchHash}"`, + `"launchHash":${launchHash}`, + ); + const lifecycle = { + effectiveLaunch: { + browserLaunched: true, + engine: "chrome", + launchHash, + }, + launched: false, + relaunchedBrowser: false, + restartedBackground: false, + restoreStatus: "not_configured", + reused: true, + saveStatus: "not_attempted", + } as const; + let browserClosed = false; + let daemonLive = true; + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + publishCleanupResource: Object.assign( + (resource: PublishedCleanupResource) => { + published.push(resource); + }, + { + markBrowserCleanupQuiescent: (): void => {}, + markBrowserCleanupRootRemoved: (): void => {}, + }, + ), + dependencies: { + runCommand: (command, options) => { + if (command.includes("batch")) { + chromeLaunched = true; + const batch = JSON.parse( + options.stdin ?? "[]", + ) as readonly unknown[]; + return Promise.resolve({ + stdout: `${JSON.stringify(batch.map(() => ({ + success: true, + data: null, + })))}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("info")) { + const sessionIndex = command.indexOf("--session"); + const requested = command[sessionIndex + 1] ?? ""; + const socketDir = + options.environment.AGENT_BROWSER_SOCKET_DIR ?? ""; + if (requested !== published[0]?.session) { + // The daemon keeps the failed preflight session active; the + // fallback must not depend on that view converging. + return Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: true, + namespace: null, + pid: 43126, + runtime: { + browserLaunched: false, + effectiveLaunch: { + browserLaunched: false, + engine: "lightpanda", + launchHash: null, + }, + engine: "lightpanda", + }, + runtimeError: null, + session: requested, + socketDir, + version: "0.32.3", + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: `${exactLaunchHashJson({ + success: true, + data: browserClosed && !daemonLive + ? { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session: requested, + socketDir, + version: null, + } + : { + active: true, + namespace: null, + pid: process.pid, + runtime: { + backgroundPid: process.pid, + browserLaunched: !browserClosed, + compatibilityStatus: "current", + effectiveLaunch: browserClosed + ? { + browserLaunched: false, + engine: "chrome", + launchHash: null, + } + : lifecycle.effectiveLaunch, + engine: "chrome", + launchHash: browserClosed ? null : launchHash, + lifecycle: browserClosed + ? { + ...lifecycle, + effectiveLaunch: { + browserLaunched: false, + engine: "chrome", + launchHash: null, + }, + } + : lifecycle, + namespace: null, + pageCount: browserClosed ? 0 : 1, + restoreCheckFn: null, + restoreCheckText: null, + restoreCheckUrl: null, + restoreKey: null, + restoreLoadedPath: null, + restoreSave: "auto", + restoreSavedPath: null, + restoreStatus: "not_configured", + restoreStatusDetail: null, + restoreValidationPending: false, + saveStatus: "not_attempted", + session: requested, + socketDir, + }, + runtimeError: null, + session: requested, + socketDir, + version: "0.32.3", + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("cdp-url")) { + return Promise.resolve({ + stdout: `${exactLaunchHashJson({ + success: true, + data: { + cdpUrl: "ws://127.0.0.1:43125/devtools/browser/exact-test", + lifecycle, + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("close")) browserClosed = true; + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [], + warnings: [], + }), + createLightpandaDependencies: () => { + lightpandaFactories += 1; + return { + run: (_globals, command) => { + if (command[0] === "close") { + return Promise.resolve({ closed: true }); + } + return Promise.resolve({ ok: true }); + }, + runBatch: (_globals, commands, options) => { + preflightArtifactsDirectory = options.cwd; + preflightSocketDirectory = + options.environment.AGENT_BROWSER_SOCKET_DIR ?? null; + if (commands.some((command) => command[0] === "open")) { + return Promise.reject( + new LightpandaCompatibilityError(true), + ); + } + return Promise.resolve(); + }, + close: () => Promise.resolve(), + }; + }, + cleanupLifecycle: { + ownerStatus: () => daemonLive + ? "exact-live-owner" + : "different-or-dead", + terminateOwner: () => { + daemonLive = false; + }, + cdpEndpointStatus: () => Promise.resolve("unavailable"), + sleep: () => Promise.resolve(), + }, + }, + }); + await session.close(); + await session.cleanup(); + expect(chromeLaunched).toBeTrue(); + expect(lightpandaFactories).toBe(1); + expect(published.map((resource) => resource.kind === "agent-browser-session-v2" + ? resource.phase + : "legacy")).toEqual([ + "prepared", + "launch-intent", + "controlled", + ]); + expect(preflightArtifactsDirectory).not.toBeNull(); + expect(preflightSocketDirectory).not.toBeNull(); + // The failed probe's teardown was complete, so its throwaway roots are + // removed even though the daemon's view never converged. + expect(existsSync(preflightArtifactsDirectory!)).toBeFalse(); + expect(existsSync(preflightSocketDirectory!)).toBeFalse(); + } finally { + if (preflightArtifactsDirectory !== null) { + rmSync(preflightArtifactsDirectory, { recursive: true, force: true }); + } + if (preflightSocketDirectory !== null) { + rmSync(preflightSocketDirectory, { recursive: true, force: true }); + } + rmSync(root, { recursive: true, force: true }); + } + }); + + test("auto resolves to Chromium but preserves preflight roots when inactivity never converges", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + type PublishedCleanupResource = Parameters< + NonNullable< + Parameters[2]["publishCleanupResource"] + > + >[0]; + const published: PublishedCleanupResource[] = []; + let chromeLaunched = false; + let lightpandaFactories = 0; + let preflightArtifactsDirectory: string | null = null; + let preflightSocketDirectory: string | null = null; + const launchHash = "18446744073709551615"; + const exactLaunchHashJson = (value: unknown): string => + JSON.stringify(value).replaceAll( + `"launchHash":"${launchHash}"`, + `"launchHash":${launchHash}`, + ); + const lifecycle = { + effectiveLaunch: { + browserLaunched: true, + engine: "chrome", + launchHash, + }, + launched: false, + relaunchedBrowser: false, + restartedBackground: false, + restoreStatus: "not_configured", + reused: true, + saveStatus: "not_attempted", + } as const; + let browserClosed = false; + let daemonLive = true; + let convergenceNow = 0; + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + publishCleanupResource: Object.assign( + (resource: PublishedCleanupResource) => { + published.push(resource); + }, + { + markBrowserCleanupQuiescent: (): void => {}, + markBrowserCleanupRootRemoved: (): void => {}, + }, + ), + dependencies: { + runCommand: (command, options) => { + if (command.includes("batch")) { + chromeLaunched = true; + const batch = JSON.parse( + options.stdin ?? "[]", + ) as readonly unknown[]; + return Promise.resolve({ + stdout: `${JSON.stringify(batch.map(() => ({ + success: true, + data: null, + })))}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("info")) { + const sessionIndex = command.indexOf("--session"); + const requested = command[sessionIndex + 1] ?? ""; + const socketDir = + options.environment.AGENT_BROWSER_SOCKET_DIR ?? ""; + if (requested !== published[0]?.session) { + // The daemon never observes the closed preflight session as + // inactive inside the bounded convergence window. + return Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: true, + namespace: null, + pid: 43126, + runtime: { + browserLaunched: false, + effectiveLaunch: { + browserLaunched: false, + engine: "lightpanda", + launchHash: null, + }, + engine: "lightpanda", + }, + runtimeError: null, + session: requested, + socketDir, + version: "0.32.3", + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: `${exactLaunchHashJson({ + success: true, + data: browserClosed && !daemonLive + ? { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session: requested, + socketDir, + version: null, + } + : { + active: true, + namespace: null, + pid: process.pid, + runtime: { + backgroundPid: process.pid, + browserLaunched: !browserClosed, + compatibilityStatus: "current", + effectiveLaunch: browserClosed + ? { + browserLaunched: false, + engine: "chrome", + launchHash: null, + } + : lifecycle.effectiveLaunch, + engine: "chrome", + launchHash: browserClosed ? null : launchHash, + lifecycle: browserClosed + ? { + ...lifecycle, + effectiveLaunch: { + browserLaunched: false, + engine: "chrome", + launchHash: null, + }, + } + : lifecycle, + namespace: null, + pageCount: browserClosed ? 0 : 1, + restoreCheckFn: null, + restoreCheckText: null, + restoreCheckUrl: null, + restoreKey: null, + restoreLoadedPath: null, + restoreSave: "auto", + restoreSavedPath: null, + restoreStatus: "not_configured", + restoreStatusDetail: null, + restoreValidationPending: false, + saveStatus: "not_attempted", + session: requested, + socketDir, + }, + runtimeError: null, + session: requested, + socketDir, + version: "0.32.3", + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("cdp-url")) { + return Promise.resolve({ + stdout: `${exactLaunchHashJson({ + success: true, + data: { + cdpUrl: "ws://127.0.0.1:43125/devtools/browser/exact-test", + lifecycle, + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("close")) browserClosed = true; + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [], + warnings: [], + }), + createLightpandaDependencies: () => { + lightpandaFactories += 1; + return { + run: (_globals, command) => { + if (command[0] === "close") { + return Promise.resolve({ closed: true }); + } + return Promise.resolve({ ok: true }); + }, + runBatch: (_globals, _commands, options) => { + preflightArtifactsDirectory = options.cwd; + preflightSocketDirectory = + options.environment.AGENT_BROWSER_SOCKET_DIR ?? null; + return Promise.resolve(); + }, + close: () => Promise.resolve(), + }; + }, + cleanupLifecycle: { + ownerStatus: () => daemonLive + ? "exact-live-owner" + : "different-or-dead", + terminateOwner: () => { + daemonLive = false; + }, + cdpEndpointStatus: () => Promise.resolve("unavailable"), + sleep: () => Promise.resolve(), + // Advance half a second per read: the preflight convergence window + // exhausts its retries in a few attempts while the Chromium + // cleanup's three-refusal endpoint loop still fits its bound. + now: () => (convergenceNow += 500), + }, + }, + }); + await session.close(); + await session.cleanup(); + expect(chromeLaunched).toBeTrue(); + expect(lightpandaFactories).toBe(1); + expect(published.map((resource) => resource.kind === "agent-browser-session-v2" + ? resource.phase + : "legacy")).toEqual([ + "prepared", + "launch-intent", + "controlled", + ]); + expect(preflightArtifactsDirectory).not.toBeNull(); + expect(preflightSocketDirectory).not.toBeNull(); + // Quiescence was never proved, so the throwaway roots are preserved + // rather than deleted under an uncertain daemon view. + expect(existsSync(preflightArtifactsDirectory!)).toBeTrue(); + expect(existsSync(preflightSocketDirectory!)).toBeTrue(); + } finally { + if (preflightArtifactsDirectory !== null) { + rmSync(preflightArtifactsDirectory, { recursive: true, force: true }); + } + if (preflightSocketDirectory !== null) { + rmSync(preflightSocketDirectory, { recursive: true, force: true }); + } + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects a foreign session's inactive report during launch-intent quiescence", async () => { + const artifactsDirectory = mkdtempSync(join(tmpdir(), "io-browser-")); + const socketDirectory = mkdtempSync(join("/tmp", "io-ab-")); + try { + const configPath = join(artifactsDirectory, "agent-browser.json"); + writeFileSync(configPath, "{}\n", { mode: 0o600 }); + const session = `io-${process.pid}-${"a".repeat(12)}`; + const directoryIdentity = (path: string) => { + const stats = lstatSync(path, { bigint: true }); + return { + device: stats.dev.toString(), + inode: stats.ino.toString(), + birthtimeNs: stats.birthtimeNs.toString(), + mode: "448" as const, + uid: stats.uid.toString(), + }; + }; + const resource = parseBrowserCleanupResourceIdentity({ + kind: "agent-browser-session-v2", + recoveryHandle: browserRecoveryHandle({ + session, + configPath, + socketDirectory, + artifactsDirectory, + }), + session, + socketDirectory, + socketDirectoryIdentity: directoryIdentity(socketDirectory), + artifactsDirectory, + artifactsDirectoryIdentity: directoryIdentity(artifactsDirectory), + phase: "launch-intent", + control: null, + }) as BrowserCleanupResourceIdentityV2; + const failure = await rejectionValue( + proveLaunchIntentAgentBrowserCleanupResourceQuiescent(resource, { + runCommand: () => Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session: `io-${process.pid}-${"b".repeat(12)}`, + socketDir: socketDirectory, + version: null, + }, + })}\n`, + stderr: "", + exitCode: 0, + }), + }), + ); + expect(failure).toBeInstanceOf(Error); + const socketMismatch = await rejectionValue( + proveLaunchIntentAgentBrowserCleanupResourceQuiescent(resource, { + runCommand: () => Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session, + socketDir: `${socketDirectory}-other`, + version: null, + }, + })}\n`, + stderr: "", + exitCode: 0, + }), + }), + ); + expect(socketMismatch).toBeInstanceOf(Error); + } finally { + rmSync(artifactsDirectory, { recursive: true, force: true }); + rmSync(socketDirectory, { recursive: true, force: true }); + } + }); + + test("fails closed when a post-publish incompatibility cannot register the Chromium retry", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + type PublishedCleanupResource = Parameters< + NonNullable< + Parameters[2]["publishCleanupResource"] + > + >[0]; + const published: PublishedCleanupResource[] = []; + let lightpandaFactories = 0; + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const failure = await rejectionValue(createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "auto", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + publishCleanupResource: Object.assign( + (resource: PublishedCleanupResource) => { + // Model the durable registrar: only an identical session may + // republish; a second attempt's fresh identity is rejected. + if ( + published.length > 0 + && resource.session !== published[0]?.session + ) { + throw new Error("browser cleanup resource identity drifted"); + } + published.push(resource); + }, + { + markBrowserCleanupQuiescent: (): void => {}, + markBrowserCleanupRootRemoved: (): void => {}, + }, + ), + dependencies: { + runCommand: (command, options) => { + if (command.includes("batch")) { + const batch = JSON.parse( + options.stdin ?? "[]", + ) as readonly unknown[]; + return Promise.resolve({ + stdout: `${JSON.stringify(batch.map(() => ({ + success: true, + data: null, + })))}\n`, + stderr: "", + exitCode: 0, + }); + } + if (command.includes("info")) { + const sessionIndex = command.indexOf("--session"); + const requested = command[sessionIndex + 1] ?? ""; + const resource = published[0]; + return Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: false, + namespace: null, + pid: null, + runtime: null, + runtimeError: null, + session: requested, + socketDir: resource === undefined + ? options.environment.AGENT_BROWSER_SOCKET_DIR ?? "" + : resource.socketDirectory, + version: null, + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [], + warnings: [], + }), + createLightpandaDependencies: () => { + lightpandaFactories += 1; + const preflight = lightpandaFactories === 1; + return { + run: () => Promise.resolve({ ok: true }), + runBatch: () => preflight + ? Promise.resolve() + : Promise.reject(new LightpandaCompatibilityError(true)), + close: () => Promise.resolve(), + }; + }, + cleanupLifecycle: { + sleep: () => Promise.resolve(), + }, + }, + })); + // The compat failure arrived after the launch-intent identity was + // durable, so the Chromium retry cannot register a second identity; the + // session fails closed with its roots preserved or already journaled. + expect(failure).toBeInstanceOf(PreservedBrowserArtifactsError); + expect(lightpandaFactories).toBe(2); + const first = published[0]; + const latest = published[published.length - 1]; + if (first === undefined || latest === undefined) { + throw new Error("the Lightpanda attempt never published"); + } + expect(latest.kind).toBe("agent-browser-session-v2"); + if (latest.kind !== "agent-browser-session-v2") { + throw new Error("the Lightpanda attempt published a legacy resource"); + } + expect(latest.phase).toBe("launch-intent"); + expect(existsSync(first.socketDirectory)).toBeFalse(); + expect(existsSync(first.artifactsDirectory)).toBeFalse(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a stale LIGHTPANDA_PATH never affects an explicit Chromium session", async () => { + let lightpandaFactories = 0; + let chromeBatches = 0; + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "chrome", + environment: { + GHOSTGET_LIGHTPANDA_PATH: "/nonexistent/lightpanda", + LIGHTPANDA_PATH: "/also/nonexistent/lightpanda", + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + dependencies: { + runCommand: (command, options) => { + if (command.includes("batch")) { + chromeBatches += 1; + const batch = JSON.parse( + options.stdin ?? "[]", + ) as readonly unknown[]; + return Promise.resolve({ + stdout: `${JSON.stringify(batch.map(() => ({ + success: true, + data: null, + })))}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [], + warnings: [], + }), + createLightpandaDependencies: () => { + lightpandaFactories += 1; + throw new Error("a Chromium session must not resolve Lightpanda"); + }, + }, + }); + await session.close(); + await session.cleanup(); + expect(lightpandaFactories).toBe(0); + expect(chromeBatches).toBeGreaterThan(0); + }); + + test("keeps imported cookie values inside the Lightpanda stdin batch channel", async () => { + const root = mkdtempSync(join(tmpdir(), "io-lightpanda-stub-")); + const secret = "private-cookie-value-0123456789"; + const runArgv: string[][] = []; + const batchCommands: string[][] = []; + try { + const stub = join(root, "lightpanda"); + writeFileSync(stub, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const session = await createBrowserSession(manifest, auth, { + headed: false, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + engine: "lightpanda", + environment: { + GHOSTGET_LIGHTPANDA_PATH: stub, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + dependencies: { + runCommand: (command) => { + runArgv.push([...command]); + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + startNetworkProxy: () => Promise.resolve({ + url: "http://127.0.0.1:43124", + port: 43_124, + close: () => Promise.resolve(), + }), + acquireCookieRecords: () => Promise.resolve({ + cookies: [{ + name: "session", + value: secret, + domain: "example.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + sameSite: "Lax" as const, + expires: 0, + }], + warnings: [], + }), + createLightpandaDependencies: () => ({ + run: (globalArguments, command) => { + runArgv.push([...globalArguments, ...command]); + return Promise.resolve({ ok: true }); + }, + runBatch: (_globals, commands) => { + for (const command of commands) { + batchCommands.push([...command]); + } + return Promise.resolve(); + }, + close: () => Promise.resolve(), + }), + }, + }); + await session.close(); + await session.cleanup(); + const cookieCommand = batchCommands.find( + (command) => command[0] === "cookies", + ); + expect(cookieCommand?.slice(0, 4)).toEqual([ + "cookies", + "set", + "session", + secret, + ]); + for (const argv of runArgv) { + expect(argv).not.toContain(secret); + } + expect(batchCommands.every((command) => command[0] !== "batch")) + .toBeTrue(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects an active Lightpanda session during Chromium control binding", async () => { + const artifactsDirectory = mkdtempSync(join(tmpdir(), "io-browser-")); + const socketDirectory = mkdtempSync(join("/tmp", "io-ab-")); + chmodSync(artifactsDirectory, 0o700); + chmodSync(socketDirectory, 0o700); + const session = "io-125-abcdef123456"; + const configPath = join(artifactsDirectory, "agent-browser.json"); + writeFileSync(configPath, "{}\n", { mode: 0o600 }); + const statsIdentity = (path: string) => { + const stats = lstatSync(path, { bigint: true }); + return { + device: stats.dev.toString(), + inode: stats.ino.toString(), + birthtimeNs: stats.birthtimeNs.toString(), + mode: "448" as const, + uid: stats.uid.toString(), + }; + }; + try { + const launchIntent = parseBrowserCleanupResourceIdentity({ + kind: "agent-browser-session-v2", + recoveryHandle: browserRecoveryHandle({ + session, + configPath, + socketDirectory, + artifactsDirectory, + }), + session, + socketDirectory, + socketDirectoryIdentity: statsIdentity(socketDirectory), + artifactsDirectory, + artifactsDirectoryIdentity: statsIdentity(artifactsDirectory), + phase: "launch-intent", + control: null, + }) as BrowserCleanupResourceIdentityV2; + const rejection = await rejectionMessage( + bindLiveAgentBrowserCleanupResource(launchIntent, { + captureOwner: () => ({ + pid: 43125, + bootId: "a".repeat(64), + processStartId: "b".repeat(64), + }), + ownerStatus: () => "exact-live-owner", + runCommand: (command) => { + if (command.includes("info")) { + return Promise.resolve({ + stdout: `${JSON.stringify({ + success: true, + data: { + active: true, + namespace: null, + pid: 43125, + runtime: { + browserLaunched: false, + effectiveLaunch: { + browserLaunched: false, + engine: "lightpanda", + launchHash: null, + }, + engine: "lightpanda", + }, + runtimeError: null, + session, + socketDir: socketDirectory, + version: "0.32.3", + }, + })}\n`, + stderr: "", + exitCode: 0, + }); + } + return Promise.resolve({ + stdout: "{\"success\":true}\n", + stderr: "", + exitCode: 0, + }); + }, + }), + ); + expect(rejection).toContain("runtime is malformed"); + } finally { + rmSync(socketDirectory, { recursive: true, force: true }); + rmSync(artifactsDirectory, { recursive: true, force: true }); + } + }); }); diff --git a/src/browser.ts b/src/browser.ts index 52d67d8f..5e37bc35 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -40,6 +40,12 @@ import type { } from "./model"; import { localBrowserCdpUrl } from "./derivation-file-chooser"; import { DOM_ACTION_TRANSPORT_DISABLED_MESSAGE } from "./transport-policy"; +import { + createLightpandaDependencies, + LightpandaCompatibilityError, + resolveLightpandaExecutable, + type BrowserEngineSelection, +} from "./lightpanda-browser"; import { captureProcessOwnerIdentity, processOwnerStatus, @@ -128,6 +134,8 @@ export type BrowserSessionDependencies = { readonly removePrivateArtifact: BrowserPrivateArtifactRemover; /** Internal seam for deterministic cleanup quiescence and deletion reproof. */ readonly cleanupLifecycle: AgentBrowserLifecycleDependencies; + /** Owned Lightpanda serve lifecycle used when the session engine resolves to Lightpanda. */ + readonly createLightpandaDependencies: typeof createLightpandaDependencies; }; /** Provider-neutral borrowed operation budget used by browser bootstraps. */ @@ -172,6 +180,19 @@ export type CreateBrowserSessionOptions = { root: BrowserCleanupResourceRoot, ) => void; }; + /** + * Engine selection for the contained session. "chrome" is the default and + * preserves the existing Chromium lane. "lightpanda" requires an auth realm + * that yields explicit cookies and a provisioned Lightpanda 1.0.0 binary. + * "auto" resolves to Lightpanda only when both requirements hold, and a + * pre-navigation Lightpanda protocol-compatibility failure retries once on + * Chromium inside the same call; when cleanup publication is configured, the + * compat probe runs on unpublished throwaway roots first so a retry never + * has to register a second durable identity. Every other failure propagates. + */ + readonly engine?: BrowserEngineSelection; + /** Environment source for executable resolution and process isolation. */ + readonly environment?: Readonly>; readonly dependencies?: Partial; }; @@ -1977,6 +1998,49 @@ function parseAgentBrowserSessionState( }); } +/** + * Engine-agnostic quiescence read for unbound launch-intent proofs. The strict + * session parser rejects an active non-Chrome runtime outright, but a + * CDP-attached Lightpanda session keeps reporting active until its owned serve + * child is reaped — quiescence only needs the exact active flag and the + * session/socket identity pins. + */ +function parseAgentBrowserSessionQuiescence( + value: unknown, + resource: BrowserCleanupResourceIdentity, +): "active" | "inactive" { + const root = unwrapAgentBrowserIdentityResult( + value, + "agent-browser session result", + ); + const data = browserIdentityRecord(root.data, "agent-browser session data"); + browserIdentityExactKeys(data, [ + "active", + "namespace", + "pid", + "runtime", + "runtimeError", + "session", + "socketDir", + "version", + ], "agent-browser session data"); + if ( + root.success !== true + || data.namespace !== null + || data.runtimeError !== null + || data.session !== resource.session + || data.socketDir !== resource.socketDirectory + ) throw new Error("agent-browser session identity changed"); + if (data.active === true) return "active"; + if ( + data.active === false + && data.pid === null + && data.runtime === null + && data.version === null + ) return "inactive"; + throw new Error("agent-browser inactive session changed shape"); +} + function parseAgentBrowserCdpControl( value: unknown, ): { @@ -2149,11 +2213,11 @@ export async function proveLaunchIntentAgentBrowserCleanupResourceQuiescent( const lifecycle = browserLifecycleCommandContext(resource, dependencies); assertBrowserCleanupResourceRootsMatch(resource); for (let read = 0; read < 2; read += 1) { - const inactive = parseAgentBrowserSessionState( + const quiescence = parseAgentBrowserSessionQuiescence( await lifecycle.inspectSession(), resource, ); - if (inactive.state !== "inactive") { + if (quiescence !== "inactive") { throw new Error(read === 0 ? "browser cleanup launch-intent session remained active" : "browser cleanup launch-intent session quiescence changed"); @@ -3000,6 +3064,8 @@ const BROWSER_CLEANUP_PROOF_SETTLING_MESSAGES: ReadonlySet = new Set([ "browser cleanup daemon quiescence is unproved", "browser cleanup endpoint refusal is unproved", "browser cleanup endpoint remained available", + "browser cleanup launch-intent session quiescence changed", + "browser cleanup launch-intent session remained active", "browser cleanup pinned owner is not quiescent", "browser cleanup pinned owner quiescence changed", "browser cleanup post-close convergence deadline expired", @@ -3099,6 +3165,7 @@ export async function convergeBrowserCleanupResourceProof( } retryable = true; } + if (dependencies.commandSignal?.aborted === true) retryable = false; if (!retryable) throw error; lastSettlingFailure = error; if (attempts >= maximumAttempts || now() >= deadline) throw error; @@ -3266,7 +3333,7 @@ export async function reproveBrowserCleanupAfterArtifactsRemoval( assertBrowserDeletionBoundaryRoots(resource); const runner = dependencies.runCommand ?? runCommand; const environment = isolatedEnvironment(resource.socketDirectory); - const inspectSession = async (): Promise => { + const inspectSession = async (): Promise<"active" | "inactive"> => { let result: CommandResult; try { result = await runner([ @@ -3302,15 +3369,19 @@ export async function reproveBrowserCleanupAfterArtifactsRemoval( } catch { throw new Error("browser cleanup session inspection changed shape"); } - return parseAgentBrowserSessionState(parsed, resource); + if (resource.phase === "launch-intent") { + return parseAgentBrowserSessionQuiescence(parsed, resource); + } + const state = parseAgentBrowserSessionState(parsed, resource); + return state.state; }; const first = await inspectSession(); - if (first.state !== "inactive") { + if (first !== "inactive") { throw new Error("browser cleanup session remained active"); } assertBrowserDeletionBoundaryRoots(resource); const second = await inspectSession(); - if (second.state !== "inactive") { + if (second !== "inactive") { throw new Error("browser cleanup session quiescence changed"); } assertBrowserDeletionBoundaryRoots(resource); @@ -3385,6 +3456,10 @@ const BROWSER_CLOSE_TEARDOWN_TIMEOUT_MS = 17_500; // 30-second cleanup join. const BROWSER_POST_CLOSE_CONVERGENCE_TIMEOUT_MS = 10_000; const BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS = 2_000; +// The owned Lightpanda serve child's own close bound is ~4s (1s SIGTERM-to- +// SIGKILL grace plus a 3s exit wait); the generic 2s resource teardown bound +// would report a healthy reap as unsettled. +const LIGHTPANDA_SERVE_TEARDOWN_TIMEOUT_MS = 6_000; const BROWSER_ACTIVE_BATCH_SETTLEMENT_TIMEOUT_MS = 2_500; function guardBrowserSetup(deadline: BrowserOperationDeadline | undefined): void { @@ -3465,10 +3540,385 @@ function cleanupFailureCause( ); } +/** + * An auth realm can drive a Lightpanda session only when it yields explicit + * cookies for the contained context. Chrome-profile state and storage-state + * files have no Lightpanda equivalent. + */ +function browserSessionYieldsCookies(auth: GhostgetAuth): boolean { + return auth.kind === "cookie-source" + || auth.kind === "cookies-file" + || (auth.kind === "browser-profile" + && auth.cookieSource !== undefined + && auth.storageState === undefined); +} + +function selectBrowserSessionEngine( + auth: GhostgetAuth, + selection: BrowserEngineSelection, + environment: Readonly>, +): "chrome" | "lightpanda" { + if (selection !== "auto" && selection !== "chrome" && selection !== "lightpanda") { + throw new Error("browser session engine must be auto, chrome, or lightpanda"); + } + const eligible = browserSessionYieldsCookies(auth); + if (selection === "lightpanda" && !eligible) { + throw new Error( + "Lightpanda browser sessions require an auth realm that yields explicit cookies", + ); + } + // The Chrome lane never launches Lightpanda — resolving the executable here + // would let a stale LIGHTPANDA_PATH break unrelated Chromium sessions. + const executable = selection !== "chrome" && eligible + ? resolveLightpandaExecutable(environment) + : null; + if (selection === "lightpanda" && executable === null) { + throw new Error( + "Lightpanda browser sessions require GHOSTGET_LIGHTPANDA_PATH or LIGHTPANDA_PATH pointing to Lightpanda 1.0.0", + ); + } + if (selection === "auto") { + return eligible && executable !== null ? "lightpanda" : "chrome"; + } + return selection; +} + +/** + * Prove the exact pre-navigation command surface on unpublished throwaway + * roots. Once a session's durable `prepared` resource is published, the cleanup + * registrar accepts only an identical or monotonic extension — a Chromium retry + * under a fresh identity can never land. An incompatible Lightpanda driver must + * therefore resolve `auto` to Chromium here, before any durable identity + * exists. Any non-compat failure still aborts the session outright. + */ +async function preflightLightpandaBrowserSession( + manifest: GhostgetManifest, + options: CreateBrowserSessionOptions, + sessionEnvironment: Readonly>, +): Promise { + const operationDeadline = options.operationDeadline; + const runBrowserCommand = options.dependencies?.runCommand ?? runCommand; + const createNetworkProxy = options.dependencies?.startNetworkProxy ?? startNetworkProxy; + const createLightpanda = options.dependencies?.createLightpandaDependencies + ?? createLightpandaDependencies; + const removePrivateArtifact = options.dependencies?.removePrivateArtifact + ?? ((path: string): void => rmSync(path, { recursive: true, force: true })); + const executable = resolveLightpandaExecutable(sessionEnvironment); + if (executable === null) { + // Engine selection already proved a provisioned binary; a resolution drift + // between selection and preflight is not a protocol incompatibility. + throw new Error("Lightpanda browser executable is no longer available"); + } + const firstOrigin = manifest.origins[0]; + if (firstOrigin === undefined) { + throw new Error("contained browser session requires one reviewed origin"); + } + const session = `io-${process.pid}-${crypto.randomUUID().slice(0, 12)}`; + const directory = mkdtempSync(join(tmpdir(), "io-browser-")); + let socketDirectory: string; + try { + socketDirectory = mkdtempSync(join("/tmp", "io-ab-")); + } catch (error) { + try { + removePrivateArtifact(directory); + } catch { + // The socket-root failure is the diagnostic; a removal failure does not + // mask it. + } + throw error; + } + const configPath = join(directory, "agent-browser.json"); + const policyPath = join(directory, "action-policy.json"); + let networkProxy: LocalNetworkProxy | null = null; + // Mirrors the contained-session path: a deadline abort can leave the proxy + // creation promise resolving after the setup step rejected, so the pending + // promise is tracked and closed late in teardown. + const networkProxyCreation: { + pending: Promise | null; + } = { pending: null }; + let lightpanda: ReturnType | null = null; + let probeError: unknown = null; + try { + chmodSync(directory, 0o700); + chmodSync(socketDirectory, 0o700); + writeFileSync(configPath, "{}\n", { mode: 0o600, flag: "wx" }); + writeFileSync(policyPath, `${JSON.stringify({ + default: "deny", + allow: runtimeBrowserPolicyActions, + })}\n`, { mode: 0o600, flag: "wx" }); + const environment = isolatedEnvironment(socketDirectory, sessionEnvironment); + networkProxy = await runBrowserSetupStep( + operationDeadline, + () => { + const creation = createNetworkProxy({ + allowPrivateNetwork: false, + timeoutMs: remainingBrowserSetupTime(options.timeoutMs, operationDeadline), + maxTransferredBytes: 1024 * 1024, + }); + networkProxyCreation.pending = creation; + return creation; + }, + ); + networkProxyCreation.pending = null; + const proxy = networkProxy; + const globalArguments = [ + "--config", + configPath, + "--session", + session, + "--content-boundaries", + "--max-output", + String(options.maxOutputBytes), + "--action-policy", + policyPath, + "--proxy", + proxy.url, + ]; + const dependencies = createLightpanda(executable, Number.POSITIVE_INFINITY); + lightpanda = dependencies; + const commandOptions = { + cwd: directory, + environment, + timeoutMs: remainingBrowserSetupTime(options.timeoutMs, operationDeadline), + maxOutputBytes: options.maxOutputBytes, + }; + await runBrowserSetupStep( + operationDeadline, + () => dependencies.runBatch!( + globalArguments, + [["open", "about:blank"]], + commandOptions, + ), + ); + // Exercise one cookie import through the same stdin batch channel the + // session seeds with. + await runBrowserSetupStep( + operationDeadline, + () => dependencies.runBatch!( + globalArguments, + browserCookieCommands( + [{ + name: "ghostget-lightpanda-preflight", + value: "1", + domain: new URL(firstOrigin).hostname, + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + sameSite: "Lax", + expires: 0, + }], + new URL(firstOrigin), + ), + commandOptions, + ), + ); + await runBrowserSetupStep( + operationDeadline, + () => dependencies.run!(globalArguments, ["close"], { + ...commandOptions, + timeoutMs: Math.min( + commandOptions.timeoutMs, + BROWSER_CLOSE_TEARDOWN_TIMEOUT_MS, + ), + }), + ); + } catch (error) { + probeError = error; + } + const failures: unknown[] = []; + if (networkProxyCreation.pending !== null) { + const closeLateProxy = networkProxyCreation.pending.then( + (proxy) => proxy.close(), + () => undefined, + ); + if (!await teardownCompletesWithin( + closeLateProxy, + BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS, + )) { + failures.push( + new Error("Lightpanda preflight proxy creation did not settle safely"), + ); + } + networkProxyCreation.pending = null; + } + if (lightpanda !== null) { + const dependencies = lightpanda; + if (!await teardownCompletesWithin( + Promise.resolve().then(() => dependencies.close()), + LIGHTPANDA_SERVE_TEARDOWN_TIMEOUT_MS, + )) { + failures.push( + new Error("Lightpanda preflight browser process did not settle safely"), + ); + } + } + if (networkProxy !== null) { + const proxy = networkProxy; + if (await teardownCompletesWithin( + Promise.resolve().then(() => proxy.close()), + BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS, + )) { + networkProxy = null; + } else { + failures.push(new Error("Lightpanda preflight proxy did not settle safely")); + } + } + let quiescenceError: LightpandaCompatibilityError | null = null; + if (probeError === null && lightpanda !== null && failures.length === 0) { + // The published cleanup path requires the daemon to observe the closed CDP + // session inactive after the owned serve child is reaped; prove the same + // convergence on the throwaway identity, under the same bounded proof the + // real cleanup uses. A probe failure already decided the outcome, so the + // proof only runs after a fully successful probe. + let launchIntentResource: BrowserCleanupResourceIdentityV2 | null = null; + try { + const preparedResource = browserCleanupResourceIdentity({ + recoveryHandle: browserRecoveryHandle({ + session, + configPath, + socketDirectory, + artifactsDirectory: directory, + }), + session, + socketDirectory, + artifactsDirectory: directory, + }); + const launchIntent = parseBrowserCleanupResourceIdentity({ + ...preparedResource, + phase: "launch-intent", + control: null, + }); + if (launchIntent.kind === "agent-browser-session-v2") { + launchIntentResource = launchIntent; + } else { + throw new Error("browser cleanup launch intent is malformed"); + } + } catch (error) { + // A root-identity or recovery-handle fault is a local filesystem + // condition Chromium shares, not a Lightpanda incompatibility. + failures.push(error); + } + if (launchIntentResource !== null) { + const resource = launchIntentResource; + try { + await runBrowserSetupStep( + operationDeadline, + () => convergeBrowserCleanupResourceProof( + resource, + "full-roots", + { + ...options.dependencies?.cleanupLifecycle, + runCommand: runBrowserCommand, + ...(operationDeadline === undefined + ? {} + : { commandSignal: operationDeadline.signal }), + commandTimeoutMs: () => Math.max( + 1, + Math.min( + options.dependencies?.cleanupLifecycle?.commandTimeoutMs?.() + ?? 10_000, + operationDeadline === undefined + ? 10_000 + : Math.max(1, Math.floor(operationDeadline.remainingTimeMs())), + ), + ), + }, + ), + ); + } catch (error) { + // A daemon that cannot prove the closed session inactive inside the + // bounded convergence window cannot satisfy Lightpanda's cleanup + // contract anywhere, while Chromium does not depend on that state: + // classify it as protocol incompatibility so `auto` resolves to + // Chromium. Identity, boundary, and malformed-output faults are not + // settling conditions and stay fail-closed. + if ( + error instanceof AgentBrowserPostCloseTransitionStillSettlingError + || error instanceof AgentBrowserCleanupOwnerStillLiveError + || error instanceof AgentBrowserLifecycleCommandUnavailableError + || ( + error instanceof Error + && BROWSER_CLEANUP_PROOF_SETTLING_MESSAGES.has(error.message) + ) + ) { + quiescenceError = new LightpandaCompatibilityError( + true, + "Lightpanda session could not be proved inactive after close", + ); + } else { + failures.push(error); + } + } + } + } + if (failures.length === 0 && quiescenceError === null) { + const removalFailures = removePrivateArtifacts( + [socketDirectory, directory], + removePrivateArtifact, + ); + if (removalFailures.length > 0) failures.push(...removalFailures); + } + if (failures.length === 0) { + // Quiescence stayed unproved, so the throwaway roots are preserved rather + // than deleted; the bare compat error still reaches the Chromium retry. + if (quiescenceError !== null) throw quiescenceError; + if (probeError !== null) throw probeError; + return; + } + failures.push(new Error("Lightpanda preflight private roots were preserved")); + throw new AggregateError( + probeError === null ? failures : [probeError, ...failures], + "Lightpanda session preflight could not be verified", + ); +} + export async function createBrowserSession( manifest: GhostgetManifest, auth: GhostgetAuth, options: CreateBrowserSessionOptions, +): Promise { + const environment = options.environment ?? process.env; + const engine = selectBrowserSessionEngine(auth, options.engine ?? "chrome", environment); + if (engine !== "lightpanda") { + return createContainedBrowserSession(manifest, auth, options, "chrome", environment); + } + if (options.engine === "auto" && options.publishCleanupResource !== undefined) { + // The first published resource pins this session's durable cleanup + // identity; a post-publish Chromium retry could never register under a + // fresh one. Resolve `auto` incompatibility before publication. + try { + await preflightLightpandaBrowserSession(manifest, options, environment); + } catch (error) { + if ( + error instanceof LightpandaCompatibilityError + && error.beforeNavigation + ) { + return createContainedBrowserSession(manifest, auth, options, "chrome", environment); + } + throw error; + } + } + try { + return await createContainedBrowserSession(manifest, auth, options, "lightpanda", environment); + } catch (error) { + if ( + options.engine === "auto" + && error instanceof LightpandaCompatibilityError + && error.beforeNavigation + ) { + return createContainedBrowserSession(manifest, auth, options, "chrome", environment); + } + throw error; + } +} + +async function createContainedBrowserSession( + manifest: GhostgetManifest, + auth: GhostgetAuth, + options: CreateBrowserSessionOptions, + engine: "chrome" | "lightpanda", + sessionEnvironment: Readonly>, ): Promise { if (process.platform === "win32") { throw new Error( @@ -3480,6 +3930,8 @@ export async function createBrowserSession( const runBrowserCommand = options.dependencies?.runCommand ?? runCommand; const createNetworkProxy = options.dependencies?.startNetworkProxy ?? startNetworkProxy; const readCookies = options.dependencies?.acquireCookieRecords ?? acquireCookieRecords; + const createLightpanda = options.dependencies?.createLightpandaDependencies + ?? createLightpandaDependencies; const removePrivateArtifact = options.dependencies?.removePrivateArtifact ?? ((path: string): void => rmSync(path, { recursive: true, force: true })); guardBrowserSetup(operationDeadline); @@ -3543,13 +3995,17 @@ export async function createBrowserSession( String(options.maxOutputBytes), "--action-policy", policyPath, - ...(auth.kind === "browser-profile" && auth.browserExecutable !== undefined - ? ["--executable-path", auth.browserExecutable] - : []), - ...(options.headed ? ["--headed"] : []), ); - if (auth.kind !== "browser-profile") { - globalArguments.push("--allowed-domains", manifest.browserDomains.join(",")); + if (engine === "chrome") { + globalArguments.push( + ...(auth.kind === "browser-profile" && auth.browserExecutable !== undefined + ? ["--executable-path", auth.browserExecutable] + : []), + ...(options.headed ? ["--headed"] : []), + ); + if (auth.kind !== "browser-profile") { + globalArguments.push("--allowed-domains", manifest.browserDomains.join(",")); + } } guardBrowserSetup(operationDeadline); chmodSync(directory, 0o700); @@ -3577,7 +4033,7 @@ export async function createBrowserSession( ?? failInitialization(new Error("browser socket directory was not initialized")); let environment: Readonly>; try { - environment = isolatedEnvironment(initializedSocketDirectory); + environment = isolatedEnvironment(initializedSocketDirectory, sessionEnvironment); } catch (error) { failInitialization(error); } @@ -3623,6 +4079,7 @@ export async function createBrowserSession( cleanupResourceIdentity = next; }; let networkProxy: LocalNetworkProxy | null = null; + let lightpanda: ReturnType | null = null; const networkProxyCreation: { pending: Promise | null; } = { pending: null }; @@ -3651,6 +4108,25 @@ export async function createBrowserSession( operationDeadline, () => { const batch = (async (): Promise => { + if (lightpanda !== null) { + const records: JsonRecord[] = []; + for (const command of commands) { + const data = await lightpanda.run!( + globalArguments, + command, + { + cwd: directory, + environment, + timeoutMs: remainingBrowserSetupTime(timeoutMs, operationDeadline), + maxOutputBytes, + }, + ); + const record: JsonRecord = { success: true, data }; + browserResultData(record); + records.push(record); + } + return records; + } const result = await runBrowserCommand( [...agentBrowserCommand(), ...globalArguments, "batch", "--bail", "--json"], { @@ -3703,6 +4179,25 @@ export async function createBrowserSession( } let closeFailure: unknown; try { + if (lightpanda !== null) { + await lightpanda.run!( + globalArguments, + ["close"], + { + cwd: directory, + environment, + timeoutMs: 15_000, + maxOutputBytes: 1024 * 1024, + }, + ); + // The driver closed its CDP session; reap the owned serve child so + // the acknowledged close also proves the browser process is gone. + // A reap failure leaves the close open so the launch-intent + // quiescence recovery below can still prove the session. + await lightpanda.close(); + closeDisposition = "acknowledged"; + return; + } const result = await runBrowserCommand( [...agentBrowserCommand(), ...globalArguments, "close", "--json"], { cwd: directory, environment, timeoutMs: 15_000, maxOutputBytes: 1024 * 1024 }, @@ -3720,21 +4215,57 @@ export async function createBrowserSession( if ( resource === null || resource.kind !== "agent-browser-session-v2" - || resource.phase !== "controlled" + || ( + resource.phase !== "controlled" + && !( + resource.phase === "launch-intent" + && resource.control === null + && lightpanda !== null + ) + ) ) throw closeFailure; try { - await provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects( - resource, - { - ...options.dependencies?.cleanupLifecycle, - runCommand: runBrowserCommand, - }, - ); + if (resource.phase === "controlled") { + await provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects( + resource, + { + ...options.dependencies?.cleanupLifecycle, + runCommand: runBrowserCommand, + }, + ); + } else { + // A failed close command does not strand a CDP-attached Lightpanda + // session: reaping the owned serve child still lets the daemon + // observe the session inactive, which the unbound launch-intent + // quiescence proof accepts. + if (lightpanda !== null) { + try { + await lightpanda.close(); + } catch { + // The reaping failure surfaces through the quiescence proof's + // continued-active observation below. + } + } + await convergeBrowserCleanupResourceProof( + resource, + "full-roots", + { + ...options.dependencies?.cleanupLifecycle, + runCommand: runBrowserCommand, + }, + ); + } } catch (quiescenceFailure) { if ( quiescenceFailure instanceof AgentBrowserCleanupOwnerStillLiveError || quiescenceFailure instanceof AgentBrowserLifecycleCommandUnavailableError + || ( + quiescenceFailure instanceof Error + && BROWSER_CLEANUP_PROOF_SETTLING_MESSAGES.has( + quiescenceFailure.message, + ) + ) ) { closeRecoveryDisposition = "retry-after-close-attempt"; } @@ -3772,20 +4303,41 @@ export async function createBrowserSession( if ( resource === null || resource.kind !== "agent-browser-session-v2" - || resource.phase !== "controlled" + || ( + resource.phase !== "controlled" + && !( + resource.phase === "launch-intent" + && resource.control === null + && lightpanda !== null + ) + ) ) { failures.push(new Error( "browser cleanup resource identity is unavailable for close convergence", )); } else { try { - await recoverPinnedAgentBrowserCleanupResourceAfterCloseAttempt( - resource, - { - ...options.dependencies?.cleanupLifecycle, - runCommand: runBrowserCommand, - }, - ); + if (resource.phase === "controlled") { + await recoverPinnedAgentBrowserCleanupResourceAfterCloseAttempt( + resource, + { + ...options.dependencies?.cleanupLifecycle, + runCommand: runBrowserCommand, + }, + ); + } else { + // An unbound Lightpanda launch-intent resource converges once + // the daemon reports the session inactive after close and the + // owned serve-child reap above. + await convergeBrowserCleanupResourceProof( + resource, + "full-roots", + { + ...options.dependencies?.cleanupLifecycle, + runCommand: runBrowserCommand, + }, + ); + } closeDisposition = "independently-proved-quiescent"; } catch (error) { failures.push(error); @@ -3808,6 +4360,18 @@ export async function createBrowserSession( ); } } + if (lightpanda !== null) { + const dependencies = lightpanda; + if (!await teardownCompletesWithin( + Promise.resolve().then(() => dependencies.close()), + LIGHTPANDA_SERVE_TEARDOWN_TIMEOUT_MS, + )) { + resourcesQuiescent = false; + failures.push( + new Error("Lightpanda browser process cleanup did not settle safely"), + ); + } + } const rootsAreUnused = sessionIsClosed() && activeBatches.size === 0; if (!rootsAreUnused) { failures.push( @@ -3901,10 +4465,22 @@ export async function createBrowserSession( }; try { guardBrowserSetup(operationDeadline); + if (engine === "lightpanda") { + const executable = resolveLightpandaExecutable(sessionEnvironment); + if (executable === null) { + throw new Error("Lightpanda executable is no longer provisioned"); + } + // The owned serve child starts lazily on the first driver command. Its + // absolute deadline is a per-command concern only; each runBatch call + // already carries the caller's operation budget, so no separate session + // horizon is enforced here. + lightpanda = createLightpanda(executable, Number.POSITIVE_INFINITY); + } // A configured storage-state file seeds the contained context instead of // cloning the profile: agent-browser cannot combine --profile with --state, // and page storage in profile mode is off the record anyway. - const sourceProfile = auth.kind === "browser-profile" && auth.storageState === undefined + const sourceProfile = engine === "chrome" + && auth.kind === "browser-profile" && auth.storageState === undefined ? profilePath(auth.profile) : null; if (sourceProfile !== null) { @@ -3913,11 +4489,17 @@ export async function createBrowserSession( guardBrowserSetup(operationDeadline); globalArguments.push("--profile", clonedProfile.userDataPath); selectedProfileDirectory = clonedProfile.profileDirectory ?? null; - } else if (auth.kind === "browser-profile" && auth.storageState === undefined) { + } else if ( + engine === "chrome" + && auth.kind === "browser-profile" && auth.storageState === undefined + ) { guardBrowserSetup(operationDeadline); globalArguments.push("--profile", auth.profile); } - if (auth.kind === "browser-profile" && auth.storageState !== undefined) { + if ( + engine === "chrome" + && auth.kind === "browser-profile" && auth.storageState !== undefined + ) { globalArguments.push("--state", auth.storageState); } networkProxy = await runBrowserSetupStep(operationDeadline, () => { @@ -3931,13 +4513,17 @@ export async function createBrowserSession( }); networkProxyCreation.pending = null; guardBrowserSetup(operationDeadline); - const proxyArguments = ownedBrowserProxyArguments( - networkProxy.url, - selectedProfileDirectory ?? undefined, - ); + const proxyArguments = engine === "lightpanda" + // Lightpanda takes a plain proxy URL; Chromium needs the profile-aware + // launch-argument form. + ? ["--proxy", networkProxy.url] + : ownedBrowserProxyArguments( + networkProxy.url, + selectedProfileDirectory ?? undefined, + ); guardBrowserSetup(operationDeadline); globalArguments.push(...proxyArguments); - const launchUrl = auth.kind === "browser-profile" + const launchUrl = engine === "lightpanda" || auth.kind === "browser-profile" ? "about:blank" : manifest.origins[0]; if (launchUrl === undefined) throw new Error("contained browser session requires one reviewed origin"); @@ -3961,7 +4547,12 @@ export async function createBrowserSession( remainingBrowserSetupTime(options.timeoutMs, operationDeadline), options.maxOutputBytes, ); - if (options.publishCleanupResource !== undefined) { + // A CDP-attached Lightpanda session has no daemon-launched browser to + // witness: `session info` reports engine "lightpanda" and + // browserLaunched false, which the Chrome control witness cannot bind. + // The resource stays at launch-intent; its post-close quiescence is proved + // by the unbound launch-intent proof once the owned serve child is reaped. + if (engine === "chrome" && options.publishCleanupResource !== undefined) { guardBrowserSetup(operationDeadline); const pinnedCleanupResource = await runBrowserSetupStep( operationDeadline, @@ -4013,8 +4604,47 @@ export async function createBrowserSession( guardBrowserSetup(operationDeadline); const cookieCommands = browserCookieCommands(cookieResult.cookies, target); guardBrowserSetup(operationDeadline); + if (lightpanda !== null) { + // Keep cookie values out of process argv: the Lightpanda driver + // accepts the whole batch on stdin, matching the Chromium lane's + // stdin seeding. + const dependencies = lightpanda; + await runBrowserSetupStep( + operationDeadline, + () => dependencies.runBatch!( + globalArguments, + cookieCommands, + { + cwd: directory, + environment, + timeoutMs: remainingBrowserSetupTime( + options.timeoutMs, + operationDeadline, + ), + maxOutputBytes: options.maxOutputBytes, + }, + ), + ); + } else { + await runBatch( + cookieCommands, + remainingBrowserSetupTime(options.timeoutMs, operationDeadline), + options.maxOutputBytes, + ); + } + } + if (engine === "lightpanda") { + // Cookie imports ran on about:blank. Establish first-party context on + // the origin's realm page rather than its root: heavyweight signed-in + // landing pages can challenge a fresh automated session before any + // in-page request runs. + const firstOrigin = manifest.origins[0]; + if (firstOrigin === undefined) { + throw new Error("contained browser session requires one reviewed origin"); + } + const realmUrl = new URL("/robots.txt", firstOrigin).href; await runBatch( - cookieCommands, + [["open", realmUrl]], remainingBrowserSetupTime(options.timeoutMs, operationDeadline), options.maxOutputBytes, ); @@ -4090,6 +4720,18 @@ export async function createBrowserSession( ); } } + if (lightpanda !== null) { + const dependencies = lightpanda; + if (!await teardownCompletesWithin( + Promise.resolve().then(() => dependencies.close()), + LIGHTPANDA_SERVE_TEARDOWN_TIMEOUT_MS, + )) { + resourcesQuiescent = false; + cleanupFailures.push( + new Error("Lightpanda browser process could not be closed safely"), + ); + } + } if ( cleanupResourcePublication === "published" && resourcesQuiescent diff --git a/src/lightpanda-browser.ts b/src/lightpanda-browser.ts index f0f412cd..5db3c4fa 100644 --- a/src/lightpanda-browser.ts +++ b/src/lightpanda-browser.ts @@ -133,9 +133,15 @@ export function selectBrowserEngine( } export class LightpandaCompatibilityError extends Error { - constructor(readonly beforeNavigation = false) { - super("Lightpanda does not implement the required browser protocol method"); + readonly beforeNavigation: boolean; + + constructor( + beforeNavigation = false, + message = "Lightpanda does not implement the required browser protocol method", + ) { + super(message); this.name = "LightpandaCompatibilityError"; + this.beforeNavigation = beforeNavigation; } } @@ -373,7 +379,7 @@ export function createLightpandaDependencies( if (proxy === undefined) throw new Error("Lightpanda proxy is missing"); const port = await freeLoopbackPort(); server = Bun.spawn([executable, ...lightpandaServeArguments(port, proxy)], { - cwd: options.cwd, env: { ...Object.fromEntries(Object.entries(options.environment).filter(([key]) => !/^(?:(?:https?|all|no)_proxy$|LIGHTPANDA_)/i.test(key))), LIGHTPANDA_DISABLE_TELEMETRY: "1" }, stdin: "ignore", stdout: "ignore", stderr: "ignore", + cwd: options.cwd, env: { ...Object.fromEntries(Object.entries(options.environment).filter(([key]) => !/^(?:(?:https?|all|no)_proxy$|LIGHTPANDA_|GHOSTGET_LIGHTPANDA_)/i.test(key))), LIGHTPANDA_DISABLE_TELEMETRY: "1" }, stdin: "ignore", stdout: "ignore", stderr: "ignore", }); process.once("exit", onParentExit); const cdpUrl = `ws://127.0.0.1:${port}/`; @@ -422,7 +428,7 @@ export function createLightpandaDependencies( commands: readonly (readonly string[])[], options: CommandOptions, ): Promise => { - if (preflightFailure !== undefined) throw preflightFailure; + if (preflightFailure !== undefined && commands.some(command => command[0] !== "close")) throw preflightFailure; if (commands.some(command => command[0] === "open" && command[1] !== "about:blank")) navigationStarted = true; const result = await run( [...agentBrowserCommand(), ...lightpandaGlobalArguments(globalArguments, await endpoint(globalArguments, remainingOptions(options))), "batch", "--bail", "--json"], diff --git a/src/media/manifest.test.ts b/src/media/manifest.test.ts index 02779de4..985f1a20 100644 --- a/src/media/manifest.test.ts +++ b/src/media/manifest.test.ts @@ -465,7 +465,7 @@ function trackedYtDlpManifest( describe("Ghostget media manifest", () => { test("uses one Ghostget-owned schema and transcriber identity", () => { expect(GHOSTGET_MEDIA_SCHEMA_VERSION).toBe(1); - expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.82"); + expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.83"); expect(localTranscriptVariantSegments(localIdentity)).toEqual([ "transcript", "local", diff --git a/src/providers/linkedin-web-profile-browser.test.ts b/src/providers/linkedin-web-profile-browser.test.ts index d088e159..07305aeb 100644 --- a/src/providers/linkedin-web-profile-browser.test.ts +++ b/src/providers/linkedin-web-profile-browser.test.ts @@ -1808,6 +1808,47 @@ describe("LinkedIn profile stats contained-browser transport", () => { await nonExact.close(); }); + test("does not consume the initial-batch rewrite on a stdin-free lifecycle command", async () => { + let capturedOptions: CreateBrowserSessionOptions | null = null; + const calls: { readonly stdin: string | undefined }[] = []; + const session: BrowserSession = { + runBatch: () => Promise.resolve([{ success: true, result: {} }]), + close: () => Promise.resolve(), + cleanup: () => Promise.resolve(), + }; + const transport = await createLinkedInProfileBrowserTransport(cookieSourceAuth, { + timeoutMs: 1_000, + maxOutputBytes: 2 * 1024 * 1024, + dependencies: { + createBrowserSession: (_manifest, _auth, options) => { + capturedOptions = options; + return Promise.resolve(session); + }, + runCommand: (_command, options) => { + calls.push({ stdin: options.stdin }); + return Promise.resolve({ exitCode: 0, stderr: "", stdout: "{}" }); + }, + }, + }); + const run = (capturedOptions as unknown as CreateBrowserSessionOptions) + .dependencies?.runCommand; + if (run === undefined) throw new Error("missing LinkedIn command wrapper"); + const baseOptions = Object.freeze({ + cwd: "/tmp/linkedin-profile-browser-test", + environment: Object.freeze({}), + timeoutMs: 1_000, + maxOutputBytes: 1_024, + }); + await run(["agent-browser", "session", "info"], baseOptions); + await run(["agent-browser", "batch", "--bail", "--json"], { + ...baseOptions, + stdin: '[["open","https://www.linkedin.com"]]', + }); + expect(calls[0]?.stdin).toBeUndefined(); + expect(calls[1]?.stdin).toBe('[["open","https://www.linkedin.com/robots.txt"]]'); + await transport.close(); + }); + test("retries only the cookie-source root and never the browser-profile blank navigation", async () => { let capturedOptions: CreateBrowserSessionOptions | null = null; let commandCalls = 0; diff --git a/src/providers/linkedin-web-profile-browser.ts b/src/providers/linkedin-web-profile-browser.ts index bb9cc82e..97e824b8 100644 --- a/src/providers/linkedin-web-profile-browser.ts +++ b/src/providers/linkedin-web-profile-browser.ts @@ -11,6 +11,7 @@ import { type CreateBrowserSessionOptions, } from "../browser"; import type { GhostgetManifest } from "../model"; +import type { BrowserEngineSelection } from "../lightpanda-browser"; import { assertLinkedInContactInfoRequest, buildLinkedInProfileContactDetailsNavigationPostPath, @@ -715,7 +716,7 @@ function linkedInProfileBrowserCommandRunner( const executionOptions = rewroteInitialRoot || rewroteInitialBlank ? { ...options, stdin: LINKEDIN_INITIAL_REALM_BATCH } : options; - initialBatchPending = false; + if (options.stdin !== undefined) initialBatchPending = false; const first = await execute(command, executionOptions); if ( !rewroteInitialRoot @@ -784,6 +785,13 @@ export async function createLinkedInProfileBrowserTransport( readonly operationDeadline?: WebSessionOperationDeadline; readonly publishCleanupResource?: WebSessionCleanupResourcePublisher; readonly dependencies?: Partial; + /** + * Engine for the contained read session. "auto" (the default) resolves to + * Lightpanda only for cookie-yielding realms when a provisioned binary + * exists, with compatibility-only fallback before navigation; "chrome" + * preserves the existing lane unconditionally. + */ + readonly engine?: BrowserEngineSelection; }, ): Promise { if ( @@ -814,6 +822,9 @@ export async function createLinkedInProfileBrowserTransport( ...(options.publishCleanupResource === undefined ? {} : { publishCleanupResource: options.publishCleanupResource }), + // Lightpanda-first is the qualified default for this read transport; an + // explicit "chrome" still pins the Chromium lane for callers that need it. + engine: options.engine ?? "auto", }; let session: BrowserSession; try { diff --git a/src/providers/linkedin-web-runtime.internal.test.ts b/src/providers/linkedin-web-runtime.internal.test.ts index dbc44be8..762b019f 100644 --- a/src/providers/linkedin-web-runtime.internal.test.ts +++ b/src/providers/linkedin-web-runtime.internal.test.ts @@ -1387,6 +1387,43 @@ describe("LinkedIn authenticated internal-API runtime", () => { ]); }); + test("forwards an explicit engine and leaves the qualified funnel unset for the transport default", async () => { + const transport: LinkedInProfileBrowserTransport = { + currentIdentityResponse: () => Promise.resolve(currentIdentityResponse()), + readProfileHtml: () => Promise.resolve( + '7,553 followers', + ), + readConnectionsHtml: () => Promise.resolve( + "

4,877 connections

", + ), + readContactInfoJson: () => Promise.reject(new Error("stats read crossed Contact-info")), + readContactNavigationText: () => Promise.reject(new Error("crossed Contact-info navigation")), + readContactOverlayText: () => Promise.reject(new Error("crossed Contact-info overlay")), + readOrganizationHtml: () => Promise.reject(new Error("stats read crossed company page")), + close: () => Promise.resolve(), + }; + const observed: (string | undefined)[] = []; + for (const engine of ["chrome", "lightpanda", undefined] as const) { + const result = await executeLinkedInWebOperation(personalProfileRecipe(), { + profile_url: "https://www.linkedin.com/in/0thernet", + }, linkedinBrowserProfileAuth, { + ...(engine === undefined ? {} : { engine }), + dependencies: { + acquireCookies: () => Promise.reject(new Error("stats read exported cookies")), + fetch: () => Promise.reject(new Error("stats read used direct fetch")), + createProfileBrowserTransport: (_auth, options) => { + observed.push(options.engine); + return Promise.resolve(transport); + }, + }, + }); + expect(result.status).toBe("succeeded"); + } + // Explicit choices pass straight through; an unset option leaves the + // transport's own qualified default (auto) in charge. + expect(observed).toEqual(["chrome", "lightpanda", undefined]); + }); + test.each([302, 401, 403])("company reads retain native identity-%i browser fallback", async (status) => { const directCalls: CapturedRequest[] = []; const browserCalls: string[] = []; @@ -4514,6 +4551,37 @@ describe("LinkedIn contacts.read runtime", () => { }); expect(browserCalls).toEqual(["identity", "profile", "close"]); }); + + test("pins Contact reads to Chromium unless an explicit engine overrides", async () => { + const observed: (string | undefined)[] = []; + const transport: LinkedInProfileBrowserTransport = { + currentIdentityResponse: () => Promise.resolve(currentIdentityResponse()), + readProfileHtml: () => Promise.resolve(firstDegreeContactHtml()), + readConnectionsHtml: () => Promise.reject(new Error("contacts.read crossed connections")), + readContactInfoJson: () => Promise.reject(new Error("absent navigation fetched GraphQL")), + readContactNavigationText: () => Promise.resolve(OVERLAY_CONTACT_FLIGHT), + readContactOverlayText: () => Promise.reject(new Error("absent navigation fetched vanity overlay")), + readOrganizationHtml: () => Promise.reject(new Error("contacts.read crossed company")), + close: () => Promise.resolve(), + }; + for (const engine of [undefined, "lightpanda"] as const) { + await executeLinkedInWebOperation(contactInfoRecipe(), { + profile_url: "https://www.linkedin.com/in/example/", + }, linkedinBrowserProfileAuth, { + ...(engine === undefined ? {} : { engine }), + dependencies: { + now: () => Date.parse("2026-09-08T18:00:00.000Z"), + createProfileBrowserTransport: (_auth, options) => { + observed.push(options.engine); + return Promise.resolve(transport); + }, + }, + }); + } + // `network requests` observation is unqualified on Lightpanda, so the + // contact overlay's RSC harvest keeps Chromium unless the caller insists. + expect(observed).toEqual(["chrome", "lightpanda"]); + }); }); const COMMENT_POST_URN = "urn:li:activity:7511809736883855360"; diff --git a/src/providers/linkedin-web-runtime.ts b/src/providers/linkedin-web-runtime.ts index 36b0b995..c244cdc1 100644 --- a/src/providers/linkedin-web-runtime.ts +++ b/src/providers/linkedin-web-runtime.ts @@ -23,6 +23,7 @@ import { type BrowserFileResolver, } from "../browser"; import type { FileInputValue, OperationInput, WebSessionRecipe } from "../model"; +import type { BrowserEngineSelection } from "../lightpanda-browser"; import { canonicalJson, isCanonicalJsonText, @@ -200,6 +201,13 @@ export type LinkedInWebExecutionOptions = { event: WebSessionProviderAcceptedMutationTargetEvent, ) => Promise; readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise; + /** + * Engine for contained browser read sessions. Unset uses each transport's + * qualified default — the LinkedIn profile transport prefers Lightpanda for + * cookie-yielding realms when a provisioned binary exists; "chrome" pins the + * Chromium lane. + */ + readonly engine?: BrowserEngineSelection; }; function isRecord(value: unknown): value is JsonRecord { @@ -766,6 +774,7 @@ export async function probeLinkedInWebIdentity( readonly timeoutMs?: number; readonly dependencies?: LinkedInWebRuntimeDependencies; readonly signal?: AbortSignal; + readonly engine?: BrowserEngineSelection; } = {}, ): Promise<{ readonly subject: string; readonly displayName: string | null }> { if (auth.kind === "browser-profile") { @@ -783,6 +792,7 @@ export async function probeLinkedInWebIdentity( timeoutMs, maxOutputBytes: MAX_SUBJECT_BYTES, operationDeadline: deadline, + ...(options.engine === undefined ? {} : { engine: options.engine }), }), "authenticated web subject probe", ); @@ -896,6 +906,7 @@ async function createLinkedInStatsBrowserTransport( ...(options.publishCleanupResource === undefined ? {} : { publishCleanupResource: options.publishCleanupResource }), + ...(options.engine === undefined ? {} : { engine: options.engine }), }); } @@ -908,7 +919,16 @@ async function executeLinkedInContactInfoRead( const target = linkedInContactInfoTarget(input.profile_url); return runReadEffect(linkedInContactReadProgram(target).pipe( Effect.provide(LinkedInContactPlatformLive({ - openBrowser: () => createLinkedInStatsBrowserTransport(auth, recipe, options), + // The contact overlay harvests live request bindings through + // `network requests` observation, which is unqualified on Lightpanda — + // it returns a well-formed empty list but has not been proven to report + // in-flight requests. Pin Chromium here until that observation is + // qualified; an explicit engine choice still wins. + openBrowser: () => createLinkedInStatsBrowserTransport( + auth, + recipe, + { ...options, engine: options.engine ?? "chrome" }, + ), decodeIdentity: identityFromMeResponse, bindIdentity: identity => boundLinkedInStatsIdentity(auth, identity), observedAt: () => new Date(options.dependencies?.now?.() ?? Date.now()).toISOString(), diff --git a/src/version.ts b/src/version.ts index be4cbd0f..f62f977a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Canonical immutable Ghostget package release identity. */ -export const GHOSTGET_VERSION = "0.18.82" as const; +export const GHOSTGET_VERSION = "0.18.83" as const; diff --git a/website/build.ts b/website/build.ts index 265efb4b..76fb5975 100644 --- a/website/build.ts +++ b/website/build.ts @@ -137,7 +137,7 @@ export const HRANESS_LOGO_URL = "https://hraness.com/icon.png" as const; export const HRANESS_LINKEDIN_URL = "https://www.linkedin.com/company/hraness" as const; export const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@hraness/ghostget" as const; export const SKILL_REPOSITORY = "hraness/ghostget" as const; -export const CONTENT_REVIEWED_RELEASE = "v0.18.82" as const; +export const CONTENT_REVIEWED_RELEASE = "v0.18.83" as const; export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" as const; export const DEMO_PUBLIC_FILES = [ "wrench-first-capture.gif",