diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ece51..b6a63e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Changelog -## Unreleased +## 0.13.2 - 2026-08-23 - Publish exact Threads text through `posts.publish@5` without requiring a PNG, while preserving optional PNG publication and exact permalink readback. +- Restore exact LinkedIn personal and organization profile reads from a + path-backed signed-in Chrome realm. Wrench clones the dormant profile into a + private contained session, binds the current member before either target + read, keeps personal profile and connection reads sequential, and finalizes + the browser's private artifacts after every result. ## 0.13.1 - 2026-08-23 diff --git a/README.md b/README.md index 3b7b84c..0dd3ca7 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ The skill teaches Codex, Claude Code, Cursor, and other compatible coding agents when to use Wrench, how to preserve its trust boundaries, and how to install the CLI if it is missing. Start a new agent session after installation. -Install the current immutable CLI release from the `v0.13.1` tag: +Install the current immutable CLI release from the `v0.13.2` tag: ```sh -bun add --global github:hraness/wrench#v0.13.1 +bun add --global github:hraness/wrench#v0.13.2 wrench adapter sync-bundled --json wrench doctor ``` @@ -102,7 +102,7 @@ Install Wrench in an agent or application that owns its own model, planning, tool loop, approvals, and interface: ```sh -bun add github:hraness/wrench#v0.13.1 +bun add github:hraness/wrench#v0.13.2 ``` ```ts diff --git a/package.json b/package.json index 0723cb0..3393ab8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/wrench", - "version": "0.13.1", + "version": "0.13.2", "description": "Open-source CLI and TypeScript SDK for precise web capabilities for AI agents: page capture, verified media archives, encrypted reads, and typed provider operations.", "license": "MIT", "type": "module", @@ -110,6 +110,7 @@ "src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json", "src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json", "src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json", + "src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json", "src/assets/adapters/reddit/wrench-web-adapter.json", "src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json", "src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json", diff --git a/skills/wrench/references/install.md b/skills/wrench/references/install.md index 8d4c138..b3cf284 100644 --- a/skills/wrench/references/install.md +++ b/skills/wrench/references/install.md @@ -21,7 +21,7 @@ When the user asked to install or use Wrench, install the current immutable release and its reviewed bundled adapter manifests: ```sh -bun add --global github:hraness/wrench#v0.13.1 +bun add --global github:hraness/wrench#v0.13.2 wrench adapter sync-bundled --json wrench --help wrench doctor --json @@ -31,7 +31,7 @@ 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 github:hraness/wrench#v0.13.1 +bun add github:hraness/wrench#v0.13.2 ``` ## Add only required optional tools diff --git a/skills/wrench/references/linkedin-adapter.md b/skills/wrench/references/linkedin-adapter.md index 29bb16b..04a53c1 100644 --- a/skills/wrench/references/linkedin-adapter.md +++ b/skills/wrench/references/linkedin-adapter.md @@ -1,7 +1,10 @@ # LinkedIn authenticated web API adapter -The current `linkedin-web` schema-v4 adapter has two observed operations: -`articles.draft.save@7` and `posts.publish@3`. The draft operation creates or replaces one private native Article +The current `linkedin-web` schema-v4 adapter has four observed operations: +`profiles.read@1`, `organizations.read@1`, `articles.draft.save@7`, and +`posts.publish@3`. The two profile-stat operations bind the current member, +then read one exact self profile or requested organization Page without DOM +automation. The draft operation creates or replaces one private native Article draft for the bound current member, supports paragraphs, H1/H2 headings, native blockquotes, native HTTPS links, one distinct banner cover, and ordered inline images with required alt text and optional captions, and independently verifies the exact unpublished result @@ -50,17 +53,40 @@ The separate `linkedin` adapter uses LinkedIn's documented OAuth API for approve ## Configure the signed-in realm -Prefer target-filtered Arc or Chrome cookies: +LinkedIn currently rejects exported session cookies outside their browser +context. Configure profile statistics with a path-backed, dormant Chrome +profile and the exact Chrome executable. A filtered cookie source may overlay +fresh origin-scoped cookies when the source browser is running: ```sh -wrench auth add linkedin-main --cookie-source arc --cookie-profile "Profile 2" +wrench auth add linkedin-main \ + --browser-profile /absolute/private/chrome-snapshot/Default \ + --browser-executable "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --trust-profile-egress \ + --cookie-source chrome \ + --cookie-profile "Profile 2" wrench auth bind linkedin-main --site linkedin wrench auth list --json ``` -Use a private profile snapshot only when current browser storage or first-party assets are required for bootstrap. Never attach the runtime operation to a live inbox tab and never copy LinkedIn session material into an OAuth token document. - -Bind `linkedin-main` to one stable current member/person identity before private reads or mutations. If organization operations are later added, bind the selected organization actor separately and prove that the current member can act for it. Reject account ambiguity, login changes, request-actor mismatch, and response-actor mismatch before dispatch. +The profile path grants its contained page scripts unfiltered public-host +egress and may contain every signed-in session in that browser profile. Keep it +private, outside the repository, and use it only with the user's informed +authority. Wrench refuses to clone an active Chromium user-data root. For a +scheduled realm, first load LinkedIn normally in the selected source profile +and confirm that its first-party session is current. Fully quit Chrome, take +one private snapshot, then point the auth locator at that dormant snapshot and +use the filtered cookie overlay for current session values. The overlay can +refresh valid cookies, but it cannot revive a source session that LinkedIn +already rejects with 401. Never attach the runtime operation to a live inbox +tab or copy LinkedIn session material into an OAuth token document. + +Bind `linkedin-main` to one stable current member/person identity before private +reads or mutations. The observed organization Page read remains a +member-authenticated view and does not select an organization actor. A future +organization mutation must bind that actor separately and prove that the +current member can act for it. Reject account ambiguity, login changes, +request-actor mismatch, and response-actor mismatch before dispatch. The retained candidate parser derives a mailbox only when the current-account response directly names one bounded `miniProfile` reference and exactly one @@ -68,25 +94,29 @@ included entity binds that reference to the same numeric member subject. It does not scan other included profile entities as a fallback. Missing, unbound, conflicting, or ambiguous direct bindings fail. -The client can strictly review a short-lived `__cf_bm` edge-cookie +The standalone client can strictly review a short-lived `__cf_bm` edge-cookie rotation: it accepts only that name, validates origin, attributes, expiry, and deletion semantics, and binds the encrypted cache to the auth-locator hash. -Removing the auth locator removes that cache. Only the observed Article draft -operation may cross the execution boundary; every capture-required operation -still refuses before this client is created. +Removing the auth locator removes that cache. Profile-backed statistics do not +export their session into that standalone client. Only the four observed +operations may cross the execution boundary; every capture-required operation +still refuses before its provider client is created. Verification on July 23, 2026 produced a durable projection-drift failure and every available LinkedIn realm returned `401` at current-account preflight. -Reauthentication alone does not re-promote the contract; a fresh low-stakes -capture must also prove the current identity, mailbox, query, response +That remains negative evidence for the independently capture-required inbox +contracts, not a demotion of the separately observed profile and Page reads. +Reauthentication alone does not promote an inbox contract; a fresh low-stakes +capture must also prove its current identity, mailbox, query, response projection, and completeness semantics. ## Recapture inbox listing `messaging.list` is a capture-required reservation. Version 1.1.0 remains archived as historical evidence of the formerly observed bundle; version -1.2.0 first demoted it, and the current 1.7.0 bundle remains capture-required -and cannot execute. The intended folder input still reserves: +1.2.0 first demoted it, and version 1.7.0 preserves that unavailable contract +history. The current bundle remains capture-required and cannot execute. The +intended folder input still reserves: - `focused` for the main inbox; - `other` for the additional inbox; @@ -117,23 +147,43 @@ use only a reviewed message query and must not mark it read. ## Profiles, organization Pages, and connections -The adapter reserves four bounded semantic operations without claiming that -their internal requests are known: - -- `profiles.read` selects one exact public profile identifier or provider - profile URN; -- `organizations.read` selects one exact organization public identifier or - organization URN and means viewing that LinkedIn Page, not acting as it; -- `relationships.recommendations.read` selects the `all` recommended-connections - surface and one bounded page; -- `relationships.connect` sends one confirmed invitation to an exact profile - URN, with an optional note of at most 300 characters. - -All four are `capture-required`. The three reads are R1; the invitation is R3 -with a 24-hour local-at-most-once window. Their presence in `wrench -capabilities` makes the CLI shape reviewable and stable while guaranteeing that -no request runs before a managed HAR proves the exact request, viewer scope, -target, response, paging, completeness, and duplicate-state behavior. +`profiles.read@1` is an observed R1 read for one exact signed-in self profile +URL. It binds the current member to the requested public slug and projects the +exact follower count. When `include_connections` is true, it also reads the +exact private My Network connection total in the same ordered browser +transport. `organizations.read@1` is an observed R1 read for one exact company +Page URL. It binds the requested company record and its matching following +state before projecting the exact follower count. Viewing a Page does not +confer Page-actor authority. + +For a path-backed auth realm, Wrench starts a task-private contained clone and +uses the browser path before any standalone cookie preflight. One code-owned +evaluation derives the reviewed CSRF header from the unique `JSESSIONID` and +performs exact `GET /voyager/api/me`. After binding that response to the auth +subject and requested self slug, it reads the exact profile response and, when +requested, the private connections response in order. The organization branch +instead reads only the exact requested Page after the same member binding. +Every response is bounded, same-origin, exact-route, status- and media-type +checked before projection. Login, checkpoint, redirect, network, cookie, +identity, target, or response drift fails closed. Wrench does not click or +inspect LinkedIn DOM, expose a caller-selected selector or script, follow a +redirect, or retry after an arbitrary browser failure. It closes the contained +browser and verifies cleanup after success or failure. + +Live evidence on August 23, 2026 showed that LinkedIn returned deletion +cookies for a valid `li_at` when the same Chrome session was replayed through a +standalone client, even with Chrome's normal request headers. A cookie-only +realm is therefore not the scheduled profile-stat transport. The path-backed +browser branch preserves the browser/device context while still keeping the +operation fixed and target-bound. + +`relationships.recommendations.read` and `relationships.connect` remain +`capture-required`. The former reserves one bounded R1 page from the `all` +recommended-connections surface. The latter reserves one R3 invitation to an +exact profile URN, with an optional note of at most 300 characters and a +24-hour local-at-most-once window. Neither performs a request before a managed +HAR proves its exact request, viewer scope, target, response, paging, +completeness, and duplicate-state behavior. Do not treat `organizations.read` as organization-actor authority. A future Page-authored post or comment must separately bind the current member, selected @@ -251,7 +301,7 @@ Keep query IDs, CSRF material, cookies, variables, feature sets, and private ide The current registry keeps these unavailable until their exact first-party exchanges are captured and reviewed: -- `feeds.read`, `profiles.read`, `organizations.read`, `relationships.recommendations.read`, `messaging.list`, `messaging.read`, `posts.read`, `comments.read`, and `articles.read` (`R1`); +- `feeds.read`, `relationships.recommendations.read`, `messaging.list`, `messaging.read`, `posts.read`, `comments.read`, and `articles.read` (`R1`); - `messaging.send` (`R3`); - `posts.repost` and `posts.quote` (`R3`); - `comments.create` and `replies.create` (`R3`); diff --git a/skills/wrench/references/platform-patterns.md b/skills/wrench/references/platform-patterns.md index 12fbe40..e77917a 100644 --- a/skills/wrench/references/platform-patterns.md +++ b/skills/wrench/references/platform-patterns.md @@ -20,13 +20,13 @@ Leave the state `capture-required` when current evidence is absent, ambiguous, e ## LinkedIn - Use `linkedin-web` with a browser-session/cookie realm for consumer Home feed, inbox folders, conversations, and native article-editor surfaces. The separate official `linkedin` OAuth adapter covers approved post, comment, reply, repost, and reaction scopes; it does not supply the consumer Home feed or inbox. -- Current bundle 1.16.0 observes `linkedin-web articles.draft.save@7` and image-only `posts.publish@3`. The Article contract binds the numeric member subject to the normalized Article-author profile, creates or replaces one exact private draft, keeps a supplied cover in the Article banner rather than the body, and can preserve the independently read existing banner during an exact replacement without another cover upload. It supports paragraphs/H1/H2/native blockquotes, native HTTPS links, and 1–20 bounded inline JPEG/PNG/WebP images with required alt text and optional captions, and verifies the exact unpublished editor-response readback. Its fixed cover and inline-image single-upload registrations, byte transfers, autosaves, and readbacks run inside contained Chrome without DOM automation. The separate post contract stages a real-size optional PNG through bounded ordered commands, admits one exact image transfer and post create, durably retains the accepted share target, and independently verifies it. `linkedin-web media.publish@1` separately reserves an MP4 route but remains capture-required; the official `linkedin posts.publish` OAuth contract already observes MP4. -- Every other LinkedIn web operation remains capture-required. Explicit inert reservations cover inbox folders, one member profile, one organization Page, one bounded page of recommended connections, one connection invitation, Article reads/publication, and other comment/message/repost surfaces. Their retained candidates do not confer executable internal requests. +- Current bundle 1.18.0 observes `linkedin-web profiles.read@1`, `organizations.read@1`, `articles.draft.save@7`, and image-only `posts.publish@3`. The two profile-stat reads use a path-backed contained Chrome realm, bind the current member before projecting one exact self profile or requested organization Page, and keep the optional private connection read sequential with the self-profile read. The fixed browser evaluation performs only bounded, exact-route first-party fetches; it does not click or inspect LinkedIn DOM. A cookie-only standalone client is not a fallback because current live evidence shows LinkedIn invalidates an exported `li_at` outside its browser/device context. The Article contract binds the numeric member subject to the normalized Article-author profile, creates or replaces one exact private draft, keeps a supplied cover in the Article banner rather than the body, and can preserve the independently read existing banner during an exact replacement without another cover upload. It supports paragraphs/H1/H2/native blockquotes, native HTTPS links, and 1–20 bounded inline JPEG/PNG/WebP images with required alt text and optional captions, and verifies the exact unpublished editor-response readback. Its fixed cover and inline-image single-upload registrations, byte transfers, autosaves, and readbacks run inside contained Chrome without DOM automation. The separate post contract stages a real-size optional PNG through bounded ordered commands, admits one exact image transfer and post create, durably retains the accepted share target, and independently verifies it. `linkedin-web media.publish@1` separately reserves an MP4 route but remains capture-required; the official `linkedin posts.publish` OAuth contract already observes MP4. +- Every other LinkedIn web operation remains capture-required. Explicit inert reservations cover inbox folders, one bounded page of recommended connections, one connection invitation, Article reads/publication, and other comment/message/repost surfaces. Their retained candidates do not confer executable internal requests. - Exclude presence, messaging badges, delivery acknowledgements, seen/read receipts, and notification badge traffic from every R1 contract. - Bind the current viewer's person/member identity to the auth realm. `organizations.read` views a Page and does not confer Page-actor authority. For organization actions, additionally bind the administered organization actor selected by the plan. - Keep `articles.publish` separate as R3. Messages, posts, comments, replies, reposts, quotes, connection requests, and native article publication are also R3. Treat reversible reaction/follow/save desired state as R2 only after both create and delete exchanges are captured. - Keep `articles.publish`, `messaging.send`, `relationships.connect`, post/comment mutations, and reactions `capture-required` until their exact request, response, actor, target, and readback contracts pass a low-stakes fixture. `comments.create` targets a post rather than a profile/Page itself. Never send through a textbox or editor fallback. -- An authenticated dedicated Chrome realm passed the current-member and exact private Article editor-response readback on August 16, 2026. LinkedIn still binds this family to the browser/device session; a cookie-only standalone client is not an allowed fallback. Inbox operations remain independently capture-required. +- An authenticated dedicated Chrome realm passed the current-member and exact private Article editor-response readback on August 16, 2026. On August 23, an exact personal profile and connection read also proved that a current normal first-party source session plus a dormant snapshot and filtered cookie overlay succeeds, while the same overlay cannot revive a source session already returning 401. LinkedIn still binds this family to the browser/device session; a cookie-only standalone client is not an allowed fallback. Inbox operations remain independently capture-required. - Avoid employment and recruiting threads as fixtures. Read [linkedin-adapter.md](linkedin-adapter.md) before changing the LinkedIn registry. diff --git a/skills/wrench/references/social-profile-stats.md b/skills/wrench/references/social-profile-stats.md index 669b6a0..7c473db 100644 --- a/skills/wrench/references/social-profile-stats.md +++ b/skills/wrench/references/social-profile-stats.md @@ -47,6 +47,15 @@ auth locator is an error. Keep the two X calls sequential, both LinkedIn calls sequential, and both Substack calls sequential because each pair shares one authenticated realm. +`linkedin-chrome` must be a path-backed `browser-profile` locator over a +private dormant Chrome snapshot, optionally overlaid with current filtered +cookies from the selected Chrome profile. A cookie-source-only LinkedIn realm +is not an acceptable scheduled fallback because LinkedIn invalidates the +exported session outside its browser/device context. Before the initial +snapshot, or after a contained identity preflight returns 401, load LinkedIn +normally in the source Chrome profile, confirm the session is current, fully +quit Chrome, and replace the dormant snapshot. A filtered cookie overlay does +not revive a stale source session. Leave a 60-second idle interval after the LinkedIn personal read before the company read; current live evidence shows shorter intervals can trigger a temporary identity-preflight redirect even when the realm remains correctly diff --git a/src/assets/adapters/linkedin/wrench-web-adapter.json b/src/assets/adapters/linkedin/wrench-web-adapter.json index 10e7201..b6e404b 100644 --- a/src/assets/adapters/linkedin/wrench-web-adapter.json +++ b/src/assets/adapters/linkedin/wrench-web-adapter.json @@ -1,7 +1,7 @@ { "schemaVersion": 4, "id": "linkedin-web", - "version": "1.17.0", + "version": "1.18.0", "displayName": "LinkedIn (Authenticated Web API)", "surfaceId": "linkedin", "origins": [ diff --git a/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json b/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json new file mode 100644 index 0000000..10e7201 --- /dev/null +++ b/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json @@ -0,0 +1,874 @@ +{ + "schemaVersion": 4, + "id": "linkedin-web", + "version": "1.17.0", + "displayName": "LinkedIn (Authenticated Web API)", + "surfaceId": "linkedin", + "origins": [ + "https://www.linkedin.com", + "https://static.licdn.com" + ], + "browserDomains": [ + "www.linkedin.com", + "static.licdn.com" + ], + "operations": { + "contacts.list": { + "description": "Capture-required contract reservation: list bounded LinkedIn messaging participants with statistics only after current viewer binding, real conversation and message pagination, group attribution, completeness, and acknowledgement-free behavior are reviewed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding reviewed conversation page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum projected messaging participants", + "minimum": 1, + "maximum": 100 + }, + "stats_scan_limit": { + "type": "number", + "description": "Maximum reviewed messages scanned per contact and direction", + "minimum": 1, + "maximum": 2000 + } + }, + "required": [] + }, + "webSession": { + "site": "linkedin", + "action": "contacts.list", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "feeds.read": { + "description": "Capture-required contract reservation: the LinkedIn home-feed query revision is known, but execution remains disabled until exact current variables and a bounded response projection are captured and reviewed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "feed": { + "type": "string", + "description": "Captured feed surface", + "enum": [ + "home" + ] + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum feed entries to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "feed" + ] + }, + "webSession": { + "site": "linkedin", + "action": "feeds.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "profiles.read": { + "description": "Observed contract: read one exact signed-in LinkedIn self profile page with target binding and an optional exact private My Network connection total.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "profile_url": { + "type": "string", + "description": "Exact public LinkedIn self profile URL", + "minLength": 25, + "maxLength": 2048 + }, + "include_connections": { + "type": "boolean", + "description": "Also read the signed-in account's exact private My Network connection total" + } + }, + "required": [ + "profile_url" + ] + }, + "webSession": { + "site": "linkedin", + "action": "profiles.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "organizations.read": { + "description": "Observed contract: read one exact LinkedIn company Page and bind its exact follower count through the target Company and FollowingState records embedded by the first-party server render.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "organization_url": { + "type": "string", + "description": "Exact public LinkedIn company Page URL", + "minLength": 30, + "maxLength": 2048 + } + }, + "required": [ + "organization_url" + ] + }, + "webSession": { + "site": "linkedin", + "action": "organizations.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "relationships.recommendations.read": { + "description": "Capture-required contract reservation: list one bounded page of LinkedIn recommended connections only after the exact viewer-bound collection, paging variables, and completeness projection are captured and reviewed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "source": { + "type": "string", + "description": "Exact recommended-connections surface", + "enum": [ + "all" + ] + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding recommendations page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum recommended profiles to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "source" + ] + }, + "webSession": { + "site": "linkedin", + "action": "relationships.recommendations.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "messaging.list": { + "description": "Capture-required contract reservation: list one bounded LinkedIn inbox page only after the current-account mailbox projection, registered query, paging, completeness, and acknowledgement-free behavior are recaptured and reviewed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "folder": { + "type": "string", + "description": "Exact LinkedIn inbox folder", + "enum": [ + "focused", + "other", + "requests", + "archive", + "spam", + "all" + ] + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum conversations to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "folder" + ] + }, + "webSession": { + "site": "linkedin", + "action": "messaging.list", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "messaging.read": { + "description": "Capture-required contract reservation: the LinkedIn message query revision is known, but execution remains disabled until exact current variables and acknowledgement-free response handling are captured and reviewed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "conversation_id": { + "type": "string", + "description": "Exact opaque conversation identifier returned by messaging.list", + "minLength": 1, + "maxLength": 512 + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum messages to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "conversation_id" + ] + }, + "webSession": { + "site": "linkedin", + "action": "messaging.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "messaging.send": { + "description": "Capture-required contract reservation: send one confirmed LinkedIn message, optionally with one reviewed attachment, only after createMessage request and response bindings are captured and reviewed.", + "risk": "R3", + "sideEffect": "Sends one externally visible LinkedIn message to the exact confirmed conversation.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "conversation_id": { + "type": "string", + "description": "Exact opaque conversation identifier returned by messaging.list", + "minLength": 1, + "maxLength": 512 + }, + "body": { + "type": "string", + "description": "Exact message text", + "minLength": 1, + "maxLength": 8000 + }, + "media": { + "type": "file", + "description": "Optional reviewed image, video, or document attachment", + "maxBytes": 536870912, + "mediaTypes": [ + "image/jpeg", + "image/png", + "image/gif", + "video/mp4", + "application/pdf", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ] + } + }, + "required": [ + "conversation_id", + "body" + ] + }, + "webSession": { + "site": "linkedin", + "action": "messaging.send", + "contractVersion": 1, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "posts.read": { + "description": "Capture-required contract reservation: read one LinkedIn consumer-web post after its exact first-party post-detail query and bounded projection are captured and reviewed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "post_urn": { + "type": "string", + "description": "Exact LinkedIn share or UGC-post URN", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "post_urn" + ] + }, + "webSession": { + "site": "linkedin", + "action": "posts.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "comments.read": { + "description": "Capture-required contract reservation: list one bounded page of comments for an exact LinkedIn post after the consumer-web collection request is captured and reviewed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "post_urn": { + "type": "string", + "description": "Exact LinkedIn share or UGC-post URN", + "minLength": 1, + "maxLength": 500 + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum comments to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "post_urn" + ] + }, + "webSession": { + "site": "linkedin", + "action": "comments.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "articles.read": { + "description": "Capture-required contract reservation: list LinkedIn first-party article records only after a fresh capture proves exact author, article identity, and paging response bindings.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "author_urn": { + "type": "string", + "description": "Exact LinkedIn person or organization author URN", + "minLength": 1, + "maxLength": 500 + }, + "start": { + "type": "number", + "description": "Zero-based article offset", + "minimum": 0, + "maximum": 100000 + }, + "limit": { + "type": "number", + "description": "Maximum article records to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "author_urn" + ] + }, + "webSession": { + "site": "linkedin", + "action": "articles.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "media.publish": { + "description": "Capture-required contract reservation: publish one confirmed LinkedIn member video only after the exact web-session video registration, contiguous signed byte transfers, processing/finalization, member and audience binding, created-share response, and independent exact-share readback are reviewed.", + "risk": "R3", + "sideEffect": "Uploads one video and publishes one externally visible LinkedIn post with the exact confirmed audience and commentary.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "body": { + "type": "string", + "description": "Exact post commentary", + "minLength": 1, + "maxLength": 3000 + }, + "visibility": { + "type": "string", + "description": "Confirmed post audience", + "enum": [ + "public", + "connections" + ] + }, + "media": { + "type": "file", + "description": "Exact plan-bound LinkedIn MP4 between 75,000 bytes and 500 MiB; runtime promotion must additionally verify duration and codec processing", + "maxBytes": 524288000, + "mediaTypes": [ + "video/mp4" + ] + }, + "title": { + "type": "string", + "description": "Optional exact video title", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "body", + "visibility", + "media" + ] + }, + "webSession": { + "site": "linkedin", + "action": "media.publish", + "contractVersion": 1, + "timeoutMs": 600000, + "maxOutputBytes": 8388608 + } + }, + "posts.publish": { + "description": "Publish one exact confirmed LinkedIn post with text and an optional single PNG through bounded ordered page staging and the current first-party image-registration/upload and post-create contracts, durably retain the accepted share URN, then independently read it back.", + "risk": "R3", + "sideEffect": "Publishes one externally visible LinkedIn post with the exact confirmed audience and content.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "body": { + "type": "string", + "description": "Exact post commentary", + "minLength": 1, + "maxLength": 3000 + }, + "visibility": { + "type": "string", + "description": "Confirmed post audience", + "enum": [ + "public", + "connections" + ] + }, + "media": { + "type": "array", + "description": "Optional single exact PNG image", + "items": { + "type": "file", + "description": "One exact PNG post image", + "maxBytes": 20971520, + "mediaTypes": [ + "image/png" + ] + }, + "minItems": 1, + "maxItems": 1 + }, + "alt_text": { + "type": "string", + "description": "Optional image alternative text", + "minLength": 1, + "maxLength": 4000 + } + }, + "required": [ + "body", + "visibility" + ] + }, + "webSession": { + "site": "linkedin", + "action": "posts.publish", + "contractVersion": 3, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "posts.repost": { + "description": "Capture-required contract reservation: create one confirmed LinkedIn repost after the exact repost mutation and returned post identity are captured and reviewed.", + "risk": "R3", + "sideEffect": "Creates one externally visible LinkedIn repost of the exact confirmed post.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_urn": { + "type": "string", + "description": "Exact LinkedIn share or UGC-post URN to repost", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "post_urn" + ] + }, + "webSession": { + "site": "linkedin", + "action": "posts.repost", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "posts.quote": { + "description": "Capture-required contract reservation: publish one confirmed LinkedIn quote repost after its exact mutation and returned post identity are captured and reviewed.", + "risk": "R3", + "sideEffect": "Publishes one externally visible LinkedIn quote repost with the exact confirmed commentary.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_urn": { + "type": "string", + "description": "Exact LinkedIn share or UGC-post URN to quote", + "minLength": 1, + "maxLength": 500 + }, + "body": { + "type": "string", + "description": "Exact quote commentary", + "minLength": 1, + "maxLength": 3000 + }, + "visibility": { + "type": "string", + "description": "Confirmed quote-post audience", + "enum": [ + "public", + "connections" + ] + } + }, + "required": [ + "post_urn", + "body", + "visibility" + ] + }, + "webSession": { + "site": "linkedin", + "action": "posts.quote", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "comments.create": { + "description": "Capture-required contract reservation: create one confirmed top-level LinkedIn comment after exact actor, post, and returned-comment bindings are captured and reviewed.", + "risk": "R3", + "sideEffect": "Publishes one externally visible comment on the exact confirmed LinkedIn post.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_urn": { + "type": "string", + "description": "Exact LinkedIn share or UGC-post URN", + "minLength": 1, + "maxLength": 500 + }, + "body": { + "type": "string", + "description": "Exact comment text", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "post_urn", + "body" + ] + }, + "webSession": { + "site": "linkedin", + "action": "comments.create", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "replies.create": { + "description": "Capture-required contract reservation: create one confirmed LinkedIn comment reply after exact actor, root, parent, and returned-reply bindings are captured and reviewed.", + "risk": "R3", + "sideEffect": "Publishes one externally visible reply to the exact confirmed LinkedIn comment.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_urn": { + "type": "string", + "description": "Exact root LinkedIn share or UGC-post URN", + "minLength": 1, + "maxLength": 500 + }, + "comment_urn": { + "type": "string", + "description": "Exact parent comment URN", + "minLength": 1, + "maxLength": 1000 + }, + "body": { + "type": "string", + "description": "Exact reply text", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "post_urn", + "comment_urn", + "body" + ] + }, + "webSession": { + "site": "linkedin", + "action": "replies.create", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "reactions.set": { + "description": "Capture-required contract reservation: set the exact desired LinkedIn reaction after reviewed create and delete mutations can bind the actor, target post, and resulting state.", + "risk": "R2", + "sideEffect": "Sets or clears the authenticated account's reaction on the exact confirmed LinkedIn post.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_urn": { + "type": "string", + "description": "Exact LinkedIn share or UGC-post URN", + "minLength": 1, + "maxLength": 500 + }, + "reaction": { + "type": "string", + "description": "Exact desired reaction, or none to clear it", + "enum": [ + "like", + "celebrate", + "support", + "love", + "insightful", + "funny", + "none" + ] + } + }, + "required": [ + "post_urn", + "reaction" + ] + }, + "webSession": { + "site": "linkedin", + "action": "reactions.set", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "relationships.connect": { + "description": "Capture-required contract reservation: send one confirmed LinkedIn connection invitation only after the exact viewer, target profile, optional note, duplicate state, request, and response bindings are captured and reviewed.", + "risk": "R3", + "sideEffect": "Sends one externally visible LinkedIn connection invitation to the exact confirmed profile.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "target_profile_urn": { + "type": "string", + "description": "Exact LinkedIn profile URN returned by a reviewed profile or recommendation read", + "minLength": 1, + "maxLength": 512 + }, + "note": { + "type": "string", + "description": "Optional exact invitation note", + "minLength": 1, + "maxLength": 300 + } + }, + "required": [ + "target_profile_urn" + ] + }, + "webSession": { + "site": "linkedin", + "action": "relationships.connect", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "articles.draft.save": { + "description": "Create or replace one private native LinkedIn Article draft from ArticleDraftDocument schemaVersion 2 with native quote blocks, ordered local inline images, alt text, captions, and either one local cover image or the exact existing replacement-draft cover using reviewed current-member-bound autosaves and unpublished readback.", + "risk": "R2", + "sideEffect": "Creates one private native LinkedIn Article draft with the confirmed cover, or replaces the exact confirmed existing private draft while either replacing or preserving its banner, plus the confirmed inline image bytes, alt text, and captions; it never publishes the draft.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "title": { + "type": "string", + "description": "Exact native article title", + "minLength": 1, + "maxLength": 150 + }, + "document": { + "type": "string", + "description": "Canonical JSON for ArticleDraftDocument schemaVersion 2; this reviewed LinkedIn contract supports bounded paragraphs, H1/H2 headings, native blockquotes, native HTTPS links, and ordered inline image blocks with required alt text and optional captions", + "minLength": 1, + "maxLength": 524288 + }, + "draft_id": { + "type": "string", + "description": "Optional exact existing private LinkedIn Article draft to replace in place", + "minLength": 1, + "maxLength": 512 + }, + "cover_image": { + "type": "file", + "description": "Exact local JPEG, PNG, or WebP cover rendered only in LinkedIn's banner slot; required for create, optional on an exact replacement to preserve the existing banner", + "maxBytes": 5242880, + "mediaTypes": [ + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "inline_images": { + "type": "array", + "description": "Ordered plan-bound local image files referenced by document imageIndex", + "items": { + "type": "file", + "description": "One exact JPEG, PNG, or WebP inline image", + "maxBytes": 5242880, + "mediaTypes": [ + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "title", + "document", + "inline_images" + ] + }, + "webSession": { + "site": "linkedin", + "action": "articles.draft.save", + "contractVersion": 7, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "articles.publish": { + "description": "Capture-required contract reservation: publish one confirmed native LinkedIn article only after its draft, optional cover upload, and publish sequence is captured and response-bound.", + "risk": "R3", + "sideEffect": "Publishes one externally visible native LinkedIn article with the exact confirmed title, body, cover, and audience.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "title": { + "type": "string", + "description": "Exact article title", + "minLength": 1, + "maxLength": 150 + }, + "body": { + "type": "string", + "description": "Exact native article body", + "minLength": 1, + "maxLength": 125000 + }, + "cover_image": { + "type": "file", + "description": "Optional reviewed article cover image", + "maxBytes": 20971520, + "mediaTypes": [ + "image/jpeg", + "image/png" + ] + }, + "visibility": { + "type": "string", + "description": "Confirmed article audience", + "enum": [ + "public", + "connections" + ] + } + }, + "required": [ + "title", + "body", + "visibility" + ] + }, + "webSession": { + "site": "linkedin", + "action": "articles.publish", + "contractVersion": 1, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + } + } +} diff --git a/src/media/manifest.test.ts b/src/media/manifest.test.ts index 340049d..35e2fa3 100644 --- a/src/media/manifest.test.ts +++ b/src/media/manifest.test.ts @@ -465,7 +465,7 @@ function trackedYtDlpManifest( describe("Wrench media manifest", () => { test("uses one Wrench-owned schema and transcriber identity", () => { expect(WRENCH_MEDIA_SCHEMA_VERSION).toBe(1); - expect(WRENCH_MEDIA_VERSION).toBe("0.13.1"); + expect(WRENCH_MEDIA_VERSION).toBe("0.13.2"); expect(localTranscriptVariantSegments(localIdentity)).toEqual([ "transcript", "local", diff --git a/src/media/manifest.ts b/src/media/manifest.ts index f99bba7..f6f2c49 100644 --- a/src/media/manifest.ts +++ b/src/media/manifest.ts @@ -39,7 +39,7 @@ import { import { compareUtf8 } from "./utf8-order"; export const WRENCH_MEDIA_SCHEMA_VERSION = 1 as const; -export const WRENCH_MEDIA_VERSION = "0.13.1" as const; +export const WRENCH_MEDIA_VERSION = "0.13.2" as const; export const WRENCH_MEDIA_MANIFEST_FILE = "wrench-media.json" as const; export const WRENCH_MEDIA_CHECKSUM_FILE = "manifest-sha256.txt" as const; const MAX_ITEM_ENTRIES = 4_096; diff --git a/src/model.test.ts b/src/model.test.ts index 2372de5..e40ddb6 100644 --- a/src/model.test.ts +++ b/src/model.test.ts @@ -1081,7 +1081,7 @@ describe("wrench manifest parsing", () => { const current = parseRuntimeManifest(currentValue); expect(current.ok).toBeTrue(); if (!current.ok) return; - expect(current.value.version).toBe("1.17.0"); + expect(current.value.version).toBe("1.18.0"); const draft = current.value.operations["articles.draft.save"]; expect(draft !== undefined && isWebSessionOperation(draft)).toBeTrue(); if (draft === undefined || !isWebSessionOperation(draft)) return; diff --git a/src/plugins/linkedin-web/plugin.test.ts b/src/plugins/linkedin-web/plugin.test.ts index cb639f7..4144f54 100644 --- a/src/plugins/linkedin-web/plugin.test.ts +++ b/src/plugins/linkedin-web/plugin.test.ts @@ -10,7 +10,7 @@ if (binding?.transport !== "web-session-api") { describe("LinkedIn web provider plugin", () => { test("versions the profile-stat source closure independently", () => { - expect(linkedinWebPlugin.version).toBe("1.3.0"); + expect(linkedinWebPlugin.version).toBe("1.4.0"); }); test("advertises observed exact personal and organization profile reads", () => { diff --git a/src/plugins/linkedin-web/plugin.ts b/src/plugins/linkedin-web/plugin.ts index 9c0a224..c34483c 100644 --- a/src/plugins/linkedin-web/plugin.ts +++ b/src/plugins/linkedin-web/plugin.ts @@ -290,7 +290,7 @@ const operations = Object.freeze([ export const linkedinWebPlugin = defineProviderPlugin({ apiVersion: 1, id: "linkedin-web", - version: "1.3.0", + version: "1.4.0", displayName: "LinkedIn Authenticated Web", sourceKind: "built-in", implementationSources: webImplementationSources(import.meta.url, [ diff --git a/src/provider-contract-inventory.test.ts b/src/provider-contract-inventory.test.ts index 914a115..a269276 100644 --- a/src/provider-contract-inventory.test.ts +++ b/src/provider-contract-inventory.test.ts @@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; const predecessorDefaultInventorySha256 = - "da82d8d952847e034bb46c0e9a5e2048b4592c778724db1f3c47501babbdd1d8"; + "8b31d4375d89a0421f73b8342b3cfe23a3c642faae6cd999511efc8cf0131de4"; const predecessorLegacyInventorySha256 = [ "c3026bbfb57dec86760e27873508c583706500d2a3fa4a025f0d134eb0a65612", "415bc8e2bed9b36ddf7b0660ac66eb748eeae5bf190c54c61a1bbad70f4ca8cc", @@ -13,10 +13,11 @@ const predecessorLegacyInventorySha256 = [ "5866c4d375b8a5f059738eb08601696c99d761554999c255d5e94c134f2070a9", "338b5807d7804d1dc4ee3c9f9b3fe399d8d281e8c569d54406816d5acfe3420b", "d608471810222a16a94f9b804bd545eadfdd7aba82049dbd7b584614279b2295", - "0e5abb2681b76cc2059edcf9dcdb639c072d3a8d7192335bdce7c6f784af5d6a", - "7bd485de58af72638842244da421cf8d048a5d298d718094f77b372e72fd92c2", - "59662306372d7b050a8bd76d04e698025fa65eaf07417bc351985605c79f95ae", - "d267d31ec70fd633d6ba2b6f9769dded20df7456dd4b7f9a056670204cd316f1", + "690d083413be239249da53f38be004a9ffdb2cd31a862a92f749c688a74261b3", + "9707875cde9b23a8600d8d86dbc13df634585d458f6071b63a9da961f7620c73", + "a4c7ccb37d286c55ff83b17614fbecd31f9f34d921d5487b92624a91af0f7f95", + "175b20407f193d0fb413f651e7b92db4acf3e4794fd76f5247e699a38fde9b24", + "7dd48dd60e8c9b5e2c609409bf33d0e8ebbc62d8c2c500d7ea25c83bbfbbc48e", "9fbec27b0fc78e6881092336d5c18501125c9c9cd7c123d8258ac99bb374f217", "f9f0feaf7f6bd5a11df12421e7517e44365238ba1508faa707ab24f60bc2558f", "ced281b781c1f73af8315245a68dfb5ea9fee9f737671e18fe1cd3ee5015ed20", @@ -233,6 +234,7 @@ describe("durable provider contract inventory", () => { 23, 23, 23, + 23, ], legacySha256: predecessorLegacyInventorySha256, acceptedLegacy: true, diff --git a/src/provider-plugin-contract-identity.ts b/src/provider-plugin-contract-identity.ts index f59d902..bcea44b 100644 --- a/src/provider-plugin-contract-identity.ts +++ b/src/provider-plugin-contract-identity.ts @@ -95,9 +95,10 @@ const identities = Object.freeze({ }, "linkedin-web": { schemaVersion: 1, - pluginVersion: "1.3.0", - implementationSha256: "baa96307587140a460e39274dd2f0f0aab8bee125c34c62231038d0401a7e737", + pluginVersion: "1.4.0", + implementationSha256: "f327a0baca1831436dc98be657c72b52f4e70494ff0e90204ac8c21a17f6a0ea", legacyCurrentReadImplementationSha256: [ + "baa96307587140a460e39274dd2f0f0aab8bee125c34c62231038d0401a7e737", "65d95a47f238acf3fac02116a1c671c7a9659dab766bd8081ccf69d4495dccda", "dbda2aee2075a0a3726241fd24932113ae6c2d139ae1f7036ea827a335f7344c", "9acb34f9ef5f59dd46b249766af236a3876a4975e0797e330f2975b3fd4ed643", @@ -266,7 +267,7 @@ const identities = Object.freeze({ } as const satisfies Readonly>); const SHA256_PATTERN = /^[a-f0-9]{64}$/u; -const MAXIMUM_LATER_CURRENT_IDENTITIES = 9; +const MAXIMUM_LATER_CURRENT_IDENTITIES = 10; for (const [pluginId, identity] of Object.entries(identities)) { if (!SHA256_PATTERN.test(identity.implementationSha256)) { diff --git a/src/providers/linkedin-web-runtime.internal.test.ts b/src/providers/linkedin-web-runtime.internal.test.ts index 61098ba..f3cf2dd 100644 --- a/src/providers/linkedin-web-runtime.internal.test.ts +++ b/src/providers/linkedin-web-runtime.internal.test.ts @@ -8,10 +8,6 @@ import type { StrictCookie } from "@hraness/kb/clip/cookies"; import type { WrenchAuth } from "../auth"; import { PreservedBrowserArtifactsError } from "../browser"; import type { WebSessionRecipe } from "../model"; -import { - listWebSessionCleanupAdmissions, - withWebSessionCleanupAdmission, -} from "../web-session-cleanup-admission"; import { canonicalJson } from "../canonical-json"; import { buildLinkedInArticleContent, @@ -36,7 +32,6 @@ import { } from "./linkedin-web-post-browser"; import { LinkedInProfileBrowserFailure, - LinkedInProfileBrowserResponseRejectedError, type LinkedInProfileBrowserTransport, } from "./linkedin-web-profile-browser"; @@ -55,6 +50,23 @@ const linkedinAuth = { subject: MEMBER_URN, } as const satisfies WrenchAuth; +const linkedinBrowserProfileAuth = { + schemaVersion: 1, + id: "linkedin-browser-profile-test", + kind: "browser-profile", + profile: "/private/tmp/linkedin-browser-profile-test", + trustUnfilteredEgress: true, + subject: MEMBER_URN, +} as const satisfies WrenchAuth; + +const unboundLinkedInBrowserProfileAuth = { + schemaVersion: 1, + id: "linkedin-browser-profile-unbound-test", + kind: "browser-profile", + profile: "/private/tmp/linkedin-browser-profile-unbound-test", + trustUnfilteredEgress: true, +} as const satisfies WrenchAuth; + type CapturedRequest = { readonly url: URL; readonly method: string; @@ -384,14 +396,18 @@ async function rejectionMessage(action: Promise): Promise { throw new Error("expected operation to reject"); } -function expectLinkedInHeaders(request: CapturedRequest, referer: string): void { +function expectLinkedInHeaders( + request: CapturedRequest, + referer: string, + expectedLiAt = "private-linkedin-cookie", +): void { expect(request.headers.get("accept")).toBe("application/vnd.linkedin.normalized+json+2.1"); expect(request.headers.get("csrf-token")).toBe("ajax:246813579"); expect(request.headers.get("referer")).toBe(referer); expect(request.headers.get("x-li-lang")).toBe("en_US"); expect(request.headers.get("x-requested-with")).toBe("XMLHttpRequest"); expect(request.headers.get("x-restli-protocol-version")).toBe("2.0.0"); - expect(request.headers.get("cookie")).toContain("li_at=private-linkedin-cookie"); + expect(request.headers.get("cookie")).toContain(`li_at=${expectedLiAt}`); } describe("LinkedIn authenticated internal-API runtime", () => { @@ -408,10 +424,11 @@ describe("LinkedIn authenticated internal-API runtime", () => { }); }); - expect(await probeLinkedInWebSubject(linkedinAuth, { + const subject = await probeLinkedInWebSubject(linkedinAuth, { timeoutMs: 1_000, dependencies: runtimeDependencies, - })).toBe(MEMBER_URN); + }); + expect(subject).toBe(MEMBER_URN); expect(calls).toHaveLength(1); }); @@ -1027,12 +1044,9 @@ describe("LinkedIn authenticated internal-API runtime", () => { expect(calls.map((call) => call.url.pathname)).toEqual(["/voyager/api/me"]); }); - test("restarts the whole personal stats read in one contained browser after only a direct current-member 401", async () => { + test("retains the cookie-source direct-401 browser fallback for compatibility", async () => { const directCalls: CapturedRequest[] = []; const browserCalls: string[] = []; - const cleanupBarriers: Promise[] = []; - const cleanupPublisher = () => undefined; - let closed = false; const transport: LinkedInProfileBrowserTransport = { currentIdentityResponse: () => { browserCalls.push("identity"); @@ -1052,7 +1066,7 @@ describe("LinkedIn authenticated internal-API runtime", () => { new Error("personal read must not request a company page"), ), close: () => { - closed = true; + browserCalls.push("close"); return Promise.resolve(); }, }; @@ -1066,12 +1080,177 @@ describe("LinkedIn authenticated internal-API runtime", () => { 401, )), now: () => Date.parse("2026-08-21T15:00:00.000Z"), + createProfileBrowserTransport: () => Promise.resolve(transport), + }, + }); + + expect(result).toMatchObject({ + status: "succeeded", + output: { + target: { id: MEMBER_URN }, + metrics: { + followers: { value: 7553 }, + connections: { value: 4877 }, + }, + }, + }); + expect(directCalls.map((call) => call.url.pathname)).toEqual(["/voyager/api/me"]); + expect(browserCalls).toEqual([ + "identity", + "profile:https://www.linkedin.com/in/0thernet/", + "connections:https://www.linkedin.com/in/0thernet/", + "close", + ]); + expect(JSON.stringify(result)).not.toContain("private-response-must-not-leak"); + }); + + test("probes browser-profile identity without acquiring or exporting cookies and always closes", async () => { + for (const outcome of ["success", "projection-failure"] as const) { + const browserCalls: string[] = []; + const transport: LinkedInProfileBrowserTransport = { + currentIdentityResponse: () => { + browserCalls.push("identity"); + return Promise.resolve(outcome === "success" + ? currentIdentityResponse() + : { data: {}, included: [] }); + }, + readProfileHtml: () => Promise.reject(new Error("probe crossed profile read")), + readConnectionsHtml: () => Promise.reject(new Error("probe crossed connections read")), + readOrganizationHtml: () => Promise.reject(new Error("probe crossed company read")), + close: () => { + browserCalls.push("close"); + return Promise.resolve(); + }, + }; + const action = probeLinkedInWebSubject(unboundLinkedInBrowserProfileAuth, { + timeoutMs: 1_000, + dependencies: { + acquireCookies: () => Promise.reject(new Error("browser-profile probe exported cookies")), + fetch: () => Promise.reject(new Error("browser-profile probe used direct fetch")), + createProfileBrowserTransport: (_auth, options) => { + expect(options).toMatchObject({ + maxOutputBytes: 2 * 1024 * 1024, + timeoutMs: 1_000, + }); + expect(options.operationDeadline).toBeDefined(); + return Promise.resolve(transport); + }, + }, + }); + + if (outcome === "success") expect(await action).toBe(MEMBER_URN); + else await expect(action).rejects.toThrow("primary member subject"); + expect(browserCalls).toEqual(["identity", "close"]); + } + }); + + test("cancels browser-profile probes before startup and during identity evaluation", async () => { + const preAborted = new AbortController(); + preAborted.abort(); + let browserCreates = 0; + await expect(probeLinkedInWebSubject(unboundLinkedInBrowserProfileAuth, { + timeoutMs: 1_000, + signal: preAborted.signal, + dependencies: { + acquireCookies: () => Promise.reject(new Error("pre-aborted probe exported cookies")), + fetch: () => Promise.reject(new Error("pre-aborted probe used direct fetch")), + createProfileBrowserTransport: () => { + browserCreates += 1; + throw new Error("pre-aborted probe started a browser"); + }, + }, + })).rejects.toThrow("authenticated web subject probe was cancelled"); + expect(browserCreates).toBe(0); + + const controller = new AbortController(); + const browserCalls: string[] = []; + let startedIdentity: (() => void) | undefined; + const identityStarted = new Promise((resolve) => { + startedIdentity = resolve; + }); + const transport: LinkedInProfileBrowserTransport = { + currentIdentityResponse: () => { + browserCalls.push("identity"); + startedIdentity?.(); + return new Promise((_resolve, reject) => { + const signal = operationSignal; + if (signal === null) { + reject(new Error("probe omitted its operation deadline")); + return; + } + if (signal.aborted) { + reject(new Error("probe identity observed cancellation")); + return; + } + signal.addEventListener( + "abort", + () => reject(new Error("probe identity observed cancellation")), + { once: true }, + ); + }); + }, + readProfileHtml: () => Promise.reject(new Error("cancelled probe crossed profile read")), + readConnectionsHtml: () => Promise.reject(new Error("cancelled probe crossed connections read")), + readOrganizationHtml: () => Promise.reject(new Error("cancelled probe crossed company read")), + close: () => { + browserCalls.push("close"); + return Promise.resolve(); + }, + }; + let operationSignal: AbortSignal | null = null; + const action = probeLinkedInWebSubject(unboundLinkedInBrowserProfileAuth, { + timeoutMs: 1_000, + signal: controller.signal, + dependencies: { + acquireCookies: () => Promise.reject(new Error("cancelled probe exported cookies")), + fetch: () => Promise.reject(new Error("cancelled probe used direct fetch")), createProfileBrowserTransport: (_auth, options) => { - expect(options).toMatchObject({ - maxOutputBytes: 4 * 1024 * 1024, - publishCleanupResource: cleanupPublisher, - timeoutMs: 1_000, - }); + operationSignal = options.operationDeadline?.signal ?? null; + return Promise.resolve(transport); + }, + }, + }); + await identityStarted; + controller.abort(); + await expect(action).rejects.toThrow("authenticated web subject probe was cancelled"); + expect(browserCalls).toEqual(["identity", "close"]); + }); + + test("uses browser-profile as the primary personal stats path with sequential cleanup tracking", async () => { + const browserCalls: string[] = []; + const cleanupBarriers: Promise[] = []; + const cleanupPublisher = () => undefined; + const transport: LinkedInProfileBrowserTransport = { + currentIdentityResponse: () => { + browserCalls.push("identity"); + return Promise.resolve(currentIdentityResponse()); + }, + readProfileHtml: (url) => { + browserCalls.push(`profile:${url}`); + return Promise.resolve( + '7,553 followers', + ); + }, + readConnectionsHtml: (url) => { + browserCalls.push(`connections:${url}`); + return Promise.resolve("

4,877 connections

"); + }, + readOrganizationHtml: () => Promise.reject(new Error("personal read crossed company page")), + close: () => { + browserCalls.push("close"); + return Promise.resolve(); + }, + }; + const result = await executeLinkedInWebOperation(personalProfileRecipe(), { + profile_url: "https://www.linkedin.com/in/0thernet", + include_connections: true, + }, linkedinBrowserProfileAuth, { + dependencies: { + acquireCookies: () => Promise.reject(new Error("browser-profile stats exported cookies")), + fetch: () => Promise.reject(new Error("browser-profile stats used direct fetch")), + now: () => Date.parse("2026-08-21T15:00:00.000Z"), + createProfileBrowserTransport: (_auth, options) => { + expect(options.publishCleanupResource).toBe(cleanupPublisher); return Promise.resolve(transport); }, }, @@ -1084,58 +1263,47 @@ describe("LinkedIn authenticated internal-API runtime", () => { expect(result).toMatchObject({ status: "succeeded", output: { - target: { id: MEMBER_URN, url: "https://www.linkedin.com/in/0thernet/" }, + target: { id: MEMBER_URN }, metrics: { - followers: { value: 7553, precision: "exact" }, - connections: { value: 4877, precision: "exact" }, + followers: { value: 7553 }, + connections: { value: 4877 }, }, }, }); - expect(directCalls.map((call) => call.url.pathname)).toEqual([ - "/voyager/api/me", - ]); expect(browserCalls).toEqual([ "identity", "profile:https://www.linkedin.com/in/0thernet/", "connections:https://www.linkedin.com/in/0thernet/", + "close", ]); - expect(JSON.stringify(result)).not.toContain("private-response-must-not-leak"); - expect(closed).toBeTrue(); expect(cleanupBarriers).toHaveLength(1); await Promise.all(cleanupBarriers); }); - test("restarts the whole organization stats read in one contained browser after a direct current-member redirect", async () => { - const directCalls: CapturedRequest[] = []; + test("uses browser-profile as the primary organization path without direct access", async () => { const browserCalls: string[] = []; - let closed = false; const transport: LinkedInProfileBrowserTransport = { currentIdentityResponse: () => { browserCalls.push("identity"); return Promise.resolve(currentIdentityResponse()); }, - readProfileHtml: () => Promise.reject(new Error("organization read crossed profile route")), - readConnectionsHtml: () => Promise.reject(new Error("organization read crossed connections route")), + readProfileHtml: () => Promise.reject(new Error("company read crossed profile page")), + readConnectionsHtml: () => Promise.reject(new Error("company read crossed connections page")), readOrganizationHtml: (url) => { browserCalls.push(`organization:${url}`); return Promise.resolve(linkedInOrganizationStatsHtml()); }, close: () => { - closed = true; + browserCalls.push("close"); return Promise.resolve(); }, }; const result = await executeLinkedInWebOperation(organizationRecipe(), { organization_url: "https://www.linkedin.com/company/hraness", - }, linkedinAuth, { + }, linkedinBrowserProfileAuth, { dependencies: { - ...dependencies(directCalls, () => new Response(null, { - status: 302, - headers: { - "content-type": "text/html", - location: "https://www.linkedin.com/login", - }, - })), + acquireCookies: () => Promise.reject(new Error("browser-profile company exported cookies")), + fetch: () => Promise.reject(new Error("browser-profile company used direct fetch")), now: () => Date.parse("2026-08-21T15:00:00.000Z"), createProfileBrowserTransport: () => Promise.resolve(transport), }, @@ -1144,149 +1312,145 @@ describe("LinkedIn authenticated internal-API runtime", () => { expect(result).toMatchObject({ status: "succeeded", output: { - target: { - id: "urn:li:fsd_company:123", - url: "https://www.linkedin.com/company/hraness/", - }, - metrics: { followers: { value: 6, precision: "exact" } }, + target: { id: "urn:li:fsd_company:123" }, + metrics: { followers: { value: 6 } }, }, }); - expect(directCalls.map((call) => call.url.pathname)).toEqual([ - "/voyager/api/me", - ]); expect(browserCalls).toEqual([ "identity", "organization:https://www.linkedin.com/company/hraness/", + "close", ]); - expect(closed).toBeTrue(); }); - test("propagates preserved profile-browser artifacts through cleanup admission for both readers", async () => { - const cases = [ - { - label: "personal", - recipe: personalProfileRecipe(), - input: { - profile_url: "https://www.linkedin.com/in/0thernet", - include_connections: true, + test("fails closed and admits cleanup when browser-profile stats finalization preserves artifacts", async () => { + for (const target of ["personal", "organization"] as const) { + const browserCalls: string[] = []; + const cleanupBarriers: Promise[] = []; + const cleanupPublisher = (_resource: unknown) => undefined; + const failure = new PreservedBrowserArtifactsError( + "private LinkedIn browser close detail", + "private-linkedin-recovery-handle", + new Error("private LinkedIn cleanup cause"), + ); + const transport: LinkedInProfileBrowserTransport = { + currentIdentityResponse: () => { + browserCalls.push("identity"); + return Promise.resolve(currentIdentityResponse()); }, - }, - { - label: "organization", - recipe: organizationRecipe(), - input: { - organization_url: "https://www.linkedin.com/company/hraness", + readProfileHtml: (url) => { + browserCalls.push(`profile:${url}`); + return Promise.resolve( + '7,553 followers', + ); }, - }, - ] as const; - const root = mkdtempSync(join(tmpdir(), "wrench-linkedin-profile-cleanup-")); - const environment = { - WRENCH_STATE_HOME: join(root, "wrench-home"), - HOME: root, - }; - try { - for (const [index, item] of cases.entries()) { - const directCalls: CapturedRequest[] = []; - const failure = new PreservedBrowserArtifactsError( - `private ${item.label} browser artifact detail`, - `synthetic-${item.label}-recovery-handle`, - new Error(`private ${item.label} cleanup cause`), - ); - let browserCreates = 0; - const authId = `linkedin-${item.label}-cleanup`; - const operation = withWebSessionCleanupAdmission({ - runId: `00000000-0000-4000-8000-00000000000${index + 1}`, - pluginId: "linkedin-web", - pluginVersion: "1.3.0", - pluginImplementationHash: "a".repeat(64), - adapterId: "linkedin-web", - adapterHash: "b".repeat(64), - surfaceId: "linkedin", - authId, - authHash: "c".repeat(64), - }, environment, (registerCleanupBarrier) => - executeLinkedInWebOperation( - item.recipe, - item.input, - linkedinAuth, - { - dependencies: { - ...dependencies(directCalls, () => jsonResponse({}, 401)), - createProfileBrowserTransport: (_auth, options) => { - browserCreates += 1; - expect(options.publishCleanupResource).toBeFunction(); - return Promise.reject(failure); - }, - }, - registerCleanupBarrier, - }, - )); + readConnectionsHtml: (url) => { + browserCalls.push(`connections:${url}`); + return Promise.resolve("

4,877 connections

"); + }, + readOrganizationHtml: (url) => { + browserCalls.push(`organization:${url}`); + return Promise.resolve(linkedInOrganizationStatsHtml()); + }, + close: () => { + browserCalls.push("close"); + return Promise.reject(failure); + }, + }; + const options = { + dependencies: { + acquireCookies: () => Promise.reject(new Error("cleanup failure exported cookies")), + fetch: () => Promise.reject(new Error("cleanup failure used direct fetch")), + now: () => Date.parse("2026-08-21T15:00:00.000Z"), + createProfileBrowserTransport: (_auth, browserOptions) => { + expect(browserOptions.publishCleanupResource).toBe(cleanupPublisher); + return Promise.resolve(transport); + }, + }, + registerCleanupBarrier: (barrier) => { + cleanupBarriers.push(barrier); + return cleanupPublisher; + }, + } satisfies Parameters[3]; + const action = target === "personal" + ? executeLinkedInWebOperation(personalProfileRecipe(), { + profile_url: "https://www.linkedin.com/in/0thernet", + include_connections: true, + }, linkedinBrowserProfileAuth, options) + : executeLinkedInWebOperation(organizationRecipe(), { + organization_url: "https://www.linkedin.com/company/hraness", + }, linkedinBrowserProfileAuth, options); + expect(cleanupBarriers).toHaveLength(1); + const cleanupOutcome = cleanupBarriers[0]!.then( + () => null, + (error: unknown) => error, + ); + const operationOutcome = action.then( + () => null, + (error: unknown) => error, + ); - const [operationOutcome] = await Promise.allSettled([operation]); - expect(operationOutcome?.status).toBe("rejected"); - if (operationOutcome?.status !== "rejected") { - throw new Error(`${item.label} profile read returned a normal result`); - } - expect(operationOutcome.reason).toBe(failure); - expect(browserCreates).toBe(1); - expect(directCalls.map((call) => call.url.pathname)).toEqual([ - "/voyager/api/me", - ]); - const admissions = listWebSessionCleanupAdmissions(environment); - expect(admissions).toHaveLength(index + 1); - expect(admissions.find((entry) => - "claim" in entry && entry.claim.authId === authId)) - .toMatchObject({ - claim: { - surfaceId: "linkedin", - authId, - containment: { status: "cleanup-unsafe" }, - resources: [{ status: "unpublished" }], - }, - }); - } - } finally { - rmSync(root, { recursive: true, force: true }); + expect(await operationOutcome).toBe(failure); + expect(await cleanupOutcome).toBe(failure); + expect(browserCalls).toEqual(target === "personal" + ? [ + "identity", + "profile:https://www.linkedin.com/in/0thernet/", + "connections:https://www.linkedin.com/in/0thernet/", + "close", + ] + : [ + "identity", + "organization:https://www.linkedin.com/company/hraness/", + "close", + ]); } }); - test("publishes only the typed bounded contained-browser response category", async () => { - const privateDiagnostic = "private-browser-response-body"; - const directCalls: CapturedRequest[] = []; - let closed = false; - const transport: LinkedInProfileBrowserTransport = { - currentIdentityResponse: () => Promise.reject( - new LinkedInProfileBrowserResponseRejectedError(401, "text/html"), - ), - readProfileHtml: () => Promise.reject(new Error(privateDiagnostic)), - readConnectionsHtml: () => Promise.reject(new Error(privateDiagnostic)), - readOrganizationHtml: () => Promise.reject(new Error(privateDiagnostic)), - close: () => { - closed = true; - return Promise.resolve(); - }, - }; - const result = await executeLinkedInWebOperation(personalProfileRecipe(), { - profile_url: "https://www.linkedin.com/in/0thernet", - include_connections: true, - }, linkedinAuth, { - dependencies: { - ...dependencies(directCalls, () => jsonResponse({}, 401)), - createProfileBrowserTransport: () => Promise.resolve(transport), - }, - }); + test("binds browser-profile personal reads to both the account subject and profile slug", async () => { + for (const drift of ["subject", "slug"] as const) { + const browserCalls: string[] = []; + const identity = drift === "subject" + ? { + data: { plainId: "987654321" }, + included: [{ + entityUrn: "urn:li:fsd_profile:987654321", + publicIdentifier: "0thernet", + }], + } + : currentIdentityResponse(); + const transport: LinkedInProfileBrowserTransport = { + currentIdentityResponse: () => { + browserCalls.push("identity"); + return Promise.resolve(identity); + }, + readProfileHtml: () => Promise.reject(new Error("drift reached profile page")), + readConnectionsHtml: () => Promise.reject(new Error("drift reached connections page")), + readOrganizationHtml: () => Promise.reject(new Error("drift reached company page")), + close: () => { + browserCalls.push("close"); + return Promise.resolve(); + }, + }; + const result = await executeLinkedInWebOperation(personalProfileRecipe(), { + profile_url: drift === "slug" + ? "https://www.linkedin.com/in/crossed-target" + : "https://www.linkedin.com/in/0thernet", + include_connections: true, + }, linkedinBrowserProfileAuth, { + dependencies: { + acquireCookies: () => Promise.reject(new Error("drift check exported cookies")), + fetch: () => Promise.reject(new Error("drift check used direct fetch")), + createProfileBrowserTransport: () => Promise.resolve(transport), + }, + }); - expect(result).toMatchObject({ - status: "failed", - output: null, - dispatch: { planned: 0, started: 0, verified: 0 }, - error: expect.stringContaining("first-party page response 401/text/html"), - }); - expect(JSON.stringify(result)).not.toContain(privateDiagnostic); - expect(closed).toBeTrue(); + expect(result).toMatchObject({ status: "failed", output: null }); + expect(browserCalls).toEqual(["identity", "close"]); + } }); - test("prioritizes closed contained-browser stages over legacy message heuristics", async () => { + test("redacts unexpected typed dependency failures from stats output", async () => { const cases = [ { expected: "contained-browser startup", @@ -1316,71 +1480,45 @@ describe("LinkedIn authenticated internal-API runtime", () => { for (const item of cases) { const privateDiagnostic = item.failure.message; - const transport: LinkedInProfileBrowserTransport = { - currentIdentityResponse: () => Promise.reject(item.failure), - readProfileHtml: () => Promise.reject(new Error("must not read profile")), - readConnectionsHtml: () => Promise.reject(new Error("must not read connections")), - readOrganizationHtml: () => Promise.reject(new Error("must not read organization")), - close: () => Promise.resolve(), - }; const result = await executeLinkedInWebOperation(personalProfileRecipe(), { profile_url: "https://www.linkedin.com/in/0thernet", include_connections: true, }, linkedinAuth, { dependencies: { ...dependencies([], () => jsonResponse({}, 401)), - createProfileBrowserTransport: () => item.startup - ? Promise.reject(item.failure) - : Promise.resolve(transport), + loadCachedCookies: () => Promise.reject(item.failure), }, }); expect(result).toMatchObject({ status: "failed", - error: expect.stringContaining(item.expected), + error: expect.stringContaining("no remote write occurred"), }); expect(JSON.stringify(result)).not.toContain(privateDiagnostic); } }); - test("never opens the fallback target page for a crossed browser-bound personal slug", async () => { + test("keeps an unreviewed direct identity 500 terminal before the requested personal page", async () => { const directCalls: CapturedRequest[] = []; - const browserCalls: string[] = []; - let closed = false; - const transport: LinkedInProfileBrowserTransport = { - currentIdentityResponse: () => { - browserCalls.push("identity"); - return Promise.resolve(currentIdentityResponse()); - }, - readProfileHtml: () => Promise.reject(new Error("crossed slug reached profile page")), - readConnectionsHtml: () => Promise.reject(new Error("crossed slug reached connections page")), - readOrganizationHtml: () => Promise.reject(new Error("crossed slug reached organization page")), - close: () => { - closed = true; - return Promise.resolve(); - }, - }; const result = await executeLinkedInWebOperation(personalProfileRecipe(), { profile_url: "https://www.linkedin.com/in/crossed-target", include_connections: true, }, linkedinAuth, { - dependencies: { - ...dependencies(directCalls, () => jsonResponse({}, 403)), - createProfileBrowserTransport: () => Promise.resolve(transport), - }, + dependencies: dependencies( + directCalls, + () => jsonResponse({ secret: "private-500-response" }, 500), + ), }); expect(result).toMatchObject({ status: "failed", output: null }); expect(directCalls.map((call) => call.url.pathname)).toEqual([ "/voyager/api/me", ]); - expect(browserCalls).toEqual(["identity"]); - expect(closed).toBeTrue(); + expect(JSON.stringify(result)).not.toContain("private-500-response"); }); - test("does not browser-fallback on identity-shape drift or on a later target-page rejection", async () => { + test("keeps identity-shape drift and later target-page rejection terminal", async () => { for (const mode of ["identity-shape", "target-page"] as const) { const calls: CapturedRequest[] = []; - let browserCreates = 0; const result = await executeLinkedInWebOperation(personalProfileRecipe(), { profile_url: "https://www.linkedin.com/in/0thernet", include_connections: true, @@ -1394,14 +1532,9 @@ describe("LinkedIn authenticated internal-API runtime", () => { } return jsonResponse({ secret: "later-private-response" }, 401); }), - createProfileBrowserTransport: () => { - browserCreates += 1; - return Promise.reject(new Error("browser fallback was not allowed")); - }, }, }); expect(result).toMatchObject({ status: "failed", output: null }); - expect(browserCreates).toBe(0); expect(calls.map((call) => call.url.pathname)).toEqual( mode === "identity-shape" ? ["/voyager/api/me"] @@ -1411,30 +1544,6 @@ describe("LinkedIn authenticated internal-API runtime", () => { } }); - test("propagates contained-browser cleanup failure instead of publishing a successful stats result", async () => { - const calls: CapturedRequest[] = []; - const transport: LinkedInProfileBrowserTransport = { - currentIdentityResponse: () => Promise.resolve(currentIdentityResponse()), - readProfileHtml: () => Promise.resolve( - '7,553 followers followers', - ), - readConnectionsHtml: () => Promise.resolve( - "

4,877 connections

", - ), - readOrganizationHtml: () => Promise.reject(new Error("unexpected organization read")), - close: () => Promise.reject(new Error("contained cleanup failed")), - }; - await expect(executeLinkedInWebOperation(personalProfileRecipe(), { - profile_url: "https://www.linkedin.com/in/0thernet", - include_connections: true, - }, linkedinAuth, { - dependencies: { - ...dependencies(calls, () => jsonResponse({}, 401)), - createProfileBrowserTransport: () => Promise.resolve(transport), - }, - })).rejects.toThrow("contained cleanup failed"); - }); - test("creates one private linked Article draft, verifies exact unpublished readback, and never publishes", async () => { const title = "Private fixture"; const documentValue = canonicalJson({ diff --git a/src/providers/linkedin-web-runtime.ts b/src/providers/linkedin-web-runtime.ts index 7a080aa..c88e04f 100644 --- a/src/providers/linkedin-web-runtime.ts +++ b/src/providers/linkedin-web-runtime.ts @@ -27,6 +27,7 @@ import { type SessionSecretSnapshot, type SessionSecretWriteResult, } from "../session-secrets"; +import { OperationDeadline } from "../operation-deadline"; import { createWebSessionClient, webSessionAuthSubject, @@ -699,6 +700,38 @@ export async function probeLinkedInWebSubject( readonly signal?: AbortSignal; } = {}, ): Promise { + if (auth.kind === "browser-profile") { + const timeoutMs = options.timeoutMs ?? 60_000; + const deadline = new OperationDeadline(timeoutMs, { + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); + const createTransport = options.dependencies?.createProfileBrowserTransport + ?? createLinkedInProfileBrowserTransport; + let transport: LinkedInProfileBrowserTransport | null = null; + try { + deadline.throwIfUnavailable("authenticated web subject probe"); + transport = await deadline.run( + () => createTransport(auth, { + timeoutMs, + maxOutputBytes: MAX_SUBJECT_BYTES, + operationDeadline: deadline, + }), + "authenticated web subject probe", + ); + return identityFromMeResponse( + await deadline.run( + () => transport!.currentIdentityResponse(), + "authenticated web subject probe", + ), + ).subject; + } finally { + try { + await transport?.close(); + } finally { + deadline.dispose(); + } + } + } const client = await createLinkedInClient( auth, options.timeoutMs ?? 60_000, @@ -814,19 +847,14 @@ async function executeLinkedInPersonalProfileRead( if (typeof includeConnections !== "boolean") { throw new Error("input.include_connections must be boolean"); } - let requestStage = "signed-in identity preflight"; + let requestStage = auth.kind === "browser-profile" + ? "contained-browser signed-in identity preflight" + : "signed-in identity preflight"; let browserTransport: LinkedInProfileBrowserTransport | null = null; try { - const client = await createLinkedInClient(auth, recipe.timeoutMs, options.dependencies, options); - const csrf = linkedInCsrfTokenFromJSessionId( - webSessionCookie(client.cookies, "JSESSIONID"), - ); + let client: WebSessionClient | null = null; let identity: LinkedInCurrentIdentity; - try { - identity = await currentIdentity(client, csrf); - } catch (error) { - if (!linkedInCurrentIdentityAllowsBrowserFallback(error)) throw error; - requestStage = "contained-browser signed-in identity preflight"; + if (auth.kind === "browser-profile") { browserTransport = await createLinkedInStatsBrowserTransport( auth, recipe, @@ -835,6 +863,25 @@ async function executeLinkedInPersonalProfileRead( identity = identityFromMeResponse( await browserTransport.currentIdentityResponse(), ); + } else { + client = await createLinkedInClient(auth, recipe.timeoutMs, options.dependencies, options); + const csrf = linkedInCsrfTokenFromJSessionId( + webSessionCookie(client.cookies, "JSESSIONID"), + ); + try { + identity = await currentIdentity(client, csrf); + } catch (error) { + if (!linkedInCurrentIdentityAllowsBrowserFallback(error)) throw error; + requestStage = "contained-browser signed-in identity preflight"; + browserTransport = await createLinkedInStatsBrowserTransport( + auth, + recipe, + options, + ); + identity = identityFromMeResponse( + await browserTransport.currentIdentityResponse(), + ); + } } const subject = boundLinkedInStatsIdentity(auth, identity); if (identity.publicIdentifier === null) { @@ -850,29 +897,36 @@ async function executeLinkedInPersonalProfileRead( requestStage = browserTransport === null ? "public self-profile page read" : "contained-browser public self-profile page read"; - const profileHtml = browserTransport === null - ? await client.requestText({ - url: new URL(target.url), - method: "GET", - headers: linkedInHtmlHeaders(`${LINKEDIN_ORIGIN}/feed/`), - expectedContentTypes: ["text/html"], - maxBytes: recipe.maxOutputBytes, - }) - : await browserTransport.readProfileHtml(target.url); + let profileHtml: string; + if (browserTransport !== null) { + profileHtml = await browserTransport.readProfileHtml(target.url); + } else { + if (client === null) throw new Error("LinkedIn direct stats client is unavailable"); + profileHtml = await client.requestText({ + url: new URL(target.url), + method: "GET", + headers: linkedInHtmlHeaders(`${LINKEDIN_ORIGIN}/feed/`), + expectedContentTypes: ["text/html"], + maxBytes: recipe.maxOutputBytes, + }); + } let connectionsHtml: string | null = null; if (includeConnections) { requestStage = browserTransport === null ? "private My Network connections page read" : "contained-browser private My Network connections page read"; - connectionsHtml = browserTransport === null - ? await client.requestText({ - url: new URL(`${LINKEDIN_ORIGIN}/mynetwork/invite-connect/connections/`), - method: "GET", - headers: linkedInHtmlHeaders(target.url), - expectedContentTypes: ["text/html"], - maxBytes: recipe.maxOutputBytes, - }) - : await browserTransport.readConnectionsHtml(target.url); + if (browserTransport !== null) { + connectionsHtml = await browserTransport.readConnectionsHtml(target.url); + } else { + if (client === null) throw new Error("LinkedIn direct stats client is unavailable"); + connectionsHtml = await client.requestText({ + url: new URL(`${LINKEDIN_ORIGIN}/mynetwork/invite-connect/connections/`), + method: "GET", + headers: linkedInHtmlHeaders(target.url), + expectedContentTypes: ["text/html"], + maxBytes: recipe.maxOutputBytes, + }); + } } requestStage = "exact metric projection"; const output = projectLinkedInPersonalProfileStats({ @@ -912,19 +966,14 @@ async function executeLinkedInOrganizationRead( options: LinkedInWebExecutionOptions, ): Promise { const target = linkedInOrganizationTarget(input.organization_url); - let requestStage = "signed-in identity preflight"; + let requestStage = auth.kind === "browser-profile" + ? "contained-browser signed-in identity preflight" + : "signed-in identity preflight"; let browserTransport: LinkedInProfileBrowserTransport | null = null; try { - const client = await createLinkedInClient(auth, recipe.timeoutMs, options.dependencies, options); - const csrf = linkedInCsrfTokenFromJSessionId( - webSessionCookie(client.cookies, "JSESSIONID"), - ); + let client: WebSessionClient | null = null; let identity: LinkedInCurrentIdentity; - try { - identity = await currentIdentity(client, csrf); - } catch (error) { - if (!linkedInCurrentIdentityAllowsBrowserFallback(error)) throw error; - requestStage = "contained-browser signed-in identity preflight"; + if (auth.kind === "browser-profile") { browserTransport = await createLinkedInStatsBrowserTransport( auth, recipe, @@ -933,20 +982,43 @@ async function executeLinkedInOrganizationRead( identity = identityFromMeResponse( await browserTransport.currentIdentityResponse(), ); + } else { + client = await createLinkedInClient(auth, recipe.timeoutMs, options.dependencies, options); + const csrf = linkedInCsrfTokenFromJSessionId( + webSessionCookie(client.cookies, "JSESSIONID"), + ); + try { + identity = await currentIdentity(client, csrf); + } catch (error) { + if (!linkedInCurrentIdentityAllowsBrowserFallback(error)) throw error; + requestStage = "contained-browser signed-in identity preflight"; + browserTransport = await createLinkedInStatsBrowserTransport( + auth, + recipe, + options, + ); + identity = identityFromMeResponse( + await browserTransport.currentIdentityResponse(), + ); + } } boundLinkedInStatsIdentity(auth, identity); requestStage = browserTransport === null ? "public company page read" : "contained-browser public company page read"; - const html = browserTransport === null - ? await client.requestText({ - url: new URL(target.url), - method: "GET", - headers: linkedInHtmlHeaders(`${LINKEDIN_ORIGIN}/feed/`), - expectedContentTypes: ["text/html"], - maxBytes: recipe.maxOutputBytes, - }) - : await browserTransport.readOrganizationHtml(target.url); + let html: string; + if (browserTransport !== null) { + html = await browserTransport.readOrganizationHtml(target.url); + } else { + if (client === null) throw new Error("LinkedIn direct stats client is unavailable"); + html = await client.requestText({ + url: new URL(target.url), + method: "GET", + headers: linkedInHtmlHeaders(`${LINKEDIN_ORIGIN}/feed/`), + expectedContentTypes: ["text/html"], + maxBytes: recipe.maxOutputBytes, + }); + } requestStage = "exact company metric projection"; const output = projectLinkedInOrganizationStats({ html, diff --git a/src/scripts/sync-bundled-adapters.test.ts b/src/scripts/sync-bundled-adapters.test.ts index 111a1cb..c8a953e 100644 --- a/src/scripts/sync-bundled-adapters.test.ts +++ b/src/scripts/sync-bundled-adapters.test.ts @@ -173,6 +173,7 @@ describe("single-process bundled adapter generation sync", () => { "linkedin-web@1.14.0", "linkedin-web@1.15.0", "linkedin-web@1.16.0", + "linkedin-web@1.17.0", "linkedin-web@1.2.0", "linkedin-web@1.3.0", "linkedin-web@1.4.0", diff --git a/src/web-session-contracts.test.ts b/src/web-session-contracts.test.ts index e7b5a63..6c9e006 100644 --- a/src/web-session-contracts.test.ts +++ b/src/web-session-contracts.test.ts @@ -65,7 +65,7 @@ describe("authenticated web-session contract identity", () => { "1c79b86a2133354878d44483e5a78efa9560f8c7f8f2c1014a20d0c998c0024f", ); expect(webSessionContractHash(linkedinFeed)).toBe( - "5f0c08ad357254f86200c7c8361842ef2f92da191ebfd15cf3d08b63ed67c8f7", + "0ae0b0279917a5964578558aa3e761d467a121b6fe4d04c09441f279fcb0f30f", ); expect(webSessionContractHash(facebookFeed)).toBe( "208db74b57370fb1e5014cd65ac3f4df317a889147609f473524c11a7bd25b0a", diff --git a/website/build.ts b/website/build.ts index 2648b2d..4af8e2b 100644 --- a/website/build.ts +++ b/website/build.ts @@ -24,7 +24,7 @@ export const REPOSITORY_URL = "https://github.com/hraness/wrench" as const; export const PUBLISHER_URL = "https://github.com/hraness" as const; export const SKILL_INSTALL_COMMAND = "npx skills add hraness/wrench" as const; export const SKILL_INSTALL_COMMAND_BUNX = "bunx skills add hraness/wrench" as const; -export const CONTENT_REVIEWED_RELEASE = "v0.13.1" as const; +export const CONTENT_REVIEWED_RELEASE = "v0.13.2" as const; export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" as const; export const PUBLIC_PAGES = [