From c7615aa559fb6661fde725c7bea9a6abb610c3ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 10:44:11 +0000 Subject: [PATCH] fix(browser): improve chrome relay tab selection --- ...-tab-available.prefers-last-target.test.ts | 102 ++++++++++++++++++ src/browser/server-context.ts | 18 +++- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 056cbcb23..4863cc1fe 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -96,4 +96,106 @@ describe("browser server-context ensureTabAvailable", () => { const second = await chrome.ensureTabAvailable(); expect(second.targetId).toBe("A"); }); + + it("falls back to the only attached tab when an invalid targetId is provided (extension)", async () => { + const fetchMock = vi.fn(); + const responses = [ + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + ]; + + fetchMock.mockImplementation(async (url: unknown) => { + const u = String(url); + if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`); + const next = responses.shift(); + if (!next) throw new Error("no more responses"); + return { ok: true, json: async () => next } as unknown as Response; + }); + + // @ts-expect-error test override + global.fetch = fetchMock; + + const state: BrowserServerState = { + // biome-ignore lint/suspicious/noExplicitAny: test stub + server: null as any, + port: 0, + resolved: { + enabled: true, + controlUrl: "http://127.0.0.1:18791", + controlHost: "127.0.0.1", + controlPort: 18791, + cdpProtocol: "http", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + color: "#FF4500", + headless: true, + noSandbox: false, + attachOnly: false, + defaultProfile: "chrome", + profiles: { + chrome: { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + cdpPort: 18792, + color: "#00AA00", + }, + clawd: { cdpPort: 18800, color: "#FF4500" }, + }, + }, + profiles: new Map(), + }; + + const ctx = createBrowserRouteContext({ getState: () => state }); + const chrome = ctx.forProfile("chrome"); + const chosen = await chrome.ensureTabAvailable("NOT_A_TAB"); + expect(chosen.targetId).toBe("A"); + }); + + it("returns a descriptive message when no extension tabs are attached", async () => { + const fetchMock = vi.fn(); + const responses = [[]]; + fetchMock.mockImplementation(async (url: unknown) => { + const u = String(url); + if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`); + const next = responses.shift(); + if (!next) throw new Error("no more responses"); + return { ok: true, json: async () => next } as unknown as Response; + }); + // @ts-expect-error test override + global.fetch = fetchMock; + + const state: BrowserServerState = { + // biome-ignore lint/suspicious/noExplicitAny: test stub + server: null as any, + port: 0, + resolved: { + enabled: true, + controlUrl: "http://127.0.0.1:18791", + controlHost: "127.0.0.1", + controlPort: 18791, + cdpProtocol: "http", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + color: "#FF4500", + headless: true, + noSandbox: false, + attachOnly: false, + defaultProfile: "chrome", + profiles: { + chrome: { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + cdpPort: 18792, + color: "#00AA00", + }, + clawd: { cdpPort: 18800, color: "#FF4500" }, + }, + }, + profiles: new Map(), + }; + + const ctx = createBrowserRouteContext({ getState: () => state }); + const chrome = ctx.forProfile("chrome"); + await expect(chrome.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); + }); }); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index a5cbb57b9..5ed460d10 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -282,13 +282,16 @@ function createProfileContext( const tabs1 = await listTabs(); if (tabs1.length === 0) { if (profile.driver === "extension") { - throw new Error("tab not found"); + throw new Error( + `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + + "Click the Clawdbot Browser Relay toolbar icon on the tab you want to control (badge ON).", + ); } await openTab("about:blank"); } const tabs = await listTabs(); - const candidates = tabs.filter((t) => Boolean(t.wsUrl)); + const candidates = profile.driver === "extension" ? tabs : tabs.filter((t) => Boolean(t.wsUrl)); const resolveById = (raw: string) => { const resolved = resolveTargetIdFromTabs(raw, candidates); @@ -308,12 +311,17 @@ function createProfileContext( return page ?? candidates.at(0) ?? null; }; - const chosen = targetId ? resolveById(targetId) : pickDefault(); + let chosen = targetId ? resolveById(targetId) : pickDefault(); + if (!chosen && profile.driver === "extension" && candidates.length === 1) { + // If an agent passes a stale/foreign targetId but we only have a single attached tab, + // recover by using that tab instead of failing hard. + chosen = candidates[0] ?? null; + } if (chosen === "AMBIGUOUS") { throw new Error("ambiguous target id prefix"); } - if (!chosen?.wsUrl) throw new Error("tab not found"); + if (!chosen) throw new Error("tab not found"); profileState.lastTargetId = chosen.targetId; return chosen; }; @@ -496,7 +504,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon return { status: 409, message: "ambiguous target id prefix" }; } if (msg.includes("tab not found")) { - return { status: 404, message: "tab not found" }; + return { status: 404, message: msg }; } if (msg.includes("not found")) { return { status: 404, message: msg };