Skip to content

feat(desktop): persistent browser profile, extensions and agent tool fixes - #63002

Open
Hona wants to merge 60 commits into
devfrom
persisted-browser
Open

Hona wants to merge 60 commits into
devfrom
persisted-browser

Conversation

@Hona

@Hona Hona commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

The desktop's in-app browser was a signed-out, in-memory pane per attachment. Agents drove it through tools that failed on one call in five and ignored a configured browser deny. After this PR it is one persistent, encrypted profile with imported sign-ins, Chrome extensions and in-pane links. Its agent tools follow the user's permission rules and work on tabs that are not on screen.

Area Before After
Profile Fresh in-memory partition per pane attachment One persist:opencode-browser profile for the whole app
Sign-ins Lost on restart, tab close or a new session Kept on disk; import from 14 browsers, Safari and cookie files
Links Web links open the system browser Plain click opens the pane; Shift or other modifiers keep the system browser
Extensions None Install from a store link, a browser profile or a folder; popups over the pane; per-extension verdict
Ad blocking n/a uBlock Origin (MV2) and uBlock Origin Lite block requests
Tool reliability 20.7% of 14,974 measured calls failed The top error groups are fixed or get a precise message (see Why)
Hidden tabs Screenshot refused, click fails with UnknownVizError Screenshot, click and hover work while the tab is not on screen
Permissions Attach grant beats a configured deny; subagents and killed servers keep grants Configured deny and ask win per operation; grants do not leak
Cookie storage Plain text (EnableCookieEncryption off) Encrypted with the OS key store in packaged builds
demo.mp4

The video shows the original profile, import and extension flow. It was recorded before the extension APIs, tool, permission and macOS work below.

uBlock Origin popup over the pane Dark Reader popup

Why

Measured tool-call errors

Browser calls from the session databases of four devices (adais and blade on Windows, air on macOS, vps on Linux with a remote desktop): 14,974 calls, 3,099 errors.

Error group adais blade air vps Total After this PR
wait without condition (Playwright-style text, timeout, state, time) 70 2,033 2 33 2,138 Condition read from the keys present
screenshot "needs a visible tab" 88 207 1 9 305 Captures hidden tabs
evaluate without script (expression, code) 81 55 3 1 140 Aliases accepted
[browser.disconnected], no desktop attached 38 27 2 13 80 Message names the next step (still an error)
Page JavaScript threw 16 52 - 12 80 Top-level return and await now run; other script errors unchanged
Caught by the script, message not stored 8 64 - - 72 Code Mode stores toolCalls[].error
Invalid browser URL (file path, file:) 3 55 - - 58 Still refused; names the value and points to preview
click UnknownVizError 7 31 - 5 43 Fixed (no forced capturePage)
tab_unavailable 5 16 - 1 22 Lists the open tab IDs
wait condition not met 9 8 - - 17 Returns met: false
evaluate, tabs.open, preview 60 s timeouts 11 9 2 4 26 Not addressed
  • Estimate, not measured after the change: about 2,150 wait and 141 evaluate errors become successful calls, and about 160 more become one precise message.
  • Without the wait group, every device was at 6.8 to 9.1%.

Community issues

Architecture

flowchart LR
  click["plain click on an http(s) link"] --> links["Links.open"]
  links -->|"browser LinkHandler"| tab["pane tab"]
  links -->|"modifier key or no handler"| system["system browser"]
  tab --> local{"server on this machine?"}
  local -->|yes| profile["persist:opencode-browser<br/>cookies, site data, extensions"]
  local -->|no| remote["per-attachment partition<br/>server tunnel"]
  remote <-->|"cookie mirror, both ways"| profile
  profile --> ext["extensions<br/>Electron APIs + profile/extension-api"]
  ext --> popup["popup over the pane"]
  agent["agent browser.* call"] --> refuse["refuse: configured deny"]
  refuse --> lookup["tab and connection lookup"]
  lookup --> authorize["authorize: permission.assert<br/>ask, tool.use policy, Allow always"]
  authorize --> gate{"extension page?"}
  gate -->|yes| refused["refused"]
  gate -->|no| tab
Loading

Profile and sign-in import

  • One profile: PARTITION = "persist:opencode-browser" in packages/gui-plugins/src/browser/profile/index.ts. Servers on this machine browse in it directly.

  • Remote servers: keep their own partition, because Chromium sets the proxy per partition. link() copies every profile cookie into it and mirrors changes both ways. Site storage and extensions stay with the profile.

  • User agent: the profile drops the Electron/… and app tokens, which sign-in pages such as Google's refuse.

  • Import sources (profile/sources.ts, chromium.ts, firefox.ts, safari.ts, cookie.ts):

    Family Browsers Key
    Chromium Chrome, Edge, Helium, Brave, Arc, Vivaldi, Opera, Chromium, Dia, Comet DPAPI (Windows), Keychain (macOS), libsecret (Linux)
    Firefox Firefox, Zen, LibreWolf, Floorp none (cookies.sqlite)
    Safari Safari none (Cookies.binarycookies, needs Full Disk Access)
    Files Netscape cookies.txt, Chrome-API JSON, Playwright storage state n/a
  • Offer in the pane: import-prompt.tsx shows a bar (a card on the new tab page) for the most recently used browser until the user imports or clicks "Not now". Settings › Browser is rebuilt around sign-ins, extensions and site data (settings.tsx).

  • Chrome and Edge on Windows: most cookies use app-bound v20 keys that only the browser can unwrap. The import counts them and says to export with a cookie extension instead. A locked Cookies database (browser still running) is named too.

  • macOS Keychain: the Chromium key is read with /usr/bin/security, so macOS names "security" in the prompt. While it is open, the app says what it asks and to choose Allow, not Always Allow.

  • Cannot save sign-ins: at start, cookiesPersist writes one probe cookie (cookie-check.opencode.invalid), flushes and counts it in a copy of the Cookies database. If it is missing (Keychain key denied), Settings and the offer show a warning and imports are off. Extensions cannot see the probe cookie.

  • macOS icons: browser icons come from the Quick Look thumbnail of the resolved bundle path.

Links

  • createWebLinks (packages/app/src/runtime/plugin/render.tsx) offers a plain left click on an http(s) link inside [data-pane-row] to Links.open. Modifier clicks, download links and links in dialogs keep the default.
  • The browser plugin's LinkHandler (browser/renderer.tsx, priority 5) takes it when the pane is available for that session and the setting is on. The file plugin no longer claims web links.
  • Terminal Ctrl+click on a URL goes through the same path (terminal/terminal.tsx).
  • Settings › Browser › "Open links in the browser pane" turns it off.

Extensions

  • Install: a Chrome Web Store or Edge Add-ons link (CRX3, developer key pinned so the store ID is kept), a copy from a Chromium profile, or an unpacked folder. Loaded with allowFileAccess: false.

  • Verdict: profile/compatibility.ts reads the manifest and returns supported, partial (with the features that may not work) or unsupported. Unsupported only when a background needs an API with events nothing stands in for (readingList, tabGroups, gcm, declarativeContent, debugger, ttsEngine). An unsupported one needs "Add anyway".

  • Results in a Windows dev build:

    Extension Verdict Background Popup Main feature
    JSON Formatter 0.10.2 supported none declared options page only yes
    Dark Reader 4.9.135 partial starts yes, shows the current tab yes
    React DevTools 8.0.0 partial starts yes hook only; no DevTools panel in the pane
    Vimium 2.4.2 partial, names its page features starts yes no: content scripts lack chrome.storage.sync
    Bitwarden 2026.10.2 partial starts yes, sign-in screen vault and autofill not tried
    1Password 8.12.44.33 partial starts opens its welcome page as a tab desktop app link refused (native messaging)
    uBlock Origin Lite 2026.1110 partial starts yes blocks (ERR_BLOCKED_BY_CLIENT)
    uBlock Origin 2026.1108 (MV2) supported starts yes, blocked count blocks; badge in the menu
  • Popups: extension-api/popup.ts anchors a view under the extensions button, sized from preferred-size-changed within 25x25 to 800x600. It closes on blur, Escape, and window blur, move or resize.

  • API modules (profile/extension-api/): a preload registered for frames and service workers defines the namespaces only in chrome-extension:// documents. Main answers only the extension's own pages and worker, and only for namespaces its manifest has permission for.

    Status APIs
    Real tabs, windows (remove refuses), action / browserAction / pageAction, popup surface, webNavigation, contextMenus (in the tab's right-click menu), cookies (host permissions only), notifications, storage.sync (this device only, pages and workers)
    Partial permissions (answers from the manifest; request grants nothing optional), commands (getAll only, no shortcuts)
    No-op sidePanel, identity, history, bookmarks, sessions, downloads, search, fontSettings, privacy.* (stubs.ts)
    Electron's own storage.local/session, runtime, scripting, alarms, idle, offscreen, declarativeNetRequest, MV2 webRequest
    Unsupported nativeMessaging, DevTools panels, storage.sync in content scripts, MV3 worker webRequest listeners, userScripts, tabGroups
  • file: guard and request blocking: any session.webRequest listener makes Electron skip the extensions' request proxy, so blockers saw nothing. protocol.handle("file") does not work either: with an extension listening it never runs and outside files load. profile/file-guard.ts and file-guard-machine.ts now register the listener only while a file: document can load something:

    // on:   did-start-navigation to a file: URL, in any frame of any web contents in the partition
    // stay: while that page loads, and while any frame lives in a process that committed a file: document
    // off:  only after did-stop-loading shows no such frame, or the page is destroyed; unknown states stay on
    • It fails closed: a file: document's loaders are created after its navigation starts, and the listener stays until no frame can use them.
    • Cost: while a file: page is open, extensions do not block requests on other pages.

Agent browser tools

Server side in packages/plugin-browser/src/{rpc,tools,files,machine,skill}.ts. No wire or protocol change: an older desktop works with this server.

Call shape Calls in the data Before After
wait {tabID, text, timeout} 1,449 condition: Missing key Text wait, timeout capped at 30 s
wait {tabID, text, state: "visible"} 344 same Text wait (hidden gives textGone)
wait {tabID, selector, timeout} 24 same Polls until a match is visible
wait {tabID, timeout} 21 same Pause, capped at 30 s
wait {tabID, time} / {ms} 36 same Pause (time up to 60 counts as seconds)
wait {condition: "text"} that timed out 17 Throws met: false with observed
evaluate {tabID, expression} / {code} 141 script: Missing key Runs
evaluate with top-level return / await 8+ Page SyntaxError Runs as an async function body
find {tabID, query} 17 text: Missing key query: Not a parameter of browser.find. Valid parameters: tabID, frameID, text.
preview of a missing path unknown Success browser.preview found no file at <absolute path>
preview with no desktop 37 "Repeating … will not help" Names the absolute path to give the user
await tools.browser.wait({ tabID, text: "Saved", timeout: 5000 })
// before: Invalid arguments: - condition: Missing key
// after:  { ...tab, met: false, waited: "text", elapsedMs, observed: 'Text "Saved" did not appear within 5000 ms.' }
  • Unknown parameters: each input is StructWithRest(Struct(fields), [Record(not a field, always fails)]). The JSON Schema drops additionalProperties: false, so core's input repair no longer deletes the guessed key before validation.

  • Descriptions on optional parameters now reach the model's JSON Schema (they were on the type side only).

  • Code Mode: packages/core/src/codemode/tool.ts stores a failed inner call's message as toolCalls[].error (up to 2,000 characters), also when the script catches it.

  • Hidden tabs (gui-plugins/src/browser/chromium.ts, capture.ts, page-machine.ts):

    • did-navigate reapplies setBackgroundThrottling(false), so a renderer that starts hidden still paints.
    • Viewport and element screenshots use capturePage(rect, { stayHidden: true }) after any pending navigation and a fresh frame, under one 2.5 s deadline.
    • click, hover and drag wait for a double rAF instead of a forced capturePage.
    • fullPage keeps the CDP path and fails with a clear message when the tab is not on screen.
    • On macOS, undrawable() fails a screenshot at once when the window is minimized or the app is hidden.
    State Windows before Windows after macOS after
    Pane open, tab in front ok / ok ok / ok ok / ok
    Another tab in front refused / UnknownVizError ok / ok ok / ok
    Pane closed refused / UnknownVizError ok / ok ok / ok
    Tab opened with focus: false refused / UnknownVizError ok / ok ok / ok
    Another session in front refused / UnknownVizError ok / ok ok / ok
    Window covered by another window refused / UnknownVizError ok / ok ok / ok
    Window on another Space n/a n/a ok / ok
    Window minimized refused / UnknownVizError ok / ok "minimized" error in 1-3 ms / ok
    App hidden (Cmd+H) n/a n/a "hidden" error in 1-3 ms / ok

    Cells are screenshot / click. Hover matches click after the change. Captures were checked as current after a click, a timer and a redirect; macOS captures are at 2x.

Permissions

The attach grant is a session rule (browser:* allow) that core evaluates after every agent rule. It still exists; the plugin now decides from the rules the user configured.

// packages/plugin-browser/src/tools.ts, execute
yield* connection.refuse(tool, input.type)     // configured deny only; before any file, tab or connection check
// ...tab and connection lookup...
yield* connection.authorize(tool, action.type) // permission.assert({ resources: [operation], save: [operation] })
  • Deny and ask per operation: a permission.evaluate hook (connection.ts) selects the rule for the operation from config, the agent definition and the session's own non-grant rules, with core's last-match semantics (selectBrowserRule in machine.ts). Deny gives [browser.denied]; ask gives the normal prompt. A read failure fails closed.
  • tool.use policies on browser now run, because every call goes through permission.assert.
  • "Allow always": calls pass save: [operation]. packages/core/src/permission.ts lets a saved approval answer an ask that a hook raised; denies are evaluated first and never overridden.
  • Subagents: a session prompt hook removes the inherited grant from a child with no attachment of its own, before its first prompt is admitted.
  • Stale grants: each grant is recorded in plugin storage with host, pid and location. At startup, before the RPC registers, reconcileStaleGrants revokes grants whose process is gone on this host. Another host's or a live process's grants stay. Builds on fix(plugin-browser): revoke stale browser grants on startup #61639 by @feiiiiii5.
  • Extension pages: BrowserPage.execute requires origin: "agent" | "user". For agent calls, agentRefusal (policy.ts) refuses navigation to extension URLs and everything except navigate, back, forward and stop on an extension document. Extension frames are dropped from frames, snapshots and contexts, and a call that names an extension frame's frameID is refused. Screenshots still show their pixels.

Cookie encryption

// packages/desktop/electron-builder.config.ts, getBase()
electronFuses: { enableCookieEncryption: true, resetAdHocDarwinSignature: true },
  • Keys: Keychain (macOS), DPAPI (Windows), libsecret or kwallet (Linux). Dev runs use the stock Electron binary and stay unencrypted.
  • resetAdHocDarwinSignature re-signs unsigned macOS builds after the flip, or Apple Silicon refuses to launch them. Signed builds are signed after the fuses anyway.
  • One-way: turning the fuse off, or a downgrade, cannot read encrypted cookies; users are signed out of sites. The app does not crash.
  • Existing cookies are encrypted when next written. Imported cookies are encrypted from their first write.
  • Linux without a keyring: Chromium falls back to a fixed key, which only obfuscates. A changed keyring signs users out.

Notes for review

  • Verified: Windows dev build (profile, import, extensions and popups, ad blocking, the file: guard with extensions installed, the tool-call shapes, hidden tabs, deny / ask / Allow always, and the extension-page refusal, all over the real tool path). macOS dev and packed beta build on Apple Silicon (hidden tabs, links, import offer, Safari import, JSON Formatter, verdicts, fuse Enabled, "cannot save sign-ins"). Linux not tested.
  • Not verified:
    • The Chromium import "Allow" path on macOS (only Deny was clicked).
    • Encrypted cookie rows (v10) on macOS, and in the persist: partition on any OS.
    • Bitwarden and 1Password with a signed-in account; chrome.cookies with a real extension; context menus with a real right-click.
    • Extensions, popups and the API modules on macOS and Linux.
  • Behaviour changes users will notice:
    • A configured ask (including "*": "ask") now prompts for browser calls while a desktop is attached.
    • wait returns met: false instead of throwing; scripts that used the throw as "not found" now continue.
    • Unknown tool parameters are rejected with the valid list.
    • Every browser tool's model-facing schema changes: one prompt-cache miss.
    • Web links open in the pane by default. Settings › Browser › "Open links in the browser pane" turns this off.
  • Core change: a saved "always" approval now answers a plugin hook's ask for every action, not only browser.
  • Still listed: a denied agent still sees the browser tools and skill while attached; every call is refused before anything reaches the desktop. Forks copy the grant. Grants written before this change have no ledger record and are not swept.
  • Hidden agent tabs keep rendering at the display rate. CPU and GPU cost of many hidden tabs is not measured.
  • Keychain prompt names "security": "Always Allow" would let any process read that browser's key, so the copy warns against it. The proper fix is an N-API addon calling SecItemCopyMatching, with signing and the RunAsNode fuse off.
  • Remote servers receive the profile's cookies, and their pages can write cookies back into the profile.
  • refactor(browser): serve the in-app browser's tools from the desktop over client MCP #59477 (browser tools over client MCP, draft) deletes packages/plugin-browser. It would move the wait, evaluate and schema work and drop the grant, subagent and ledger fixes.
  • No feature flag: GUI plugins cannot read server flags, the fuse is a one-way build setting, and the server changes are fixes.

Follow-ups


Added by the opencode bot (AI-generated).

Fixes #53580

Related: #52419

Public API: a saved "always" approval now overrides a permission.evaluate hook's ask for every action; browser tools honour configured browser rules and reject unknown parameters; codemode toolCalls gain optional error

@Hona
Hona requested a review from Brendonovich as a code owner November 10, 2026 01:41
Copilot AI balanced review requested due to automatic review settings November 10, 2026 01:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks sound overall. The local-server path (sameMachine uses the persistent partition directly, with per-page file: grants by webContentsId) and the link routing (plain clicks inside [data-pane-row]; the file handler now skips http(s)) fit the existing host/plugin boundary. I could not run it: it all runs in the Electron desktop app, which isn't available here. bun test src/browser passes (205) and tsc --noEmit is clean for gui-plugins and app. The packages' typecheck script fails here with Script not found "check", so I ran tsc directly.

Public API: the plugin SDK, server HTTP API, config and CLI are unchanged. Additions are internal to the built-in browser plugin: a preferences store (links, default on), a BrowserProfile Ipc, a persist:opencode-browser partition and a browser-extensions folder under the app's user data.

Main concern: mirroring the whole profile's cookies into remote servers' partitions (inline comment). Before/after screenshots or a short video of the Settings › Browser page would help, since I could not render it.

Fixes #60313
Fixes #53580
Fixes #55730
Related: #52419, #62989


if (!shared) {
handle.network = yield* createBrowserNetwork({ rpc, attachment, location: options.location, partition })
// Sign-ins carry over both ways, so a page of a remote server is signed in where the profile is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This copies every cookie in the profile into each remote server's partition, including sign-ins imported from the user's personal Chrome, Safari or Firefox, and syncs changes both ways. Pages there load through that server's tunnel, and the agent on it can drive and browser.evaluate those pages. So any remote server the user attaches to gets the user's full set of personal sign-ins, not just the ones made for the work. Cookies for plain http: sites also cross the tunnel unencrypted. Local servers already had this level of access, but a remote one may belong to someone else. Could remote partitions stay unlinked by default, or ask once per server before mirroring?

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits look sound. I could not run them: they change only the desktop pane and settings, and the desktop app can't run here. bun test src/browser passes (206) and tsc --noEmit is clean for gui-plugins.

Public API is still unchanged. The internal additions are a welcomed field in the browser plugin's preferences store and an optional icon on the BrowserProfile Ipc's sources.

The earlier point about mirroring all profile cookies into remote servers' partitions is not addressed yet; that thread stays open. Nothing new to raise.

@esafak

esafak commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

In addition to having a default, shared browser profile, it would be good for the user to be able to specify the profile in order to work concurrently on things that may compete for browser state.

@Hona

Hona commented Nov 10, 2026

Copy link
Copy Markdown
Member Author

hmm cool idea @esafak.. I'll explore that

Hona and others added 15 commits November 13, 2026 08:03
…tach grant

Attaching a desktop browser writes browser:* and skill:browser allows into the
session's permissions. Session rules are evaluated after agent rules, so the
grant overrode a browser deny from config, an agent definition (for example a
"*" deny) or the session itself. Browser tools never called the permission
check either, so tool.use policies did not apply.

The plugin now records each agent's rules before it appends its own default
deny. A browser call is refused when those rules, plus the session's rules
without the grant, deny the browser, decided the way core's tool filter decides.
The call then runs the permission check with the operation as its resource, so
permission.evaluate hooks such as tool.use policies apply. Sessions without a
deny behave as before: the grant allows the check, so nothing prompts.

Refs #54044, #53294, #56869
A key the schema did not declare was dropped silently: input repair deletes undeclared keys of a closed object, and Effect structs ignore excess keys. A call such as evaluate({ tabID, expression }) then failed with 'script: Missing key' and no hint that expression was the problem. Every browser tool now fails with 'Not a parameter of browser.X. Valid parameters: ...'.
…el return and await

Models passed expression (and code) where evaluate required script; every such call failed. Scripts with a top-level return or await failed with a SyntaxError in the page. The server now folds expression and code into script, and sends a script that does not compile as a classic script but does as an async function body as (async () => { ... })(). Scripts that compile today reach the page unchanged.
…alse

browser.wait without condition was 2,138 of 3,099 browser errors across four devices. Models write Playwright-style keys: text with timeout, state, selector, url, time, ms. wait now reads the condition from whichever key is present (none means load), accepts timeout for timeoutMs, shortens waits above 30 s instead of rejecting them, pauses for delayMs, time or ms, and polls for a selector, ref or URL from the server with actions every desktop already supports. A condition that does not hold in time returns met: false with what the page showed instead of throwing. Waits also no longer fail when the document changed since the server last saw the tab.
…rent

A child session copies its parent's permissions, so a subagent spawned from an
attached session got the browser:* and skill:browser allows but no attachment.
It listed the browser tools and the Browser skill, every call failed with
[browser.disconnected], and the copied allow outlived the parent's attachment.

The plugin now revokes the grant from a child session while the child's prompt
is admitted (session prompt hook), before any step of the child can list the
tools, so there is no race with its first step. It holds the attachment lock
while it checks and writes, and skips a child that has an attachment of its own.
A subagent whose agent denies the browser stays denied even when the desktop
attaches to it, through the check from the previous commit.

Refs #61238
…illed

The attach grant is a stored session rule, but its revoke runs only from the
plugin's finalizer. After SIGKILL, a crash or a stop timeout, the session kept
browser:* and skill:browser allows with no attachment behind them, so the next
server listed the browser tools and every call failed with
[browser.disconnected].

Each grant is now recorded in plugin storage with the server process that wrote
it (host and process ID) and its location, before the grant is written; a
successful revoke removes the record. When the plugin starts for a location, and
before it registers the browser RPC so no attach can race it, it revokes the
grants recorded for that location by a process that no longer runs.

Several server processes can share one database (a desktop's server and a
manual `opencode serve` both use opencode.db), so a sweep of every session in
the location could revoke another server's live grant. The sweep skips records
of a running process and of another host, which cannot be checked. Grants
written before this change have no record and are left alone; the next attach
and detach of their session removes them.

The startup sweep before RPC registration, its location scoping and ignoring
per-session failures come from #61639.

Fixes #61609

Co-authored-by: Chen Yufeiyang <fei76161@gmail.com>
- A session with no desktop no longer says 'Repeating browser actions while disconnected will not help' (#55795): it says the browser runs in the desktop app, to tell the user, and to continue without it until they open the session there.

- A closed or foreign tabID lists the session's open tabs (at most ten), so the next call can use one.

- A local path or file: URL passed to tabs.open or navigate is named and answered with browser.preview({ path }) or serving it over HTTP; other invalid URLs are named too.

- The reconnect advice no longer asks the model to reconnect the desktop, which it cannot do.
…no desktop is connected

preview acknowledged dispatch before the pane opened anything, so a path that existed nowhere returned success (#61625). The server now resolves the path the way the Review pane does (workspace-relative or absolute, file:// URLs, :line and #L suffixes, and the one workspace file whose path ends with it) and fails with the resolved path when no file is there or it is a folder. Without a desktop app, preview now says the file was not shown and gives its absolute path for the model to pass to the user, instead of the generic disconnected advice (28 of the 38 disconnected errors on this machine were previews).
The skill and tool descriptions now show evaluate with return and await, wait with its implied conditions, met: false and delayMs, preview as the way to show a local HTML file, and what to do on disconnected and tab_unavailable errors. Optional parameters such as tabs.open focus, frameID and limit had descriptions the model never saw: optional() does not carry annotations to the encoded JSON Schema, so they now sit on the inner schema.
metadata.toolCalls kept only { tool, status, input }. When a program caught an inner call's failure, its message was lost: 30% of browser errors in the recorded sessions have no message, and analysis had to guess the cause from the input. Each failed row now carries error, bounded to 2,000 characters. Interrupted calls stay without one.
A page whose view starts hidden, and the renderer a cross-site navigation starts in a hidden tab, never renders: the backgroundThrottling preference does not wake it. Re-apply it on every commit so hidden tabs keep rendering frames.

Viewport and element screenshots now copy the tab's last rendered frame (capturePage with stayHidden), which works while the tab is hidden, instead of refusing unless the Review pane shows it. They wait for a pending main-frame navigation and a fresh frame first, so a capture never shows the page a redirect left. Full-page captures still need the tab on screen and say so.

Click and hover no longer force a capturePage after scrolling, which threw UnknownVizError on hidden tabs; they wait for a rendered frame instead. The capture error no longer advises browser.tabs.focus, which did not help.
# Conflicts:
#	packages/gui-plugins/src/browser/model.ts
Hona added 16 commits November 13, 2026 09:59
A minimized window or a hidden app on macOS draws no frames, so capturePage never settled and the tool timed out after 60 s with advice to check the desktop connection. Refuse at once in those states, bound every capture attempt by one 2.5 s deadline, and say the window must be restored. Windows keeps capturing minimized windows.
app.getFileIcon answers by file type on macOS, so every browser showed the generic application icon (Safari a document icon). Draw the bundle's Quick Look thumbnail at its resolved path instead; a failure still falls back to the glyph.
Importing from a Chromium browser on macOS reads its Safe Storage key through /usr/bin/security, so the system prompt names 'security', and its Always Allow would let any process read that key without asking. While the prompt is open, the sign-in offer and Settings now say what macOS asks and why, and to choose Allow, not Always Allow; a denied prompt says the same. Naming OpenCode in the prompt needs an in-process Keychain read, which needs a native module.
With cookie encryption on, Chromium writes no cookie to disk while the system key store refuses its key (a denied Keychain prompt, a locked keyring), so imported sign-ins vanished at the next start without a word. The profile now writes one short-lived cookie at start and looks for it in its database. When it is missing, the profile refuses imports, and Settings and the sign-in offer say that sign-ins cannot be saved and why.
Show calls its children once, and the count was a plain string built there, so a new summary never reached the page until Settings opened again. Render the count as an expression that tracks the summary.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could not run the new commits: they all run in the desktop app, which can't run here. bun test src/browser passes (261) and tsc --noEmit is clean for gui-plugins. plugin-browser still has the one preview-path failure on Linux (site\\index.html keeps its backslash in files.ts).

The earlier point about mirroring all profile cookies into remote servers' partitions is not addressed yet; that thread stays open.

New: these commits add about 2,500 lines that rebuild parts of Chrome's extension APIs in the desktop app's main process: chrome.tabs, windows, cookies, contextMenus, webNavigation, notifications, storage.sync, permissions and action/popups, plus a preload script in every frame of the profile. Caller identity comes from the frame's address or the worker's scope, and chrome.cookies checks host permissions, so I found no blocker. Still, this is a large, security-sensitive surface that stands on its own, apart from the profile, link routing and import. With the pull request now at 85 files, a separate pull request for it would be much easier to review and revert.

Public API: unchanged by these commits.

@Hona Hona changed the title feat(desktop): one persistent browser profile with imported sign-ins, extensions and in-pane links feat(desktop): persistent browser profile, extensions and agent tool fixes Nov 13, 2026

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits look sound. I could not run them: they change only desktop browser behaviour, and the desktop app can't run here. bun test src/browser passes (264) and tsc --noEmit is clean for gui-plugins. plugin-browser still has the one preview-path failure on Linux (site\\index.html keeps its backslash in files.ts).

The earlier point about mirroring all profile cookies into remote servers' partitions is not addressed yet; that thread stays open.

Public API: unchanged by these commits. Nothing new to raise.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits look sound. The preview-path failure on Linux is fixed: plugin-browser tests now all pass (121), and tsc --noEmit is clean for plugin-browser and gui-plugins. The compatibility-table change is a refactor with no change in behaviour.

The earlier point about mirroring all profile cookies into remote servers' partitions is not addressed yet; that thread stays open.

Public API: unchanged by these commits. Nothing new to raise.

This branch has not been deployed

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