diff --git a/src/browser/server.ts b/src/browser/server.ts index 62ad1872a..77a099c14 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -26,6 +26,7 @@ import { DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, normalizeBrowserScreenshot, } from "./screenshot.js"; +import { resolveTargetIdFromTabs } from "./target-id.js"; export type BrowserTab = { targetId: string; @@ -270,7 +271,15 @@ export async function startBrowserControlServerFromConfig( const reachable = await isChromeReachable(state.cdpPort, 300); if (!reachable) return jsonError(res, 409, "browser not running"); try { - await activateTab(state.cdpPort, targetId); + const tabs = await listTabs(state.cdpPort); + const resolved = resolveTargetIdFromTabs(targetId, tabs); + if (!resolved.ok) { + if (resolved.reason === "ambiguous") { + return jsonError(res, 409, "ambiguous target id prefix"); + } + return jsonError(res, 404, "tab not found"); + } + await activateTab(state.cdpPort, resolved.targetId); res.json({ ok: true }); } catch (err) { jsonError(res, 500, String(err)); @@ -284,7 +293,15 @@ export async function startBrowserControlServerFromConfig( const reachable = await isChromeReachable(state.cdpPort, 300); if (!reachable) return jsonError(res, 409, "browser not running"); try { - await closeTab(state.cdpPort, targetId); + const tabs = await listTabs(state.cdpPort); + const resolved = resolveTargetIdFromTabs(targetId, tabs); + if (!resolved.ok) { + if (resolved.reason === "ambiguous") { + return jsonError(res, 409, "ambiguous target id prefix"); + } + return jsonError(res, 404, "tab not found"); + } + await closeTab(state.cdpPort, resolved.targetId); res.json({ ok: true }); } catch (err) { jsonError(res, 500, String(err)); @@ -304,8 +321,20 @@ export async function startBrowserControlServerFromConfig( try { const tabs = await listTabs(state.cdpPort); const chosen = targetId - ? tabs.find((t) => t.targetId === targetId) - : tabs.at(0); + ? (() => { + const resolved = resolveTargetIdFromTabs(targetId, tabs); + if (!resolved.ok) { + if (resolved.reason === "ambiguous") { + return "AMBIGUOUS" as const; + } + return null; + } + return tabs.find((t) => t.targetId === resolved.targetId) ?? null; + })() + : (tabs.at(0) ?? null); + if (chosen === "AMBIGUOUS") { + return jsonError(res, 409, "ambiguous target id prefix"); + } if (!chosen?.wsUrl) return jsonError(res, 404, "tab not found"); let shot: Buffer = Buffer.alloc(0); diff --git a/src/browser/target-id.test.ts b/src/browser/target-id.test.ts new file mode 100644 index 000000000..d00036532 --- /dev/null +++ b/src/browser/target-id.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTargetIdFromTabs } from "./target-id.js"; + +describe("browser target id resolution", () => { + it("resolves exact ids", () => { + const res = resolveTargetIdFromTabs("FULL", [ + { targetId: "AAA" }, + { targetId: "FULL" }, + ]); + expect(res).toEqual({ ok: true, targetId: "FULL" }); + }); + + it("resolves unique prefixes (case-insensitive)", () => { + const res = resolveTargetIdFromTabs("57a01309", [ + { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, + ]); + expect(res).toEqual({ + ok: true, + targetId: "57A01309E14B5DEE0FB41F908515A2FC", + }); + }); + + it("fails on ambiguous prefixes", () => { + const res = resolveTargetIdFromTabs("57A0", [ + { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, + { targetId: "57A0BEEF000000000000000000000000" }, + ]); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.reason).toBe("ambiguous"); + expect(res.matches?.length).toBe(2); + } + }); + + it("fails when no tab matches", () => { + const res = resolveTargetIdFromTabs("NOPE", [{ targetId: "AAA" }]); + expect(res).toEqual({ ok: false, reason: "not_found" }); + }); +}); diff --git a/src/browser/target-id.ts b/src/browser/target-id.ts new file mode 100644 index 000000000..4e53ea300 --- /dev/null +++ b/src/browser/target-id.ts @@ -0,0 +1,24 @@ +export type TargetIdResolution = + | { ok: true; targetId: string } + | { ok: false; reason: "not_found" | "ambiguous"; matches?: string[] }; + +export function resolveTargetIdFromTabs( + input: string, + tabs: Array<{ targetId: string }>, +): TargetIdResolution { + const needle = input.trim(); + if (!needle) return { ok: false, reason: "not_found" }; + + const exact = tabs.find((t) => t.targetId === needle); + if (exact) return { ok: true, targetId: exact.targetId }; + + const lower = needle.toLowerCase(); + const matches = tabs + .map((t) => t.targetId) + .filter((id) => id.toLowerCase().startsWith(lower)); + + const only = matches.length === 1 ? matches[0] : undefined; + if (only) return { ok: true, targetId: only }; + if (matches.length === 0) return { ok: false, reason: "not_found" }; + return { ok: false, reason: "ambiguous", matches }; +}