feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(browser): embedded browser automation (opencli CDP observe→act) - #18

Merged
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser
Jun 16, 2026
Merged

feat(browser): embedded browser automation (opencli CDP observe→act)#18
Astro-Han merged 3 commits into
mainfrom
claude/embedded-browser

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.

  • Sealed CDP bridge (cdp-bridge.ts): wraps webContents.debugger behind a loopback WebSocket (ws://127.0.0.1:<random-port>/<secret>, secret kept in main-process memory only). opencli's CDPBridge client connects to it; its stealth script auto-registers on connect and applies to future documents.
  • 6 generic observe→act tools (registered unconditionally, no flag): browser_navigate / snapshot / click / type / wait / extract. Loop: numbered [ref] snapshot (observe) → act by ref (click/type).
  • Per-conversation views: each conversation owns a WebContentsView child 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.
  • Dedicated browser permission 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.
  • Visible-conversation lease: the agent touches the browser only for the conversation currently on screen. EVERY action — 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 long wait/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.
  • Renderer panel (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

  • Browser views are ephemeral across app restart (login persists, the live page does not) — the simplest split; the agent re-opens on demand. Persisting the live URL across restarts was intentionally skipped.
  • Multi-session parallel browsing is out of scope for now (YAGNI), tracked as a follow-up. The browser is single-window: one view is drawn at a time, and the visible-lease rejects mutations on an off-screen conversation. The blocker is the presentation layer, not state management — the runtime already runs sessions concurrently (SessionManager keys active sessions by id) and each conversation already owns its own view + CDP connection + history. A hidden embedded WebContentsView simply 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.
  • No "open browser from UI" button yet — the only cold entry is the agent (a navigate creates 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.
  • No browser_screenshot yet — 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.
  • Cut after review. Two independent reviewers (Codex + a fresh-eye pass, Occam's-razor focus) drove a cleanup commit that removed the dead tool/host surface: the screenshot stub, speculative snapshot knobs, a duplicated URL validator, an unwired probe abstraction, and a few zero-caller exports (−157 lines, no capability lost).
  • Hardened after a second review round. Added the visible-conversation lease above — the safety gap the review flagged (the agent could otherwise act on a hidden, backgrounded view). Plus two small fixes: viewportBounds now rejects non-finite rects from the untyped IPC boundary before they reach setBounds, 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).
  • Tightened after a third review round. The browser permission is now a single explicit contract: browser gets 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_extract recovers from an invalid CSS selector (a [12] ref a model echoes) as a clean “no match” instead of a raw DOMException, and @jackwener/opencli is pinned to exact 1.8.4 to match the contract test’s “pinned release” assertion.
  • Tightened after a fourth review round. Two safety gaps closed by one model — the agent touches the browser only for the conversation you're watching, and only after you approve it for the turn: (1) the first approved click/type after a permission grant no longer loses the race against the renderer's viewport restore (the modal hides the native view; the mutate now waits briefly for the strip to come back rather than rejecting); (2) the visible lease now gates reads too, so a backgrounded conversation can't snapshot/extract a 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).
  • Tightened after a fifth review round. The whole-turn browser note no longer overstates: it renders only when "本轮记住" is checked (the runtime persists the grant only on allow && rememberForTurn — locked by the rememberForTurn=false does NOT add to set test), and it drops the inaccurate "switching conversations revokes the grant" line. endTurn fires 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.
  • Tightened after a sixth review round. The visible lease is now CONTINUOUS, not a one-time preflight: canDrive() only gated the start, so a browser_wait / navigate / extract / delayed mutate that began while shown kept running after the user switched away. withBrowserPage registers each in-flight action; main's browser:active-session handler calls revokeHiddenBrowserActions(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 new BrowserActionRevokedError — 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.
  • Chat-header badges were hardened so the narrower chat column (browser panel open) no longer collapses short status pills (e.g. 运行中) into vertical text; only the tab title and model label absorb the squeeze.

Verification

Freshly re-run on this branch:

  • npm run typecheck — clean across core / storage / runtime / ui / desktop.
  • Unit tests:
    • core 618 pass / 0 fail
    • storage 76 pass / 0 fail
    • desktop 1469 pass / 0 fail
    • runtime 396 pass / 1 fail — pre-existing & environment-dependent, not from this PR (which touches no runtime/network code). The failing test network/proxy-test "times out when the proxy accepts TCP but never responds" asserts the error text matches /timeout/i, but in this sandbox fetch to 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:browser19/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/BrowserSession end 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.
  • Manual GUI smoke on real sites: panel render, address bar + nav, agent observe→act, permission prompts (explore/ask/execute), per-conversation view isolation, shared login partition, stealth probe, archive/quit teardown.

…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-Han
Astro-Hanforce-pushed the claude/embedded-browser branch from 731c333 to 1b1d195CompareJune 16, 2026 11:17
@Astro-Han
Astro-Han merged commit 66e4295 into mainJun 16, 2026
@Astro-Han
Astro-Han deleted the claude/embedded-browser branch June 16, 2026 11:21
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)
jackwener pushed a commit that referenced this pull request Jun 21, 2026
feat(browser): embedded browser automation (opencli CDP observe→act)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han