feat(browser): replace the preview proxy with a real browser panel and an agent web tool (#2883)

The preview panel worked by proxying a dev server through OpenChamber's own
origin and rewriting the HTML that came back. Anything the rewriter did not
anticipate broke, and pages that refuse to be embedded never loaded at all.
This deletes the proxy (-1604 lines and its tests) and merges the preview and
browser panels into one surface backed by a real Chromium view.

What the panel is now

- A `<webview>` in its own session partition: logins and cookies persist, hot
  reload works because nothing is rewritten, DevTools are one click away.
- Annotation: pick one element, drag a region, or draw freehand, write a note,
  and it reaches chat with a screenshot of the visible page with the marks on it.
- Toolbar: hard reload, page zoom, device sizes, a light/dark switch that
  applies to the page rather than the app, and cookie/cache clearing scoped to
  the panel alone.
- Several pages at once, each tab showing the page's own favicon, and an address
  bar that suggests pages already visited in this project.
- Dev servers are listed from what is actually listening on the machine, checked
  against what a project announced, so a server is offered no matter how it was
  started. One that is still starting is waited for instead of failing.

Remote dev servers

The desktop app binds a local port and pipes raw bytes to the OpenChamber host
over the existing authenticated connection, so the page keeps its own origin at
the root of its own host. The reachable set is exactly what discovery reports
and is re-checked per connection, so an authenticated client cannot dial
arbitrary local services on the host. Links and redirects to another loopback
port stay on the machine that served the page. A tunnel that cannot be opened is
reported; it is never replaced by the plain loopback URL, which would answer
from the user's own machine under a remote address.

Agent control

Browser actions are a separate `openchamber_web` tool: open, snapshot, click,
type, scroll, inspect computed styles, resize between mobile/tablet/desktop, and
capture a screenshot into `.openchamber/screenshots/` in the project. The
existing `openchamber` tool keeps sessions, worktrees and scheduled tasks. Each
has its own setting in the new Settings -> General -> OpenChamber Tools section,
and the plugin is not injected at all when both are off.

Capability belongs to the connected client, not to configuration: a client
declares on its event stream that it can drive a page, which only a Chromium
host does. Exactly one client performs each request — it claims the request
before acting, and the first claim wins — because deciding by whose result
arrives first would be too late for a click that already happened. No client
listening is answered immediately with an explanation rather than a timeout.

Runtime boundaries

Web tabs get a plain iframe that can display a page but not inspect one. The
VS Code extension no longer offers the surface at all, since nothing that makes
the panel worth having works there. Mobile is unaffected.

Native boundary

Camera, microphone, location and device-picker requests from panel pages are
denied — Electron grants them by default when no handler is set, and the panel
loads whatever address the user types. Page capture, appearance emulation and
storage clearing verify that their target belongs to the panel's own session
instead of trusting a web-contents id from the renderer.

Persisted state

Stored `preview` tabs migrate to `browser` (v13 -> v14). Context panel tab
limits are now per surface, so filling one surface no longer evicts another's
tabs. Address history is stored per project and per runtime.

Documentation

`preview.mdx` and `desktop-browser.mdx` rewritten across all locales, the agent
tool settings path corrected, new `DOCUMENTATION.md` for the browser-control
broker and the dev tunnel, and the `ui-api-decoupling` skill updated where it
still described the deleted proxy.
This commit is contained in:
Bohdan Triapitsyn
2026-08-13 22:44:13 +03:00
committed by GitHub
parent 50613bb170
commit a5aa32446d
151 changed files with 10431 additions and 5587 deletions
+7
View File
@@ -153,6 +153,13 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
- Tunnel lifecycle integration through the web server runtime.
- Auto-update checks, downloads, and restart/apply flow.
- The browser panel's own session (`persist:openchamber-browser`): its storage is
cleared only through the scoped clear-data command, and camera, microphone,
location, and device-picker requests from pages shown there are denied. Electron
grants permission requests by default when no handler is set, and the panel
loads whatever address the user types. Tab favicons are fetched in this
session too, so icons behind the page's own login resolve and the app's origin
never requests anything from a third-party host.
## IPC Pattern
+219 -4
View File
@@ -1132,6 +1132,76 @@ const injectRuntimeConfigIntoHtml = (html) => {
return `${initScript}${html}`;
};
/**
* The browser panel's own session, kept separate from OpenChamber's.
*
* Every page the user opens in the panel shares this partition, which is what
* lets a dev-server login persist between sessions without touching the app's
* own storage.
*/
const BROWSER_PANEL_PARTITION = 'persist:openchamber-browser';
/**
* Denies device and location access to pages shown in the browser panel.
*
* Electron grants permission requests by default when no handler is set. The
* panel loads whatever address the user types, so that default would hand a
* page the camera, the microphone, or the user's location without anything
* being asked or shown — a browser people would not tolerate.
*
* This denies rather than prompts: a prompt is the right end state, but a
* silent grant is the one outcome that must not stay. Denials are logged so a
* page that legitimately needs something is diagnosable rather than mysterious.
*/
const MAX_FAVICON_BYTES = 512 * 1024;
const FAVICON_MIME_TYPES = new Set([
'image/x-icon',
'image/vnd.microsoft.icon',
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/svg+xml',
]);
/**
* Resolves a web contents id to a browser-panel view, or refuses.
*
* These commands take an id from the renderer, and an id is guessable. Without
* this a compromised renderer could point capture or the debugger at another
* window's contents. Membership of the panel's own session is the proof: only
* views created with that partition have it, and nothing else in the app does.
*/
const resolveBrowserPanelContents = (rawId) => {
const id = Number.isFinite(rawId) ? Math.trunc(rawId) : null;
if (id === null || id < 0) throw new Error('webContentsId is required');
const target = webContents.fromId(id);
if (!target || target.isDestroyed()) throw new Error('WebContents not found');
if (target.session !== session.fromPartition(BROWSER_PANEL_PARTITION)) {
throw new Error('That view is not a browser panel page');
}
return target;
};
const hardenBrowserPanelSession = () => {
const panelSession = session.fromPartition(BROWSER_PANEL_PARTITION);
panelSession.setPermissionRequestHandler((_contents, permission, callback, details) => {
log.info('[electron] browser panel denied a permission request', {
permission,
origin: details?.requestingUrl || '',
});
callback(false);
});
// Asked before some features even request; answering here keeps a page from
// reporting a capability it would then be denied.
panelSession.setPermissionCheckHandler(() => false);
// Serial, HID and USB device pickers.
panelSession.setDevicePermissionHandler(() => false);
};
const registerPackagedUiProtocol = () => {
if (!shouldUsePackagedUi()) return;
protocol.handle(UI_PROTOCOL, async (request) => {
@@ -3649,6 +3719,28 @@ const runSpecChain = (specs, appName) => {
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
};
// The tunnel client lives in the web package (it already has a WebSocket
// client) and is loaded only if the user actually previews a remote dev server.
let devTunnelClientPromise = null;
const getDevTunnelClient = async () => {
if (!devTunnelClientPromise) {
devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js')
.then(({ createDevTunnelClient }) => createDevTunnelClient({ logger: log }))
.catch((error) => {
devTunnelClientPromise = null;
throw error;
});
}
return devTunnelClientPromise;
};
const closeAllDevTunnels = () => {
if (!devTunnelClientPromise) return;
const pending = devTunnelClientPromise;
devTunnelClientPromise = null;
pending.then((client) => client.closeAll()).catch(() => {});
};
const handleInvoke = async (browserWindow, command, args = {}) => {
switch (command) {
case 'desktop_start_window_drag':
@@ -3740,11 +3832,131 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
return { supported: true, enabled, active };
}
// Dev-server tunnels: bind a loopback port here and pipe it to a dev server
// on the remote OpenChamber host, so the browser panel loads a real origin
// instead of a rewritten page. Deliberately absent from
// COMMANDS_SAFE_FOR_REMOTE — a remote page must never open local listeners.
case 'desktop_dev_tunnel_open': {
const baseUrl = typeof args.baseUrl === 'string' ? args.baseUrl.trim() : '';
const port = Number.isFinite(args.port) ? Math.trunc(args.port) : 0;
if (!baseUrl) throw new Error('baseUrl is required');
if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required');
const headers = {};
const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {};
for (const [name, value] of Object.entries(requestHeaders)) {
if (typeof value === 'string' && value) headers[name] = value;
}
if (typeof args.clientToken === 'string' && args.clientToken) {
headers.Authorization = `Bearer ${args.clientToken}`;
}
const client = await getDevTunnelClient();
const result = await client.open({ baseUrl, port, headers });
return { localPort: result.localPort, reused: result.reused, url: `http://127.0.0.1:${result.localPort}/` };
}
case 'desktop_dev_tunnel_close': {
const baseUrl = typeof args.baseUrl === 'string' ? args.baseUrl.trim() : '';
const port = Number.isFinite(args.port) ? Math.trunc(args.port) : 0;
if (!baseUrl || !(port > 0)) return { closed: false };
const client = await getDevTunnelClient();
return { closed: client.close({ baseUrl, port }) };
}
/**
* Forces prefers-color-scheme for one previewed page.
*
* nativeTheme.themeSource is app-wide and would drag OpenChamber's own
* appearance along with it, so this goes through the page's own emulation
* instead. The debugger session has to stay attached: emulation is part of
* that session and resets the moment it detaches.
*/
case 'desktop_browser_set_color_scheme': {
const scheme = args.scheme === 'light' || args.scheme === 'dark' ? args.scheme : 'system';
const target = resolveBrowserPanelContents(args.webContentsId);
if (!target.debugger.isAttached()) {
try {
target.debugger.attach('1.3');
} catch {
// DevTools owns the only debugger session a page can have.
throw new Error('Close DevTools for this page before changing its appearance');
}
}
await target.debugger.sendCommand('Emulation.setEmulatedMedia', scheme === 'system'
? { features: [] }
: { features: [{ name: 'prefers-color-scheme', value: scheme }] });
if (scheme === 'system') {
// Nothing left to emulate; give the session back so DevTools can attach.
try { target.debugger.detach(); } catch { /* already gone */ }
}
return { scheme };
}
/**
* Fetches a page's favicon for the tab strip.
*
* Done here, in the panel's own session, rather than by the renderer: the
* icon often sits behind the same login as the page, and letting the app's
* own origin request it would both fail on those and quietly send traffic
* to third-party hosts from OpenChamber itself. The bytes come back as a
* data URL so nothing else has to fetch anything.
*/
case 'desktop_browser_fetch_favicon': {
const target = typeof args.url === 'string' ? args.url.trim() : '';
let parsed;
try {
parsed = new URL(target);
} catch {
throw new Error('A favicon URL is required');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Unsupported favicon URL');
}
const response = await electronNet.fetch(parsed.toString(), {
session: session.fromPartition(BROWSER_PANEL_PARTITION),
});
if (!response.ok) throw new Error(`Favicon request failed (${response.status})`);
const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!FAVICON_MIME_TYPES.has(mime)) throw new Error('Favicon is not an image');
const buffer = Buffer.from(await response.arrayBuffer());
// A tab icon is a few kilobytes; anything of a different order is not one,
// and is not worth holding in memory for every tab.
if (buffer.length === 0 || buffer.length > MAX_FAVICON_BYTES) {
throw new Error('Favicon is not a usable size');
}
return { dataUrl: `data:${mime};base64,${buffer.toString('base64')}` };
}
// Scoped to the browser panel's own partition, so clearing it can never
// touch OpenChamber's session or any other window's storage.
case 'desktop_browser_clear_data': {
// Exact match, not a prefix: a prefix would also accept a partition that
// merely starts with this name, which is not what the comment above
// promises and would quietly stop being true if one were ever added.
const partition = typeof args.partition === 'string' ? args.partition.trim() : '';
if (partition !== BROWSER_PANEL_PARTITION) {
throw new Error('Unsupported browser partition');
}
const storages = [];
if (args.cookies === true) storages.push('cookies');
if (args.cache === true) storages.push('localstorage', 'indexdb', 'websql', 'serviceworkers', 'cachestorage');
if (storages.length === 0) return { cleared: false };
const browserSession = session.fromPartition(partition);
await browserSession.clearStorageData({ storages });
if (args.cache === true) await browserSession.clearCache();
return { cleared: true };
}
case 'desktop_browser_capture_page': {
const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null;
if (wcId === null || wcId < 0) throw new Error('webContentsId is required');
const wc = webContents.fromId(wcId);
if (!wc || wc.isDestroyed()) throw new Error('WebContents not found');
const wc = resolveBrowserPanelContents(args.webContentsId);
const image = await wc.capturePage();
const buffer = image.toJPEG(82);
return {
@@ -5083,6 +5295,8 @@ app.on('window-all-closed', () => {
app.on('before-quit', (event) => {
state.quitRequested = true;
// Loopback listeners would otherwise outlive the window that needed them.
closeAllDevTunnels();
if (state.installingUpdate) {
return;
@@ -5156,6 +5370,7 @@ app.whenReady().then(async () => {
});
nativeTheme.themeSource = readThemeSource();
registerPackagedUiProtocol();
hardenBrowserPanelSession();
setupAutoUpdater();
if (process.platform === 'darwin') {