Uh oh!
There was an error while loading. Please reload this page.
feat(browser): embedded browser automation (opencli CDP observe→act) - #18
Merged
Conversation
…n lease Port PawWork's opencli CDP + numbered-ref observe→act approach into the desktop agent. The agent drives a real, per-conversation Chromium view and the page renders live in a right-side panel. - Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket whose secret stays in main-process memory and never crosses IPC; opencli's stealth script auto-registers on connect. - Six generic observe→act tools (navigate / snapshot / click / type / wait / extract): numbered [ref] snapshot to observe, act by ref. Takeover-reload is deferred to the first mutate so observing never disturbs a page the user has open; browser_extract treats an invalid selector as "no match". - Per-conversation WebContentsView (controller + view-manager), the renderer panel that mirrors its on-screen strip each frame, and the main/preload IPC wiring. Views are ephemeral across restart; a shared persistent partition keeps logins, with a once-per-partition security backstop. - Visible-conversation lease: the agent touches the browser only for the conversation on screen. EVERY action is rejected when its conversation is backgrounded; a mutate also needs a real on-screen viewport (native CDP clicks hit-test a composited frame a hidden view lacks). The lease is continuous and revocable — an action still running when the user switches away is severed — and background throttling tracks shown-ness. Includes the main-process unit tests (cdp-bridge, session, logic, tools, view-manager, automation-host) driven through fakes, no live CDP endpoint.
…-wide prompt Browser effects are irreversible, so they get their own permission category instead of riding the read/exec defaults: - `browser` is prompt-on-effect: blocked in explore, prompts in ask AND execute (never auto-allowed like read tools). - It carries its own prompt reason (the dialog names the logged-in session it drives, not a generic "custom" request) and ONE turn-wide permission scope, so "allow for this turn" carries the whole observe→act loop instead of re-prompting on every ref. - The prompt note (shown only when "remember for this turn" is checked, since the grant only persists then) is honest that one allow covers the turn's reads, navigation, clicks, and typing — the live visible view is the act-phase safety net, so there is no second prompt. Also keeps the chat-header badges from collapsing into vertical text when the browser panel narrows the chat column.
…pencli - browser-observe-act-smoke.mjs: real Electron E2E (sealed ws bridge ↔ webContents.debugger ↔ opencli ↔ live DOM): goto → numbered snapshot → fill-by-ref → click-by-ref → DOM-effect verify → markdown extract; the partition backstop installing once across views; and the visible lease end to end through the real host/manager/BrowserSession — background read / navigate / mutate rejected with no view or connection, a mutate waiting out a modal-close viewport restore, an in-flight read revoked against the live bridge on switch-away, and background throttling restored on hide. - Pin @jackwener/opencli to exact 1.8.4 (+ lockfile) so the opencli-contract test's "pinned release" assertion can't drift on a future 1.8.x.
Astro-Hanforce-pushed
the
claude/embedded-browser
branch
from
June 16, 2026 11:17
731c333 to
1b1d195Comparejackwener pushed a commit
that referenced
this pull request
Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
jackwener pushed a commit
that referenced
this pull request
Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
jackwener pushed a commit
that referenced
this pull request
Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
jackwener pushed a commit
that referenced
this pull request
Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Embedded browser automation for the desktop agent — ported from PawWork's opencli CDP + numbered-ref observe→act approach. The agent drives a real, per-conversation Chromium view through 6 generic tools, and the page renders live in a right-side panel.
cdp-bridge.ts): wrapswebContents.debuggerbehind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli'sCDPBridgeclient connects to it; its stealth script auto-registers on connect and applies to future documents.browser_navigate / snapshot / click / type / wait / extract. Loop: numbered[ref]snapshot (observe) → act by ref (click/type).WebContentsViewchild of the main window — lazily created, stacked, all hidden except the shown one. Views are in-memory (ephemeral across app restart, by design); a shared persistent partition (persist:maka-browser) keeps cookies/login on disk so a login in one conversation is available to all and survives restart.browserpermission category — prompt-on-effect: blocked in explore, prompts in ask and execute (browser effects are treated as irreversible, not auto-allowed like read tools). Takeover-reload is deferred to first effect: observe never reloads, the first mutate reloads once to apply stealth to an already-open page, navigate clears without reloading.snapshot/extract/wait(read),navigate,click/type(act) — is rejected when the calling conversation is backgrounded, so a background conversation can't even read a logged-in page the user can't see; a mutate additionally requires a real, non-empty on-screen viewport (opencli's native CDP click hit-tests a composited frame a hidden view lacks). The check runs before the view/connection is acquired, so a vetoed background action creates neither. The lease is continuous, not just a preflight: an action already running when the user switches away is revoked and its connection severed, so a longwait/navigate/extract/delayed mutate can never keep reading or driving a now-hidden page. Because the permission modal hides the native view while it is open, a mutate on the on-screen conversation whose viewport is momentarily absent (the modal just closed) waits briefly for the renderer to restore the strip rather than rejecting — so the first approved click/type lands without a retry. The user always sees the page the agent is acting on — the visible view plus the per-turn permission prompt is the safety net, now enforced rather than assumed.browser-panel.tsx): address bar + nav controls (lucide icons, matching the app's icon set). The panel reserves a strip and mirrors its on-screen rect to main each animation frame, so the native view tracks the strip on resize / sidebar drags. The page is a native view floating above the DOM, not a React child.Deliberate scope
SessionManagerkeys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embeddedWebContentsViewsimply can't be driven for clicks: tested directly, a non-displayed view still does snapshot / type / navigate, but a native click silently no-ops because it never composites a frame to hit-test — true even with PawWork's exact 1280×720 default bound, parented or not. PawWork (where this was ported from) gets parallelism by being multi-window (each conversation's view displayed in its own visible window), not by hidden rendering. A follow-up would pick a presentation change — multi-window (à la PawWork), single-window split-pane, or offscreen rendering — and the current lease is just the single-window special case of "a view must be displayed somewhere to be driven", which generalizes when that lands.navigatecreates the conversation's panel). The address bar handles manual navigation once a page is open, but it lives inside the panel, which mounts only for a live view — so it is not itself a cold-start entry. A small open-browser affordance is deferred to a follow-up PR.browser_screenshotyet — the app feeds no images to the model at all (attachments are stringified into the prompt), so a screenshot tool would only ever return a byte count the model can't use. Deferred to the phase-2 multimodal PR, which adds image-to-model support and rebuilds the tool against it.snapshotknobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).viewportBoundsnow rejects non-finite rects from the untyped IPC boundary before they reachsetBounds, and the shared-partition security backstop (will-download+ permission handlers) installs once per session instead of once per view (no listener pile-up across conversations).browsergets its own prompt reason (the dialog names the logged-in session it drives, not a generic “custom” request) and one turn-wide permission scope, so “allow for this turn” actually carries the whole observe→act loop instead of re-prompting on every ref. Plus:browser_extractrecovers from an invalid CSS selector (a[12]ref a model echoes) as a clean “no match” instead of a raw DOMException, and@jackwener/opencliis pinned to exact1.8.4to match the contract test’s “pinned release” assertion.snapshot/extracta logged-in page off screen; and (3) the prompt is honest that one browser grant covers the whole turn's reads, navigation, clicks, and typing (rather than splitting into two prompts — the live visible view is the act-phase safety net).allow && rememberForTurn— locked by therememberForTurn=false does NOT add to settest), and it drops the inaccurate "switching conversations revokes the grant" line.endTurnfires on run completion/abort (closing a conversation aborts its turn), never on a plain switch — a switch just parks the action behind the visible lease and resumes it on return without re-asking. The note now states the grant expires when the turn ends.canDrive()only gated the start, so abrowser_wait/navigate/extract/ delayed mutate that began while shown kept running after the user switched away.withBrowserPageregisters each in-flight action; main'sbrowser:active-sessionhandler callsrevokeHiddenBrowserActions(shown)on every switch, severing any action whose conversation just went off screen (same connection-sever path as a timeout/abort) and rejecting with a newBrowserActionRevokedError— so no tool result can carry hidden-page data. And background throttling is now scoped to shown-ness instead of held off for the whole cached connection's life: a hidden conversation's page throttles normally (no off-screen CPU drain), while a shown view stays full-speed so native CDP clicks composite (deleting the override outright was tried and rejected — the smoke proves it's load-bearing for clicks on any view the OS backgrounds, e.g. the app being unfocused). Covered by two new session unit tests + three new smoke checks (live-bridge revoke, both throttle transitions); smoke now 19/19.Verification
Freshly re-run on this branch:
npm run typecheck— clean across core / storage / runtime / ui / desktop.network/proxy-test"times out when the proxy accepts TCP but never responds" asserts the error text matches/timeout/i, but in this sandboxfetchto the target returns "fetch failed" before the 100 ms timeout fires — a machine-dependence the test's own comment calls out.Validated on this branch (CDP path unchanged by the later UI commits):
npm run smoke:browser— 19/19: real Electron observe→act E2E (sealed ws bridge ↔webContents.debugger↔ opencli ↔ live DOM): goto → numbered snapshot → fill-by-ref → click-by-ref → DOM-effect verify → markdown extract, plus a check that the partition security backstop installs once across views, plus the visible-lease driven through the real host/manager/BrowserSessionend to end (background-conversation read/navigate/mutate all rejected with no view or connection created; a mutate on the shown conversation waits out a modal-close viewport restore and lands; click/type land once the conversation is shown with a viewport). New this round: an in-flight read is revoked against the live bridge when its conversation goes off screen, and a shown view runs un-throttled while hiding it restores background throttling.