diff --git a/apps/extension/src/session-manager/ref-store.ts b/apps/extension/src/session-manager/ref-store.ts index 568392fc..6ed5ac15 100644 --- a/apps/extension/src/session-manager/ref-store.ts +++ b/apps/extension/src/session-manager/ref-store.ts @@ -1,19 +1,11 @@ -/** - * Per-session map from `@e` snapshot refs to a CDP node address. - * - * Each fresh `tool.snapshot` resets the store: M6 will call - * `replace(...)` with the new ref → node address pairs. A node address - * includes the owning flat CDP session/frame when the element lives in - * an OOPIF; tools resolve live geometry from that identity at call time. - * - * Refs are session-scoped (§7): looking up a ref in the wrong session - * returns `null`, never silently leaks. Storing values in different - * sessions is fine; they live in independent `RefStore` instances - * inside the `SessionContext`. - */ +import type { VisualCandidate } from "@/tools/vom/visual-discovery"; + +/** Session-local refs describe the latest observation. Reusing eN does not identify + * which observation a caller read; generation is internal bookkeeping only. */ export type BackendNodeId = number; -export interface RefEntry { +export interface DomRefEntry { + readonly kind: "dom"; backendNodeId: BackendNodeId; tabId: number | null; frameId?: string; @@ -21,7 +13,15 @@ export interface RefEntry { generation: number; } +export interface VisualRefInput { + readonly kind: "visual-region"; + readonly candidate: VisualCandidate; +} + +export type RefEntry = DomRefEntry | (VisualRefInput & { readonly generation: number }); + export type RefInput = + | VisualRefInput | BackendNodeId | { backendNodeId: BackendNodeId; @@ -31,7 +31,7 @@ export type RefInput = }; export class RefStore { - private readonly map = new Map(); + private map = new Map(); private generation = 0; size(): number { @@ -44,7 +44,7 @@ export class RefStore { resolve(ref: string, opts: { tabId?: number } = {}): BackendNodeId | null { const entry = this.map.get(normaliseRef(ref)); - if (!entry) return null; + if (!entry || entry.kind !== "dom") return null; if (opts.tabId !== undefined && entry.tabId !== opts.tabId) return null; return entry.backendNodeId; } @@ -58,9 +58,11 @@ export class RefStore { * Used after every fresh `tool.snapshot`. */ replace(entries: Iterable): void { - this.map.clear(); - this.generation += 1; - for (const [ref, input] of entries) this.map.set(normaliseRef(ref), this.entry(input)); + const generation = this.generation + 1; + const next = new Map(); + for (const [ref, input] of entries) next.set(normaliseRef(ref), this.entry(input, generation)); + this.map = next; + this.generation = generation; } set( @@ -73,6 +75,7 @@ export class RefStore { } = {}, ): void { this.map.set(normaliseRef(ref), { + kind: "dom", backendNodeId: id, tabId: opts.tabId ?? null, ...(opts.frameId ? { frameId: opts.frameId } : {}), @@ -89,20 +92,37 @@ export class RefStore { return this.map.entries(); } - private entry(input: RefInput): RefEntry { + private entry(input: RefInput, generation: number): RefEntry { + if (typeof input !== "number" && "kind" in input) { + const { document } = input.candidate; + if ( + !document?.attachmentId || + !document.frameId || + !Number.isSafeInteger(document.target?.tabId) || + !Number.isSafeInteger(document.documentElementBackendNodeId) || + document.documentElementBackendNodeId <= 0 || + !Number.isSafeInteger(input.candidate.backendNodeId) || + input.candidate.backendNodeId <= 0 + ) + throw new TypeError("visual ref requires a verified DOM identity and anchor"); + // Preserve the read-only evidence and shared clipping chain; do not clone ancestors per ref. + return { kind: "visual-region", candidate: input.candidate, generation }; + } if (typeof input === "number") { return { + kind: "dom", backendNodeId: input, tabId: null, - generation: this.generation, + generation, }; } return { + kind: "dom", backendNodeId: input.backendNodeId, tabId: input.tabId, ...(input.frameId ? { frameId: input.frameId } : {}), ...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}), - generation: this.generation, + generation, }; } } diff --git a/apps/extension/src/tools/__tests__/human-loop.test.ts b/apps/extension/src/tools/__tests__/human-loop.test.ts index d695233d..b0d929df 100644 --- a/apps/extension/src/tools/__tests__/human-loop.test.ts +++ b/apps/extension/src/tools/__tests__/human-loop.test.ts @@ -405,6 +405,7 @@ describe("handleRequestHelp", () => { agentWindowId: 99, refStore: { resolveEntry: () => ({ + kind: "dom", backendNodeId: 42, tabId: 5, frameId: "child", diff --git a/apps/extension/src/tools/__tests__/observation.test.ts b/apps/extension/src/tools/__tests__/observation.test.ts index 1fc9feb8..c8000f3c 100644 --- a/apps/extension/src/tools/__tests__/observation.test.ts +++ b/apps/extension/src/tools/__tests__/observation.test.ts @@ -3813,7 +3813,9 @@ describe("handleSnapshot", () => { if ("code" in result) throw new Error(`unexpected error: ${JSON.stringify(result)}`); expect(result.text).toContain('@e1 button "Frame action"'); - const frameRef = [...ctx.refStore.entries()].find(([, entry]) => entry.backendNodeId === 22); + const frameRef = [...ctx.refStore.entries()].find( + ([, entry]) => entry.kind === "dom" && entry.backendNodeId === 22, + ); expect(frameRef?.[1]).toMatchObject({ tabId: 4, frameId: "child", diff --git a/apps/extension/src/tools/__tests__/visual-ref.test.ts b/apps/extension/src/tools/__tests__/visual-ref.test.ts new file mode 100644 index 00000000..94be024e --- /dev/null +++ b/apps/extension/src/tools/__tests__/visual-ref.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "@/session-manager/manager"; +import { RefStore, type VisualRefInput } from "@/session-manager/ref-store"; +import { handleDownload } from "../download"; +import { handleRequestHelp, resetHelpLifecycleForTests } from "../human-loop"; +import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "../interaction"; +import { handleGetHtml, handleScreenshot } from "../observation"; +import type { CdpRunner } from "../shared"; +import { lookupRefTarget, lookupSnapshotRef, resolveSnapshotRef } from "../snapshot-ref"; +import { handleUpload } from "../upload"; + +function visual(): VisualRefInput { + return { + kind: "visual-region", + candidate: { + document: { + attachmentId: "a", + target: { tabId: 4, sessionId: "child" }, + frameId: "frame", + documentElementBackendNodeId: 1, + }, + backendNodeId: 42, + parentBackendNodeId: 1, + region: { + status: "available", + borderBox: { x: 0, y: 0, width: 100, height: 50 }, + crop: { x: 0, y: 0, width: 100, height: 50 }, + }, + }, + }; +} + +async function setup() { + const manager = new SessionManager({ + agentWindow: { + create: async () => 100, + remove: async () => {}, + ensureActiveTab: async () => 4, + }, + }); + const ctx = await manager.start("test"); + ctx.refStore.replace([ + ["e1", visual()], + ["e2", { backendNodeId: 43, tabId: 4 }], + ]); + const send = vi.fn(async () => { + throw new Error("unexpected target operation"); + }); + const cdp = { send, sendToTarget: send, trackSessionTab: vi.fn() } as unknown as CdpRunner; + const tab = { id: 4, windowId: 100, active: true, url: "https://example.com" } as chrome.tabs.Tab; + const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) }; + return { manager, ctx, send, cdp, tabsApi }; +} + +describe("typed visual refs", () => { + afterEach(() => resetHelpLifecycleForTests()); + + it("keeps candidate evidence and cannot resolve a visual anchor as a bare DOM node", () => { + const store = new RefStore(); + const input = visual(); + store.replace([ + ["@e1", input], + ["e2", { backendNodeId: 43, tabId: 4 }], + ]); + const entry = store.resolveEntry("e1"); + expect(entry?.kind).toBe("visual-region"); + if (entry?.kind !== "visual-region") throw new Error("missing visual ref"); + expect(entry.candidate).toBe(input.candidate); + expect(entry.generation).toBe(1); + expect(store.resolve("@e1")).toBeNull(); + expect(store.resolve("e2")).toBe(43); + // The latest observation replaces both target kinds; eN strings carry no generation. + store.replace([["e1", { backendNodeId: 99, tabId: 4 }]]); + expect(store.resolve("e1")).toBe(99); + expect(store.resolveEntry("e1")).toMatchObject({ kind: "dom", generation: 2 }); + expect(store.resolveEntry("e2")).toBeNull(); + }); + + it("rejects missing visual identity without partially publishing a replacement", () => { + const store = new RefStore(); + store.set("e1", 10, { tabId: 4 }); + const invalid = visual(); + invalid.candidate.document.attachmentId = ""; + expect(() => + store.replace([ + ["e2", 20], + ["e3", invalid], + ]), + ).toThrow(TypeError); + expect(store.resolveEntry("e1")).toMatchObject({ generation: 0, backendNodeId: 10 }); + expect(store.size()).toBe(1); + store.replace([["e1", visual()]]); + expect(store.resolveEntry("e1")?.generation).toBe(1); + }); + + it("isolates tabs/sessions and distinguishes unsupported targets from missing refs", async () => { + const { ctx } = await setup(); + expect(lookupRefTarget(ctx, "@e1", 4)?.kind).toBe("visual-region"); + expect(lookupRefTarget(ctx, "e1", 5)).toBeNull(); + expect(lookupSnapshotRef(ctx, "e1", 4)).toBeNull(); + expect(resolveSnapshotRef(ctx, "e1", 4)).toMatchObject({ + code: "unsupported", + data: { reason: "ref_kind_unsupported" }, + }); + expect(resolveSnapshotRef(ctx, "e1", 5)).toMatchObject({ + code: "not_found", + data: { reason: "ref_not_found" }, + }); + expect(lookupRefTarget({ ...ctx, refStore: new RefStore() }, "e1", 4)).toBeNull(); + expect(lookupSnapshotRef(ctx, "e2", 4)).toMatchObject({ backendNodeId: 43 }); + }); + + it.each([ + "click", + "fill", + "hover", + "press", + "select", + "get_html", + "screenshot", + ])("rejects visual refs in %s before target effects", async (tool) => { + const { manager, send, cdp, tabsApi } = await setup(); + const params = { session_id: "test", tab_id: 4, ref: "@e1" }; + const deps = { cdp, tabsApi }; + const captureVisibleTab = vi.fn(); + let result: unknown; + switch (tool) { + case "click": + result = await handleClick(manager, params, deps); + break; + case "fill": + result = await handleFill(manager, { ...params, value: "hello" }, deps); + break; + case "hover": + result = await handleHover(manager, params, deps); + break; + case "press": + result = await handlePress(manager, { ...params, key: "Enter" }, deps); + break; + case "select": + result = await handleSelect(manager, { ...params, values: ["one"] }, deps); + break; + case "get_html": + result = await handleGetHtml(manager, params, deps); + break; + case "screenshot": + result = await handleScreenshot(manager, params, { + ...deps, + captureApi: { ...tabsApi, captureVisibleTab }, + }); + break; + } + expect(result).toMatchObject({ code: "unsupported", data: { reason: "ref_kind_unsupported" } }); + expect(send).not.toHaveBeenCalled(); + expect(captureVisibleTab).not.toHaveBeenCalled(); + }); + + it.each([ + "input", + "drop", + "download", + ] as const)("rejects visual refs before %s file-transfer setup", async (mode) => { + const { manager, send, cdp, tabsApi } = await setup(); + const params = { session_id: "test", tab_id: 4, ref: "e1" }; + const result = + mode === "download" + ? await handleDownload( + manager, + { ...params, browser_relative_dir: "test" }, + { cdp, tabsApi }, + ) + : await handleUpload( + manager, + { + ...params, + mode, + files: [{ transfer_id: "test", name: "test.txt", staged_path: "/unused/test.txt" }], + }, + { cdp, tabsApi }, + ); + expect(result).toMatchObject({ code: "unsupported", data: { reason: "ref_kind_unsupported" } }); + expect(send).not.toHaveBeenCalled(); + }); + + it("does not scroll or highlight a visual anchor in human help", async () => { + const { manager, cdp, tabsApi, send } = await setup(); + const sendToTab = vi.fn(async (_tab: number, _message: unknown) => ({ + type: "bsk-help-response", + outcome: "continued", + })); + const result = await handleRequestHelp( + manager, + { session_id: "test", tab_id: 4, prompt: "help", targets: [{ ref: "@e1" }] }, + { + cdp, + tabsApi, + sendToTab, + windows: { update: vi.fn(async () => ({}) as never) }, + activateTab: vi.fn(async () => {}), + notifications: null, + autoAttachLifecycle: false, + }, + ); + expect(result).toMatchObject({ resolved_targets: [{ ref: "@e1", matched: false }] }); + expect(send).not.toHaveBeenCalled(); + expect(sendToTab.mock.calls[0][1]).toMatchObject({ rects: [], selectors: [] }); + }); +}); diff --git a/apps/extension/src/tools/observation.ts b/apps/extension/src/tools/observation.ts index ea99ddf9..acb41be9 100644 --- a/apps/extension/src/tools/observation.ts +++ b/apps/extension/src/tools/observation.ts @@ -48,7 +48,7 @@ import { normaliseRef as sharedNormaliseRef, type ToolEffect, } from "./shared"; -import { resolveSnapshotRef } from "./snapshot-ref"; +import { lookupRefTarget, resolveSnapshotRef } from "./snapshot-ref"; import { type CapturedNode, type CapturedSurfaceProbe, probeHoverSurfaces } from "./vom/capture"; import { captureObservationFacts, semanticCapture } from "./vom/capture-coordinator"; import type { FrameDocument as CapturedFrameDocument } from "./vom/frame-document"; @@ -308,6 +308,12 @@ export async function handleScreenshot( if (!deps.cdp) { return { code: "cdp_failed", message: "screenshot ref capture requires CDP" }; } + if (lookupRefTarget(ctx, ref, target.tabId)?.kind === "visual-region") + return rpcError( + "unsupported", + "ref_kind_unsupported", + "visual-region screenshot execution is not available in this build", + ); const node = resolveSnapshotRef(ctx, ref, target.tabId); if (isRpcError(node)) return node; if (signal?.aborted) return cancelled("screenshot"); diff --git a/apps/extension/src/tools/snapshot-ref.ts b/apps/extension/src/tools/snapshot-ref.ts index 90f46bcf..1a82ecff 100644 --- a/apps/extension/src/tools/snapshot-ref.ts +++ b/apps/extension/src/tools/snapshot-ref.ts @@ -3,7 +3,7 @@ // `ref_not_found` errors for hard-failure tool paths. import type { SessionContext } from "@/session-manager/manager"; -import { normaliseRef } from "@/session-manager/ref-store"; +import { normaliseRef, type RefEntry } from "@/session-manager/ref-store"; import type { RpcError } from "@/transport/types"; import { rpcError } from "./errors"; @@ -14,14 +14,22 @@ export interface SnapshotRefLookup { cdpSessionId?: string; } -function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) { +/** Typed lookup never turns a visual anchor into a DOM operation target. */ +export function lookupRefTarget( + ctx: SessionContext, + refKey: string, + tabId: number, +): RefEntry | null { const entry = ctx.refStore.resolveEntry(refKey); - return entry?.tabId === tabId ? entry : null; + if (!entry) return null; + const ownerTabId = + entry.kind === "visual-region" ? entry.candidate.document.target.tabId : entry.tabId; + return ownerTabId === tabId ? entry : null; } /** * Soft lookup: returns `null` when the ref is unknown or bound to a - * different tab. Used by paths that report `matched: false` instead of + * different tab, or is a visual region. Used by paths that report `matched: false` instead of * emitting an RPC error (e.g. `tool.request_help`). */ export function lookupSnapshotRef( @@ -30,8 +38,8 @@ export function lookupSnapshotRef( tabId: number, ): SnapshotRefLookup | null { const refKey = normaliseRef(ref); - const entry = refEntryForTab(ctx, refKey, tabId); - if (!entry) return null; + const entry = lookupRefTarget(ctx, refKey, tabId); + if (!entry || entry.kind !== "dom") return null; return { backendNodeId: entry.backendNodeId, refKey, @@ -50,6 +58,13 @@ export function resolveSnapshotRef( ref: string, tabId: number, ): SnapshotRefLookup | RpcError { + const entry = lookupRefTarget(ctx, ref, tabId); + if (entry?.kind === "visual-region") + return rpcError( + "unsupported", + "ref_kind_unsupported", + `ref ${ref} is a visual region, not a DOM operation target`, + ); const looked = lookupSnapshotRef(ctx, ref, tabId); if (looked === null) { return rpcError( diff --git a/apps/extension/src/tools/vom/__tests__/document-identity.test.ts b/apps/extension/src/tools/vom/__tests__/document-identity.test.ts new file mode 100644 index 00000000..af519352 --- /dev/null +++ b/apps/extension/src/tools/vom/__tests__/document-identity.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CdpRunner } from "../../shared"; +import { verifyDocumentIdentity, verifyVisualTargetIdentity } from "../document-identity"; +import type { VisualCandidate } from "../visual-discovery"; + +const candidate: VisualCandidate = { + document: { + attachmentId: "attached", + target: { tabId: 4, sessionId: "child-session" }, + frameId: "child-frame", + documentElementBackendNodeId: 10, + }, + backendNodeId: 20, + parentBackendNodeId: 10, + region: { + status: "available", + borderBox: { x: 0, y: 0, width: 10, height: 10 }, + crop: { x: 0, y: 0, width: 10, height: 10 }, + }, +}; + +function fixture( + options: { + connected?: boolean; + sameDocument?: boolean; + root?: number; + missing?: boolean; + fail?: string; + abortAt?: string; + detachAt?: string; + } = {}, +) { + const controller = new AbortController(); + let attachment = "attached"; + const send = vi.fn( + async (_tab: number, _session: string, method: string, params: Record) => { + if (options.abortAt === method) controller.abort(); + if (options.detachAt === method) attachment = "new-attachment"; + if (options.fail === method) throw new Error("CDP failed"); + if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; + if (method === "DOM.resolveNode") + return options.missing ? {} : { object: { objectId: "anchor" } }; + if (method === "Runtime.evaluate" || method === "Runtime.callFunctionOn") { + let root: unknown = {}; + if (method === "Runtime.callFunctionOn") { + const document = { documentElement: {} }; + // Exercise the actual predicate, including connection and owner checks. + root = new Function("document", `return (${params.functionDeclaration}).call(this)`).call( + { + isConnected: options.connected ?? true, + ownerDocument: options.sameDocument === false ? {} : document, + }, + document, + ); + } + return { + result: { + deepSerializedValue: + root === null + ? { type: "null" } + : { type: "node", value: { backendNodeId: options.root ?? 10 } }, + }, + }; + } + if (method === "Runtime.releaseObjectGroup") return {}; + throw new Error(`unexpected ${method}`); + }, + ); + const cdp = { + send: vi.fn(), + sendToTarget: ( + target: { tabId: number; sessionId?: string }, + method: string, + params: Record, + ) => send(target.tabId, target.sessionId!, method, params), + getAttachmentId: () => attachment, + } as unknown as CdpRunner; + return { cdp, send, controller }; +} + +describe("visual target identity", () => { + it("uses the exact frame and target with bounded local reads and releases objects", async () => { + const { cdp, send } = fixture(); + expect(await verifyVisualTargetIdentity(cdp, candidate)).toBe("current"); + expect(send.mock.calls.map((c) => c[2])).toEqual([ + "Page.createIsolatedWorld", + "DOM.resolveNode", + "Runtime.callFunctionOn", + "Runtime.releaseObjectGroup", + ]); + expect( + send.mock.calls.every(([tab, session]) => tab === 4 && session === "child-session"), + ).toBe(true); + expect(send.mock.calls[0][3]).toMatchObject({ frameId: "child-frame" }); + expect(send.mock.calls[1][3]).toMatchObject({ backendNodeId: 20, executionContextId: 7 }); + expect(send.mock.calls[2][3]).toMatchObject({ objectId: "anchor" }); + expect(send.mock.calls[3][3].objectGroup).toBe(send.mock.calls[1][3].objectGroup); + }); + + it.each([ + { connected: false }, + { sameDocument: false }, + { root: 11 }, + ])("rejects detached/adopted/replaced DOM: %j", async (options) => { + const { cdp } = fixture(options); + expect(await verifyVisualTargetIdentity(cdp, candidate)).toBe("changed"); + }); + + it("does not add anchor reads to existing capture identity verification", async () => { + const { cdp, send } = fixture(); + expect(await verifyDocumentIdentity(cdp, candidate.document)).toBe("current"); + expect(send.mock.calls.map((c) => c[2])).toEqual([ + "Page.createIsolatedWorld", + "Runtime.evaluate", + "Runtime.releaseObjectGroup", + ]); + }); + + it.each([ + "Page.createIsolatedWorld", + "DOM.resolveNode", + "Runtime.callFunctionOn", + ])("fails closed and cleans up when %s fails", async (fail) => { + const { cdp, send } = fixture({ fail }); + expect(await verifyVisualTargetIdentity(cdp, candidate)).toBe("unavailable"); + expect(send.mock.calls.at(-1)?.[2]).toBe("Runtime.releaseObjectGroup"); + }); + + it("rejects attachment changes before and during verification", async () => { + const first = fixture(); + expect( + await verifyVisualTargetIdentity({ ...first.cdp, getAttachmentId: () => "other" }, candidate), + ).toBe("changed"); + expect(first.send).not.toHaveBeenCalled(); + const second = fixture({ detachAt: "Runtime.callFunctionOn" }); + expect(await verifyVisualTargetIdentity(second.cdp, candidate)).toBe("changed"); + const missing = fixture({ missing: true }); + expect(await verifyVisualTargetIdentity(missing.cdp, candidate)).toBe("unavailable"); + }); + + it.each([ + "DOM.resolveNode", + "Runtime.callFunctionOn", + ])("propagates cancellation at %s after releasing the object group", async (abortAt) => { + const { cdp, send, controller } = fixture({ abortAt }); + await expect( + verifyVisualTargetIdentity(cdp, candidate, controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(send.mock.calls.at(-1)?.[2]).toBe("Runtime.releaseObjectGroup"); + }); +}); diff --git a/apps/extension/src/tools/vom/document-identity.ts b/apps/extension/src/tools/vom/document-identity.ts index e10fc777..da233943 100644 --- a/apps/extension/src/tools/vom/document-identity.ts +++ b/apps/extension/src/tools/vom/document-identity.ts @@ -2,6 +2,7 @@ import type { CdpRunner } from "../shared"; import { sendToCdpTarget } from "../shared"; import { isAbortError, throwIfAborted } from "./capture-abort"; import type { DocumentIdentity } from "./facts"; +import type { VisualCandidate } from "./visual-discovery"; /** Compare the snapshot root with the current root in that exact frame. * Deep serialization supplies the backend ID without a separate describeNode. */ @@ -9,6 +10,24 @@ export async function verifyDocumentIdentity( cdp: CdpRunner, identity: DocumentIdentity, signal?: AbortSignal, +): Promise<"current" | "changed" | "unavailable"> { + return verifyIdentity(cdp, identity, signal); +} + +/** Verify the visual anchor in its original frame; geometry is checked by screenshot execution. */ +export async function verifyVisualTargetIdentity( + cdp: CdpRunner, + candidate: VisualCandidate, + signal?: AbortSignal, +): Promise<"current" | "changed" | "unavailable"> { + return verifyIdentity(cdp, candidate.document, signal, candidate.backendNodeId); +} + +async function verifyIdentity( + cdp: CdpRunner, + identity: DocumentIdentity, + signal?: AbortSignal, + anchorBackendNodeId?: number, ): Promise<"current" | "changed" | "unavailable"> { const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId; throwIfAborted(signal); @@ -23,17 +42,36 @@ export async function verifyDocumentIdentity( frameId: identity.frameId, worldName: "bsk-document-identity", }); - const reply = await send<{ + const serializationOptions = { + serialization: "deep", + additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" }, + }; + type RootReply = { result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } }; - }>("Runtime.evaluate", { - expression: "document.documentElement", - contextId: world.executionContextId, - objectGroup, - serializationOptions: { - serialization: "deep", - additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" }, - }, - }); + }; + let reply: RootReply; + if (anchorBackendNodeId === undefined) { + reply = await send("Runtime.evaluate", { + expression: "document.documentElement", + contextId: world.executionContextId, + objectGroup, + serializationOptions, + }); + } else { + const anchor = await send<{ object?: { objectId?: string } }>("DOM.resolveNode", { + backendNodeId: anchorBackendNodeId, + executionContextId: world.executionContextId, + objectGroup, + }); + if (!anchor.object?.objectId) return "unavailable"; + reply = await send("Runtime.callFunctionOn", { + objectId: anchor.object.objectId, + functionDeclaration: + "function() { return this.isConnected && this.ownerDocument === document ? document.documentElement : null; }", + objectGroup, + serializationOptions, + }); + } if (!attached()) return "changed"; const root = reply.result?.deepSerializedValue; if (root?.type === "null") return "changed"; diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index 8196bd28..42d726ed 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -24,6 +24,7 @@ export type RpcErrorReason = | "agent_window_scope" | "element_not_visible" | "ref_not_found" + | "ref_kind_unsupported" | "selector_not_found" | "target_not_fillable" | "fill_value_invalid" diff --git a/crates/bsk-cli/src/cli/render_error.rs b/crates/bsk-cli/src/cli/render_error.rs index 2f2d6faf..66cb2c19 100644 --- a/crates/bsk-cli/src/cli/render_error.rs +++ b/crates/bsk-cli/src/cli/render_error.rs @@ -40,6 +40,7 @@ use bsk_protocol::ErrorCode; pub mod reason { pub const AGENT_WINDOW_SCOPE: &str = "agent_window_scope"; pub const ELEMENT_NOT_VISIBLE: &str = "element_not_visible"; + pub const REF_KIND_UNSUPPORTED: &str = "ref_kind_unsupported"; pub const REF_NOT_FOUND: &str = "ref_not_found"; pub const SELECTOR_NOT_FOUND: &str = "selector_not_found"; pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable"; @@ -247,6 +248,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend ), exit_code: base.exit_code, }, + (ErrorCode::Unsupported, reason::REF_KIND_UNSUPPORTED) => RenderInfo { + summary: "this tool does not support the ref target type", + hint: Some( + "use a DOM ref for DOM operations; visual-region refs require an available visual screenshot path", + ), + exit_code: base.exit_code, + }, (ErrorCode::NotFound, reason::REF_NOT_FOUND) => RenderInfo { summary: "snapshot ref was not found for this tab", hint: Some("rerun `bsk snapshot` for the current tab and use one of the returned refs"), @@ -664,6 +672,15 @@ mod tests { assert!(info.summary.contains("timed out after browser dispatch")); } + #[test] + fn visual_ref_type_error_has_specific_guidance() { + let data = serde_json::json!({ "reason": reason::REF_KIND_UNSUPPORTED }); + let info = info_for_error(ErrorCode::Unsupported, Some(&data)); + assert!(info.summary.contains("ref target type")); + assert!(info.hint.unwrap().contains("DOM ref")); + assert_eq!(info.exit_code, info_for(ErrorCode::Unsupported).exit_code); + } + #[test] fn ref_not_found_overrides_not_found_copy() { let data = serde_json::json!({ "reason": reason::REF_NOT_FOUND });