fix(extension): claim agent tabs explicitly

This commit is contained in:
drakezhang
2026-09-04 11:17:05 +08:00
parent a77353bce8
commit 0dc11b30ea
17 changed files with 187 additions and 263 deletions
@@ -196,9 +196,8 @@ describe("ToolDispatcher", () => {
},
});
await sessions.start("aa11");
// Register tab 7 as agent-created (pending counter + onCreated classify).
sessions.markAgentTabPending(4242);
sessions.classifyNewTab(7, 4242);
// Register the exact tab id returned by the agent's creation path.
sessions.get("aa11")?.agentCreatedTabs.add(7);
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
@@ -20,7 +20,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => 7),
};
}
@@ -197,6 +197,7 @@ describe("handleEmulate", () => {
it("targets an explicit tab_id", async () => {
const sm = await makeManager();
sm.get("aa11")?.agentCreatedTabs.add(9);
const deps = makeDeps();
const result = await handleEmulate(
sm,
@@ -276,6 +277,7 @@ describe("handleEmulate", () => {
it("keeps emulation state per tab", async () => {
const sm = await makeManager();
sm.get("aa11")?.agentCreatedTabs.add(9);
const deps = makeDeps();
await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
await handleEmulate(
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => 4),
};
}
@@ -52,10 +52,16 @@ function installHelpLifecycleChrome() {
function fakeManager(sessionId: string, agentWindowId: number, tabId: number) {
const refStore = new RefStore();
const session = {
sessionId,
agentWindowId,
refStore,
borrowedTabs: new Map(),
agentCreatedTabs: new Set([tabId]),
};
const mgr = {
get: (id: string) =>
id === sessionId ? { sessionId, agentWindowId, refStore, borrowedTabs: new Map() } : null,
findByWindowId: (wid: number) => (wid === agentWindowId ? { sessionId } : null),
get: (id: string) => (id === sessionId ? session : null),
findByWindowId: (wid: number) => (wid === agentWindowId ? session : null),
} as unknown as SessionManager;
return mgr;
}
@@ -357,6 +363,7 @@ describe("handleRequestHelp", () => {
agentWindowId: 99,
refStore,
borrowedTabs: new Map(),
agentCreatedTabs: new Set([5]),
}
: null,
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
@@ -413,6 +420,7 @@ describe("handleRequestHelp", () => {
}),
},
borrowedTabs: new Map(),
agentCreatedTabs: new Set([5]),
}
: null,
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
@@ -498,6 +506,7 @@ describe("handleRequestHelp", () => {
agentWindowId: 99,
refStore,
borrowedTabs: new Map(),
agentCreatedTabs: new Set([5]),
}
: null,
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => 4),
};
}
@@ -113,6 +113,7 @@ describe("handleClick", () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
ctx.agentCreatedTabs.add(5);
const fake = makeFakeCdp({});
const res = await handleClick(
sm,
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => 4),
};
}
@@ -28,7 +28,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => 4),
};
}
@@ -84,6 +84,7 @@ function fakeManager() {
agentWindowId: AGENT_WINDOW_ID,
refStore: { resolve: () => null, replace: () => {} },
borrowedTabs: new Map(),
agentCreatedTabs: new Set([TAB_ID]),
}
: null,
findByWindowId: (windowId: number) =>
@@ -302,7 +302,7 @@ describe("handleSessionStop window release (issue #57)", () => {
ctx.agentCreatedTabs.add(11);
ctx.agentCreatedTabs.add(12);
// home tab id from ensureActiveTab override
ctx.homeTabId = 10;
ctx.agentCreatedTabs.add(10); // home tab
// user tab 99 (created via Chrome UI, NOT in agentCreatedTabs)
const state: FakeState = {
tabs: new Map([
@@ -340,7 +340,7 @@ describe("handleSessionStop window release (issue #57)", () => {
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
ctx.homeTabId = 10;
ctx.agentCreatedTabs.add(10); // home tab
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
@@ -369,7 +369,7 @@ describe("handleSessionStop window release (issue #57)", () => {
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11); // no longer in state → remove throws
ctx.homeTabId = 10;
ctx.agentCreatedTabs.add(10); // home tab
const state: FakeState = {
tabs: new Map([[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab]]),
windowsClosed: new Set(),
@@ -399,7 +399,7 @@ describe("handleSessionStop window release (issue #57)", () => {
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
ctx.homeTabId = 10;
ctx.agentCreatedTabs.add(10); // home tab
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
@@ -427,7 +427,7 @@ describe("handleSessionStop window release (issue #57)", () => {
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
ctx.homeTabId = 10;
ctx.agentCreatedTabs.add(10); // home tab
const state: FakeState = {
tabs: new Map([[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab]]),
windowsClosed: new Set(),
@@ -473,7 +473,7 @@ describe("handleSessionStop window release (issue #57)", () => {
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11); // Step 4 remove() will throw for this tab
ctx.homeTabId = 10;
ctx.agentCreatedTabs.add(10); // home tab
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
+54 -11
View File
@@ -60,19 +60,24 @@ describe("handleTabList", () => {
expect(res.tabs.map((t) => t.scope).sort()).toEqual(["agent", "user"]);
});
it("scope=user excludes the requesting session's Agent Window", async () => {
it("scope=user includes unclaimed tabs inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const tabs = fakeChromeTabsApi([
{ windowId: 100, url: "chrome://newtab/", title: "Agent New Tab", active: true },
{ windowId: 100, url: "https://user.example/", title: "Free user tab", active: false },
{ windowId: 200, url: "https://example.com", title: "Example", active: true },
{ windowId: 200, url: "https://github.com", title: "GitHub", active: false },
]);
const res = await handleTabList(sm, { session_id: "aa11", scope: "user" }, tabs);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tabs).toHaveLength(2);
expect(res.tabs).toHaveLength(3);
expect(res.tabs.every((t) => t.scope === "user")).toBe(true);
expect(res.tabs.map((t) => t.url)).toEqual(["https://example.com", "https://github.com"]);
expect(res.tabs.map((t) => t.url)).toEqual([
"https://user.example/",
"https://example.com",
"https://github.com",
]);
});
it("scope=agent returns only the requesting session's Agent Window tabs", async () => {
@@ -221,6 +226,7 @@ describe("handleTabCreate", () => {
active: true,
});
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
expect(sm.get("aa11")?.agentCreatedTabs.has(10)).toBe(true);
});
it("forwards explicit url + index + active=false", async () => {
@@ -257,7 +263,7 @@ describe("handleTabCreate", () => {
expect(res).toMatchObject({ code: "invalid_params" });
});
it("releases the pending agent-tab slot when chrome.tabs.create fails", async () => {
it("does not claim any tab when chrome.tabs.create fails", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
@@ -267,18 +273,37 @@ describe("handleTabCreate", () => {
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
expect(res).toMatchObject({ code: "protocol_error" });
// No tab was opened, so no `onCreated` event will consume the pending
// slot. Without the release the counter leaks and the next tab the user
// opens is misclassified as agent-created — the exact confusion the
// counter exists to prevent.
expect(sm.classifyNewTab(42, 100)).toBe("user");
expect(sm.get("aa11")?.agentCreatedTabs).toEqual(new Set([1]));
});
it("claims the returned id even when a user tab appears during tab creation", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
let resolveCreate!: (tab: chrome.tabs.Tab) => void;
const createResult = new Promise<chrome.tabs.Tab>((resolve) => {
resolveCreate = resolve;
});
spies.create.mockImplementationOnce(() => createResult);
const pending = handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
// A user can create an unrelated same-window tab while Chrome is still
// resolving the agent request. There is no pending slot for it to steal.
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
resolveCreate({ id: 10, windowId: 100, active: true } as chrome.tabs.Tab);
await expect(pending).resolves.toMatchObject({ tab_id: 10 });
expect(ctx.agentCreatedTabs.has(10)).toBe(true);
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
});
});
describe("handleTabClose", () => {
it("removes a tab inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(5);
const state: FakeTabState = {
tabs: new Map([[5, { id: 5, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
@@ -291,6 +316,23 @@ describe("handleTabClose", () => {
expect(spies.remove).toHaveBeenCalledWith(5);
});
it("refuses to close a free tab in the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[5, { id: 5, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 5 }, { tabs: api });
expect(res).toMatchObject({
code: "permission_denied",
data: { reason: "agent_window_scope" },
});
expect(spies.remove).not.toHaveBeenCalled();
});
it("rejects closing a borrowed tab — caller must tab_return first", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
@@ -341,7 +383,8 @@ describe("handleTabClose", () => {
describe("handleTabSelect", () => {
it("activates a tab inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(3);
const state: FakeTabState = {
tabs: new Map([[3, { id: 3, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => 4),
};
}
+3 -13
View File
@@ -269,7 +269,8 @@ export async function handleSessionStop(
return { code: "cancelled", message: "session_stop aborted before window close" };
}
// Step 4: close the agent-created tabs and the home tab. `tabsApi` is a
// Step 4: close every tab explicitly created by the agent, including the
// home tab. `tabsApi` is a
// TabMutationApi (remove only); `queryApi` is a separate read-only
// ChromeTabsApi. If neither is injected, we conservatively fall back to
// closing the window (see Step 5).
@@ -277,7 +278,7 @@ export async function handleSessionStop(
const queryApi = deps.tabsQuery;
if (tabsApi) {
// 4a: close each agent-created tab that still exists.
// Close each agent-created tab that still exists.
const agentCreatedTabIds = Array.from(ctx.agentCreatedTabs);
for (const tabId of agentCreatedTabIds) {
try {
@@ -288,16 +289,6 @@ export async function handleSessionStop(
console.warn(`[bsk session_stop] failed to close agent tab ${tabId}`, err);
}
}
// 4b: close the home tab by id (not by URL — avoids deleting a user's
// `about:blank` tab). Non-fatal if it's already gone.
if (ctx.homeTabId != null) {
try {
await tabsApi.remove(ctx.homeTabId);
} catch (err) {
console.warn(`[bsk session_stop] failed to close home tab`, err);
}
}
}
// Step 5: decide whether to release (keep) the window or close it.
@@ -312,7 +303,6 @@ export async function handleSessionStop(
// regression). Exclude any id still tracked in agentCreatedTabs.
const userTabs = liveWindowTabs.filter((t) => {
if (t.id === undefined) return false;
if (t.id === ctx.homeTabId) return false;
return !ctx.agentCreatedTabs.has(t.id);
});
const leakedAgentTabs = liveWindowTabs.filter(
+11 -8
View File
@@ -8,7 +8,11 @@
import type { CdpDebuggee, DialogCursor } from "@/browser-driver/chromium-cdp";
import type { CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import {
isAgentControlledTab,
type SessionContext,
type SessionManager,
} from "@/session-manager/manager";
import { normaliseRef } from "@/session-manager/ref-store";
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
import { rpcError } from "./errors";
@@ -323,23 +327,22 @@ export async function resolveCdpAccessibleTargetTab(
}
/**
* Sandbox guard: M7 write tools (click / fill / press / navigate*)
* MUST refuse to touch a tab outside the session's Agent Window
* (§6 — borrowing brings the tab into the Agent Window first).
* Sandbox guard: M7 write tools (click / fill / press / navigate*) must
* refuse tabs the session has not explicitly claimed. A controlled tab must
* also remain inside the session's Agent Window.
*
* Returns an `RpcError` when the resolved target sits in a user window;
* `null` on success.
* Returns an `RpcError` for free tabs and tabs outside the Agent Window.
*/
export function enforceAgentWindow(
ctx: SessionContext,
target: { tabId: number; windowId: number },
toolName: string,
): RpcError | null {
if (target.windowId !== ctx.agentWindowId) {
if (target.windowId !== ctx.agentWindowId || !isAgentControlledTab(ctx, target.tabId)) {
return rpcError(
"permission_denied",
"agent_window_scope",
`${toolName} can only act on tabs inside the Agent Window (tab ${target.tabId} is in window ${target.windowId}; borrow it first)`,
`${toolName} can only act on tabs claimed by this session inside the Agent Window (tab ${target.tabId} is free or outside window ${ctx.agentWindowId}; borrow it first)`,
);
}
return null;
+40 -53
View File
@@ -7,7 +7,11 @@ import {
OVERLAY_AGENT_OVERLAY_RESET,
type OverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import {
isAgentControlledTab,
type SessionContext,
type SessionManager,
} from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { isRpcError, lookupSession } from "./shared";
@@ -25,11 +29,9 @@ export interface TabInfo {
window_id?: number;
active?: boolean;
/**
* Where the tab sits relative to the requesting session: tabs in any
* window other than an Agent Window are `user`; tabs in this
* session's own Agent Window are `agent`. Tabs in *other* sessions'
* Agent Windows are filtered out entirely (cross-session isolation,
* design §6).
* Ownership relative to the requesting session. Explicitly created or
* borrowed tabs in this session's Agent Window are `agent`; every other
* visible tab is `user`. Other sessions' Agent Windows are filtered out.
*/
scope?: "user" | "agent";
}
@@ -259,7 +261,8 @@ export async function handleTabList(
if (typeof t.id !== "number") continue;
const winId = typeof t.windowId === "number" ? t.windowId : -1;
if (otherAgentWindowIds.has(winId)) continue;
const tabScope: "user" | "agent" = winId === myAgentWindowId ? "agent" : "user";
const tabScope: "user" | "agent" =
winId === myAgentWindowId && isAgentControlledTab(ctx, t.id) ? "agent" : "user";
if (scope === "user" && tabScope !== "user") continue;
if (scope === "agent" && tabScope !== "agent") continue;
tabs.push({
@@ -369,6 +372,7 @@ function buildCreateProps(
* Returns the created tab on success, or an `RpcError` on failure.
*/
async function createTabAndCleanup(
ctx: SessionContext,
deps: TabManagementDeps,
createProps: chrome.tabs.CreateProperties,
): Promise<CreatedChromeTab | RpcError> {
@@ -381,25 +385,28 @@ async function createTabAndCleanup(
message: err instanceof Error ? err.message : String(err),
};
}
if (aborted(deps.signal, "tab_create")) {
// We already opened the tab; close it on abort so we don't leak.
if (typeof tab.id === "number") {
try {
await getTabsApi(deps).remove(tab.id);
} catch (cleanupErr) {
return rpcError(
"protocol_error",
"cleanup_failed",
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "tab", resource_id: tab.id },
);
}
}
return { code: "cancelled", message: "tab_create aborted" };
}
if (typeof tab.id !== "number") {
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
}
// Claim the concrete id returned by Chrome. Ownership never depends on
// matching this request to an asynchronous onCreated event.
ctx.agentCreatedTabs.add(tab.id);
if (aborted(deps.signal, "tab_create")) {
try {
await getTabsApi(deps).remove(tab.id);
ctx.agentCreatedTabs.delete(tab.id);
} catch (cleanupErr) {
// Keep the claim when cleanup fails so session_stop can retry instead
// of releasing an agent-owned tab to the user.
return rpcError(
"protocol_error",
"cleanup_failed",
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "tab", resource_id: tab.id },
);
}
return { code: "cancelled", message: "tab_create aborted" };
}
return { ...tab, id: tab.id };
}
@@ -426,27 +433,8 @@ export async function handleTabCreate(
const paramErr = validateTabCreateParams(params);
if (paramErr) return paramErr;
// Flag the pending agent tab *before* create so the chrome.tabs.onCreated
// listener (background.ts) can tell this tab apart from a user-opened tab.
manager.markAgentTabPending(ctx.agentWindowId);
const tab = await createTabAndCleanup(deps, buildCreateProps(ctx, params));
if (isRpcError(tab)) {
// Release the pending slot when no tab was actually opened: with no
// `onCreated` event to consume it, the counter would leak and the next
// tab the user opens would be misclassified as agent-created. An abort
// (`cancelled`) is the exception — the tab did open (and was cleaned up),
// so its `onCreated` still consumes the slot.
if (tab.code !== "cancelled") {
manager.releaseAgentTabPending(ctx.agentWindowId);
}
return tab;
}
// Track agent-created tabs so session_stop can clean them up (design §3.1).
// The onCreated listener also adds this id; this is an idempotent fallback
// in case the listener has not fired yet.
ctx.agentCreatedTabs.add(tab.id);
const tab = await createTabAndCleanup(ctx, deps, buildCreateProps(ctx, params));
if (isRpcError(tab)) return tab;
return {
tab_id: tab.id,
@@ -496,13 +484,13 @@ async function authoriseAgentTab(
`${toolName}: tab ${tabId} is borrowed by session ${otherBorrower}`,
);
}
if (tab.windowId === ctx.agentWindowId) {
if (tab.windowId === ctx.agentWindowId && isAgentControlledTab(ctx, tab.id)) {
return tab;
}
return rpcError(
"permission_denied",
"agent_window_scope",
`${toolName}: tab ${tabId} is not in Agent Window ${ctx.agentWindowId}`,
`${toolName}: tab ${tabId} is not claimed by this session in Agent Window ${ctx.agentWindowId}`,
);
}
@@ -775,13 +763,6 @@ async function executeBorrowCore(
);
if (moveErr) return moveErr;
// Best-effort activation — failure is non-fatal.
try {
await p.tabsApi.update(p.tabId, { active: true });
} catch (err) {
console.debug("[bsk tab_borrow] activate after move failed", err);
}
return { originalWindowId, originalIndex };
}
@@ -845,6 +826,12 @@ export async function handleTabBorrow(
message: err instanceof Error ? err.message : String(err),
};
}
// Activate after commit so overlay/event observers see the tab as claimed.
try {
await tabsApi.update(params.tab_id, { active: true });
} catch (err) {
console.debug("[bsk tab_borrow] activate after move failed", err);
}
return {
tab_id: params.tab_id,
original_window_id: coreResult.originalWindowId,