Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5dc744b
Harden browser controller ingress
Bzcasper Sep 25, 2026
8260195
Make daemon deployment reproducible and restart-safe
Bzcasper Sep 25, 2026
22690fc
test: add real Chrome reconnect smoke test
Bzcasper Sep 25, 2026
829e72f
fix: capture page console from main world
Bzcasper Sep 25, 2026
5e4d829
fix: preserve systemd daemon ownership in smoke test
Bzcasper Sep 25, 2026
8d28311
fix: separate managed daemon lifecycle from bridge
Bzcasper Sep 25, 2026
8a57f17
fix(errors): name the selector when it matches nothing instead of "El…
Sep 29, 2026
a268db0
fix(targeting): one shared resolver for every tool, find/click_text r…
Sep 30, 2026
b50b4ab
fix(hang): a frozen page fails in seconds and navigate/reload recover it
Sep 30, 2026
454ce57
feat(input): coordinate actions, triple click, key sequences, zoomed …
Sep 30, 2026
e5b62cb
feat(window): browser_resize_window, background tabs, window focus, f…
Sep 30, 2026
06c0980
fix(console): capture the page's own console; filters for console and…
Sep 30, 2026
e20f258
feat(upload): upload images without a local file, to inputs or drop z…
Sep 30, 2026
f842293
feat(gif): record the agent's actions in a tab as an animated GIF
Sep 30, 2026
3b91458
feat(multi-browser): several browsers at once, each session picks one
Sep 30, 2026
e58787f
feat(shortcuts): save and replay named action sequences with {{variab…
Sep 30, 2026
a9e27d7
chore(release): 2.4.0 — GIF export in parts, README for the new capab…
Sep 30, 2026
aa79782
test(gif): multi-part export is reassembled and written, bytes never …
Sep 30, 2026
f03d62f
refactor: browser_find in its own module (inspection.js back under 60…
Sep 30, 2026
6f56fec
feat: request interception controls with tests
Bzcasper Sep 30, 2026
d1334f6
merge: origin/main (observe-act, batch/CIC parity v2.3.0) into main
Bzcasper Sep 30, 2026
b5a1799
chore(deploy): make the daemon systemd unit portable; drop host-speci…
Bzcasper Sep 30, 2026
91a7b15
fix(review): lock-safe frozen-tab recovery, DPR-correct screenshot ma…
Sep 30, 2026
f88520b
chore(lint): no useless assignment in isVisible
Sep 30, 2026
5217539
Merge pull request #20 from compnew2006/feat/cic-parity-2
compnew2006 Sep 30, 2026
89b9e93
fix(review): scoped and truthful interception, verified daemon identity
Sep 30, 2026
3e8f60e
Merge v2.4.0 (feat/cic-parity) into PR #19
Sep 30, 2026
e5e9c40
fix(ci): Babel 8 ESLint parser so npm ci and lint pass
Sep 30, 2026
c3d1ee3
Merge remote-tracking branch 'origin/feat/cic-parity' into pr19
Sep 30, 2026
238c121
fix(lint): attach cause when the daemon lock is held
Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@
# Port the daemon's WebSocket + HTTP server binds on. The extension connects
# here. Override only if something else owns 7225 on your machine.
# WS_PORT=7225

# Interface to bind. 127.0.0.1 keeps the daemon loopback-only (recommended);
# do NOT set 0.0.0.0 unless you understand the exposure.
# WS_HOST=127.0.0.1
# The daemon always binds its HTTP/WebSocket control plane to 127.0.0.1.
# There is intentionally no WS_HOST override: remote callers (including n8n)
# need a separately authenticated transport, not a public browser socket.

# --- Daemon state --------------------------------------------------------

Expand Down
137 changes: 96 additions & 41 deletions README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ Report vulnerabilities through:

## Security Model

- **Local-only communication**: WebSocket between extension and server runs on localhost only.
- **Local-only communication**: the daemon's HTTP/WebSocket control plane is hard-bound to IPv4 loopback (`127.0.0.1`), not configurable through `WS_HOST`. WebSocket traffic between the extension and daemon remains local. MCP clients use stdio and then an authenticated local IPC socket; they do not get a TCP listener.
- **Remote callers and n8n**: Browser Controller has no supported remote or n8n ingress protocol. Do not publish port 7225 or change the listener to a wildcard address. A remote integration should run on the same host and reach the MCP client through its approved local process boundary, or be given a separately authenticated transport; browser-control credentials must not be reused as a network API key.
- **Origin validation (exact-match on a pinned extension ID)**: the daemon pins the extension's `chrome-extension://<id>` Origin on first contact, then rejects every later request whose Origin is not an exact match. This applies to BOTH the WebSocket upgrade and the HTTP endpoints (`/pair`, `/status`, `/kill`) through one shared gate — a web page and a co-installed hostile extension (which carries its own Origin and cannot forge ours) are both rejected. The browser sets the `Origin` header; it cannot be forged from page JS.
- **Token auth on the control plane**: the WebSocket upgrade additionally requires the daemon's auth token (sent out-of-band via `Sec-WebSocket-Protocol` subprotocol, with a `?token=` legacy fallback). HTTP endpoints cannot require that token because `/pair` is how it is obtained; they instead require the browser-asserted pinned Origin plus the separate `X-BC-Enrollment` secret.
- **Versioned capability handshake**: transport authentication is necessary but not sufficient to mark the extension ready. The daemon and extension advertise a protocol major, application version, and capabilities before tool traffic is routed. An explicitly incompatible protocol major is rejected; application-version differences alone are diagnostic because compatible patch releases can share the same wire contract. Peers that omit the protocol field are identified as legacy during the migration window rather than being silently mistaken for a current peer.
Expand Down
17 changes: 17 additions & 0 deletions deploy/systemd/browser-controller-daemon.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Example systemd user unit. Adjust the checkout path (and node location if
# `node` is not on the user manager's PATH) to match your installation.
[Unit]
Description=Browser Controller shared daemon
After=graphical-session.target

[Service]
Type=simple
WorkingDirectory=%h/browser-controller
Environment=HOME=%h
ExecStart=/usr/bin/env node %h/browser-controller/mcp-server/dist/daemon.js
Restart=always
RestartSec=2
NoNewPrivileges=true

[Install]
WantedBy=default.target
3 changes: 2 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export default [
parserOptions: {
requireConfigFile: false,
babelOptions: {
presets: [['@babel/preset-typescript', { allowDeclareFields: true }]],
// Babel 8 always allows `declare` fields (the option was removed).
presets: ['@babel/preset-typescript'],
},
},
},
Expand Down
36 changes: 36 additions & 0 deletions extension/console-main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* MAIN-world console capture. content.js runs in the extension's isolated
* world, where patching `console` only sees the extension's own calls — the
* page's console.log/warn/info/debug/error never reached browser_console.
* This script patches the PAGE's console and hands each entry to content.js
* as a JSON string on a private DOM event (object details don't cross
* worlds). Uncaught errors / rejections are still captured by content.js.
*/
(function () {
'use strict';
if (window.__bcConsoleMain) return;
Object.defineProperty(window, '__bcConsoleMain', { value: true });
const EVENT = '__bc_console_entry';
const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
const fmt = (a) => {
if (typeof a === 'string') return a;
if (a instanceof Error) return `${a.name}: ${a.message}`;
if (a && typeof a === 'object') {
try { return JSON.stringify(a); } catch { return Object.prototype.toString.call(a); }
}
return String(a);
};
for (const level of LEVELS) {
const orig = console[level];
if (typeof orig !== 'function') continue;
const patched = function (...args) {
try {
let text = args.map(fmt).join(' ');
if (text.length > 2000) text = text.slice(0, 2000) + '…[truncated]';
document.dispatchEvent(new CustomEvent(EVENT, { detail: JSON.stringify({ level, text }) }));
} catch { /* never break the page's logging */ }
return orig.apply(this, args);
};
try { console[level] = patched; } catch { /* frozen console */ }
}
})();
9 changes: 9 additions & 0 deletions extension/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@
console.info = (...a) => capture('info', ...a);
console.debug = (...a) => capture('debug', ...a);

// Page console entries from console-main.js (MAIN world), JSON on a DOM event.
document.addEventListener('__bc_console_entry', (e) => {
let entry;
try { entry = JSON.parse(e.detail); } catch { return; }
if (!entry || typeof entry.text !== 'string') return;
if (entry.text.indexOf('ResizeObserver loop') !== -1) return;
try { chrome.runtime.sendMessage({ type: 'console', level: String(entry.level || 'log'), text: entry.text.slice(0, 2100) }); } catch {}
});

window.addEventListener('error', (e) => {
// Silence the well-known ResizeObserver loop warning: it's a benign browser
// notice (element resized during its own observation callback), not a real
Expand Down
72 changes: 51 additions & 21 deletions extension/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@ import {
persistSessionState,
dropTabState,
dropDocumentState,
} from './lib/state.js';
import { showLockShield, hideLockShield } from './lib/overlay.js';
import { lockTabUi, releaseTabUi } from './lib/lock-ops.js';
} from "./lib/state.js";
import { showLockShield, hideLockShield } from "./lib/overlay.js";
import { enrichCapture } from "./handlers/intercept.js";
import { lockTabUi, releaseTabUi } from "./lib/lock-ops.js";
import {
getOpenTabs,
buildStatusPayload,
broadcastStatus,
applyPort,
applyToken,
applyEnrollment,
} from './lib/connection.js';
} from "./lib/connection.js";

export function registerEventListeners() {
chrome.runtime.onMessage.addListener((msg, sender, respond) => {
Expand All @@ -32,12 +33,17 @@ export function registerEventListeners() {
// before that, Chrome logs "message channel closed before a response was
// received". Every branch below responds synchronously (or is fire-and-forget),
// so we return false (or nothing) — Chrome handles it without the warning.
if (msg.type === 'console' && sender.tab?.id != null) {
if (msg.type === "console" && sender.tab?.id != null) {
const buf = getTabBuffer(consoleByTab, sender.tab.id);
pushCapped(buf, { level: msg.level, text: msg.text, timestamp: Date.now(), url: sender.tab.url });
pushCapped(buf, {
level: msg.level,
text: msg.text,
timestamp: Date.now(),
url: sender.tab.url,
});
return false; // fire-and-forget; no response expected
}
if (msg.type === 'getStatus') {
if (msg.type === "getStatus") {
// Async: fetch tabs before responding so the popup gets a full snapshot
// (connection + locks + open tabs) in one message. Returning true signals
// Chrome we'll call respond() asynchronously.
Expand All @@ -46,15 +52,15 @@ export function registerEventListeners() {
});
return true; // async response
}
if (msg.type === 'setPort') {
if (msg.type === "setPort") {
respond(applyPort(msg.port));
return false;
}
if (msg.type === 'setToken') {
if (msg.type === "setToken") {
respond(applyToken(msg.token));
return false;
}
if (msg.type === 'setEnrollment') {
if (msg.type === "setEnrollment") {
// The popup owns the user-facing entry of the enrollment secret. Persist,
// re-pair, reconnect — all inside connection.js. The onMessage listener is
// NOT async, so we .then() and return true (Chrome keeps the respond()
Expand All @@ -64,38 +70,40 @@ export function registerEventListeners() {
});
return true; // async response — respond() fires from the .then()
}
if (msg.type === 'unlockAll') {
if (msg.type === "unlockAll") {
// Snapshot BEFORE unlockAll() — unlockAll clears the map, so reading after
// would lose the list of tabs whose shields need removing.
const prev = tabLocks.snapshot();
tabLocks.unlockAll();
persistSessionState();
for (const { tabId } of prev) hideLockShield(tabId);
broadcastStatus('All tab locks cleared');
broadcastStatus("All tab locks cleared");
respond({ success: true });
return false;
}
if (msg.type === 'lockTab') {
if (msg.type === "lockTab") {
const owner = msg.sessionId;
if (msg.tabId == null || !owner) {
respond({ success: false, error: 'tabId and sessionId required' });
respond({ success: false, error: "tabId and sessionId required" });
return false;
}
// lockTabUi is async (it awaits the shield injection) — keep Chrome's
// respond() channel open for the async reply.
lockTabUi(msg.tabId, owner, `Tab ${msg.tabId} pinned to ${owner}`)
.then((shielded) => respond({ success: true, shielded }))
.catch((err) => respond({ success: false, error: err?.message || String(err) }));
.catch((err) =>
respond({ success: false, error: err?.message || String(err) }),
);
return true;
}
if (msg.type === 'unlockTab') {
if (msg.type === "unlockTab") {
// { tabId } — release one tab's lock (vs unlockAll which clears all).
if (msg.tabId == null) {
respond({ success: false, error: 'tabId required' });
respond({ success: false, error: "tabId required" });
return false;
}
const was = releaseTabUi(msg.tabId);
broadcastStatus(`Tab ${msg.tabId} unpinned (was ${was || '-'})`);
broadcastStatus(`Tab ${msg.tabId} unpinned (was ${was || "-"})`);
respond({ success: true, previousSession: was || null });
return false;
}
Expand All @@ -106,12 +114,34 @@ export function registerEventListeners() {
(details) => {
if (details.tabId == null || details.tabId < 0) return; // not a real tab
const buf = getTabBuffer(networkByTab, details.tabId);
pushCapped(buf, {
const entry = {
method: details.method,
url: details.url,
status: details.statusCode,
type: details.type,
timestamp: details.timeStamp,
};
pushCapped(buf, entry);
// Intercept ledger enrichment (best-effort; never breaks capture).
try {
enrichCapture(details.tabId, entry);
} catch {}
},
{ urls: ["<all_urls>"] },
);

// Requests that never completed (DNS failure, blocked, aborted, CORS…):
// onCompleted never fires for them, so without this they were invisible.
chrome.webRequest.onErrorOccurred.addListener(
(details) => {
if (details.tabId == null || details.tabId < 0) return;
const buf = getTabBuffer(networkByTab, details.tabId);
pushCapped(buf, {
method: details.method,
url: details.url,
error: details.error,
type: details.type,
timestamp: details.timeStamp,
});
},
{ urls: ['<all_urls>'] },
Expand All @@ -136,11 +166,11 @@ export function registerEventListeners() {
// from the short-lived per-call listener inside handleNavigate — they share no
// state and Chrome supports multiple onUpdated listeners (review NOTE 7c).
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === 'loading') {
if (changeInfo.status === "loading") {
dropDocumentState(tabId);
persistSessionState();
}
if (changeInfo.status === 'complete' && tabLocks.owner(tabId)) {
if (changeInfo.status === "complete" && tabLocks.owner(tabId)) {
showLockShield(tabId);
}
});
Expand Down
Loading
Loading