feat: tab isolation for multi-agent browser access

Add per-tab ownership tracking to BrowserManager. Scoped agents
must create their own tab via newtab before writing. Unowned tabs
(pre-existing, user-opened) are root-only for writes. Read access
always allowed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-04 23:18:23 -07:00
parent bc3ca4b786
commit 8fa3d7b06d
+37 -1
View File
@@ -46,6 +46,10 @@ export class BrowserManager {
/** Server port — set after server starts, used by cookie-import-browser command */
public serverPort: number = 0;
// ─── Tab Ownership (multi-agent isolation) ──────────────
// Maps tabId → clientId. Unowned tabs (not in this map) are root-only for writes.
private tabOwnership: Map<number, string> = new Map();
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
private refMap: Map<string, RefEntry> = new Map();
@@ -506,7 +510,7 @@ export class BrowserManager {
}
// ─── Tab Management ────────────────────────────────────────
async newTab(url?: string): Promise<number> {
async newTab(url?: string, clientId?: string): Promise<number> {
if (!this.context) throw new Error('Browser not launched');
// Validate URL before allocating page to avoid zombie tabs on rejection
@@ -519,6 +523,11 @@ export class BrowserManager {
this.pages.set(id, page);
this.activeTabId = id;
// Record tab ownership for multi-agent isolation
if (clientId) {
this.tabOwnership.set(id, clientId);
}
// Wire up console/network/dialog capture
this.wirePageEvents(page);
@@ -536,6 +545,7 @@ export class BrowserManager {
await page.close();
this.pages.delete(tabId);
this.tabOwnership.delete(tabId);
// Switch to another tab if we closed the active one
if (tabId === this.activeTabId) {
@@ -611,6 +621,32 @@ export class BrowserManager {
return this.pages.size;
}
// ─── Tab Ownership (multi-agent isolation) ──────────────
/** Get the owner of a tab, or null if unowned (root-only for writes). */
getTabOwner(tabId: number): string | null {
return this.tabOwnership.get(tabId) || null;
}
/**
* Check if a client can access a tab.
* Read access is always allowed. Write access requires ownership.
* Unowned tabs are root-only for writes.
*/
checkTabAccess(tabId: number, clientId: string, isWrite: boolean): boolean {
if (clientId === 'root') return true;
if (!isWrite) return true;
const owner = this.tabOwnership.get(tabId);
if (!owner) return false; // unowned = root-only for writes
return owner === clientId;
}
/** Transfer tab ownership to a different client. */
transferTab(tabId: number, toClientId: string): void {
if (!this.pages.has(tabId)) throw new Error(`Tab ${tabId} not found`);
this.tabOwnership.set(tabId, toClientId);
}
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
for (const [id, page] of this.pages) {