diff --git a/extensions/browser/src/browser-tool-binding.test.ts b/extensions/browser/src/browser-tool-binding.test.ts index 2d8c23e617d0..4c1f56b70444 100644 --- a/extensions/browser/src/browser-tool-binding.test.ts +++ b/extensions/browser/src/browser-tool-binding.test.ts @@ -29,13 +29,36 @@ describe("browser tab tool binding", () => { }); }); - it("rejects route, tab, and browser-wide action escapes", () => { - expect(() => - applyBrowserTabToolBinding({ action: "snapshot", targetId: "target-b" }, binding), - ).toThrow("cannot override its run-bound tab target"); - expect(() => - applyBrowserTabToolBinding({ action: "snapshot", node: "other" }, binding), - ).toThrow("cannot override its run-bound node"); + it("pins page extraction to the trusted tab and browser route", () => { + expect( + applyBrowserTabToolBinding( + { action: "extract", query: "When does the release ship?" }, + binding, + ), + ).toEqual({ + action: "extract", + query: "When does the release ship?", + target: "node", + node: "desktop", + profile: "chrome", + targetId: "target-a", + }); + }); + + it("rejects page extraction route escapes and browser-wide actions", () => { + for (const [input, error] of [ + [{ targetId: "target-b" }, "cannot override its run-bound tab target"], + [{ profile: "other" }, "cannot override its run-bound profile"], + [{ node: "other" }, "cannot override its run-bound node"], + [{ target: "host" }, "cannot override its run-bound target"], + ] as const) { + expect(() => + applyBrowserTabToolBinding( + { action: "extract", query: "When does the release ship?", ...input }, + binding, + ), + ).toThrow(error); + } expect(() => applyBrowserTabToolBinding({ action: "open" }, binding)).toThrow( "unavailable in a tab-bound run", ); diff --git a/extensions/browser/src/browser-tool-binding.ts b/extensions/browser/src/browser-tool-binding.ts index 4c79f889a4c3..892b482ca20a 100644 --- a/extensions/browser/src/browser-tool-binding.ts +++ b/extensions/browser/src/browser-tool-binding.ts @@ -52,6 +52,7 @@ const TAB_BOUND_ACTIONS = new Set([ "console", "dialog", "download", + "extract", "focus", "navigate", "pdf", diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index d61662ad3ed2..272e6539ed73 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -3318,6 +3318,57 @@ describe("browser tool extract", () => { beforeEach(resetBrowserToolMocks); afterEach(() => vi.restoreAllMocks()); + const runToolBinding = { + kind: "tab" as const, + tabId: 17, + target: "host" as const, + profile: "openclaw", + targetId: "target-a", + }; + + it("extracts from the trusted tab in a tab-bound run", async () => { + const tool = createBrowserTool({ agentId: "work", runToolBinding }); + + const result = await tool.execute?.("call-bound-extract", { + action: "extract", + query: "When does the release ship?", + }); + + expect(browserActionsMocks.browserPageContent).toHaveBeenCalledWith(undefined, { + targetId: "target-a", + profile: "openclaw", + timeoutMs: 60_000, + signal: undefined, + }); + expect(toolCommonMocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "work", + useUtilityModel: true, + allowMissingApiKeyModes: ["aws-sdk"], + }), + ); + expect(result?.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("Friday."), + }); + }); + + it("rejects extraction from a foreign tab before browser or model access", async () => { + const tool = createBrowserTool({ agentId: "work", runToolBinding }); + + await expect( + tool.execute?.("call-bound-extract-escape", { + action: "extract", + query: "When does the release ship?", + targetId: "target-b", + }), + ).rejects.toThrow("cannot override its run-bound tab target"); + + expect(browserActionsMocks.browserPageContent).not.toHaveBeenCalled(); + expect(toolCommonMocks.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); + expect(toolCommonMocks.completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled(); + }); + it("captures, converts, and answers with the configured agent model", async () => { toolCommonMocks.sanitizeHtml.mockResolvedValueOnce("
Ships Friday.
"); toolCommonMocks.htmlToMarkdown.mockReturnValueOnce({ text: "Ships **Friday**." });