Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cc723b5
browser: add "firecrawl" driver type
developersdigest Mar 3, 2026
8911c9d
browser: add firecrawl v2 session lifecycle
developersdigest Mar 3, 2026
fed9d44
browser: add firecrawl session state and context options
developersdigest Mar 3, 2026
d17bff4
browser: wire firecrawl into availability, reachability, and stop
developersdigest Mar 3, 2026
02e956c
browser: use runtime cdpUrl for firecrawl dynamic wss
developersdigest Mar 3, 2026
e790d0a
browser: surface firecrawl session in status api
developersdigest Mar 3, 2026
31433d0
browser: wire firecrawl api key through all callsites
developersdigest Mar 3, 2026
2fa5573
web-fetch: export firecrawl config resolvers
developersdigest Mar 3, 2026
427f1fd
firecrawl: add dedicated search and scrape tools
developersdigest Mar 3, 2026
d503082
onboarding: add firecrawl oauth setup step
developersdigest Mar 3, 2026
52c1263
config: add firecrawl zod schema
developersdigest Mar 3, 2026
41caede
fix: preserve firecrawl dynamic cdpUrl across config refresh and rout…
developersdigest Mar 3, 2026
52b3690
fix: handle v2 search response format (data.web nested array)
developersdigest Mar 3, 2026
cc2a0e0
browser: surface both liveViewUrl and interactiveLiveViewUrl
developersdigest Mar 3, 2026
56ab1ab
fix: use runtime cdpUrl in focus/close/listProfiles for firecrawl ses…
developersdigest Mar 3, 2026
92a84d4
Integrate Firecrawl tools and update auth URL
developersdigest Mar 3, 2026
e0ed10d
test: simplify firecrawl onboard tests, add alsoAllow coverage
developersdigest Mar 3, 2026
9468d12
fix: validate API key from browser auth before persisting
developersdigest Mar 3, 2026
b1e1e3a
fix: add 10s timeout to auth polling fetch
developersdigest Mar 3, 2026
9702f09
Add firecrawl driver and dynamic API key
developersdigest Mar 3, 2026
f4e841f
fix: re-resolve firecrawl API key from config, preserve firecrawl driver
developersdigest Mar 3, 2026
459b162
fix: inline firecrawl key resolution to avoid web-fetch import chain
developersdigest Mar 3, 2026
c7ddaee
fix: address remaining review feedback
developersdigest Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/agents/openclaw-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSubagentsTool } from "./tools/subagents-tool.js";
import { createTtsTool } from "./tools/tts-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import {
createFirecrawlScrapeTool,
createFirecrawlSearchTool,
createWebFetchTool,
createWebSearchTool,
} from "./tools/web-tools.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";

export function createOpenClawTools(options?: {
Expand Down Expand Up @@ -107,6 +112,8 @@ export function createOpenClawTools(options?: {
config: options?.config,
sandboxed: options?.sandboxed,
});
const firecrawlSearchTool = createFirecrawlSearchTool({ config: options?.config });
const firecrawlScrapeTool = createFirecrawlScrapeTool({ config: options?.config });
const messageTool = options?.disableMessageTool
? null
: createMessageTool({
Expand Down Expand Up @@ -187,6 +194,8 @@ export function createOpenClawTools(options?: {
}),
...(webSearchTool ? [webSearchTool] : []),
...(webFetchTool ? [webFetchTool] : []),
...(firecrawlSearchTool ? [firecrawlSearchTool] : []),
...(firecrawlScrapeTool ? [firecrawlScrapeTool] : []),
...(imageTool ? [imageTool] : []),
...(pdfTool ? [pdfTool] : []),
];
Expand Down
260 changes: 260 additions & 0 deletions src/agents/tools/firecrawl-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createFirecrawlScrapeTool, createFirecrawlSearchTool } from "./firecrawl-tools.js";

function installMockFetch(payload: unknown) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
status: 200,
statusText: "OK",
json: () => Promise.resolve(payload),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}

function configWithApiKey(apiKey: string) {
return {
config: {
tools: {
web: {
fetch: {
firecrawl: { apiKey },
},
},
},
},
};
}

describe("firecrawl_search tool", () => {
const priorFetch = global.fetch;

afterEach(() => {
vi.unstubAllEnvs();
global.fetch = priorFetch;
});

it("returns null when no Firecrawl API key is present", () => {
vi.stubEnv("FIRECRAWL_API_KEY", "");
const tool = createFirecrawlSearchTool({ config: {} });
expect(tool).toBeNull();
});

it("returns a tool when config API key is present", () => {
const tool = createFirecrawlSearchTool(configWithApiKey("fc-test-key"));
expect(tool).not.toBeNull();
expect(tool?.name).toBe("firecrawl_search");
});

it("returns a tool when FIRECRAWL_API_KEY env var is set", () => {
vi.stubEnv("FIRECRAWL_API_KEY", "fc-env-key");
const tool = createFirecrawlSearchTool({ config: {} });
expect(tool).not.toBeNull();
expect(tool?.name).toBe("firecrawl_search");
});

it("calls POST /v2/search with correct payload", async () => {
const mockFetch = installMockFetch({
success: true,
data: [
{
title: "Example",
url: "https://example.com",
description: "An example site",
},
],
});
const tool = createFirecrawlSearchTool(configWithApiKey("fc-test-key"));
await tool?.execute?.("call-1", { query: "test query", limit: 3 });

expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://api.firecrawl.dev/v2/search");
expect(init.method).toBe("POST");
expect(init.headers).toMatchObject({
Authorization: "Bearer fc-test-key",
"Content-Type": "application/json",
});
const body = JSON.parse(init.body as string) as Record<string, unknown>;
expect(body.query).toBe("test query");
expect(body.limit).toBe(3);
});

it("uses default limit of 5", async () => {
const mockFetch = installMockFetch({ success: true, data: [] });
const tool = createFirecrawlSearchTool(configWithApiKey("fc-test-key"));
await tool?.execute?.("call-1", { query: "test" });

const body = JSON.parse(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
) as Record<string, unknown>;
expect(body.limit).toBe(5);
});

it("clamps limit to 20", async () => {
const mockFetch = installMockFetch({ success: true, data: [] });
const tool = createFirecrawlSearchTool(configWithApiKey("fc-test-key"));
await tool?.execute?.("call-1", { query: "test", limit: 50 });

const body = JSON.parse(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
) as Record<string, unknown>;
expect(body.limit).toBe(20);
});

it("wraps result descriptions but keeps URLs raw", async () => {
installMockFetch({
success: true,
data: [
{
title: "Test Title",
url: "https://example.com/page",
description: "Test description",
},
],
});
const tool = createFirecrawlSearchTool(configWithApiKey("fc-test-key"));
const result = await tool?.execute?.("call-1", { query: "test" });
const details = result?.details as {
results?: Array<{ title?: string; url?: string; description?: string }>;
externalContent?: { untrusted?: boolean; wrapped?: boolean };
};

// URL should be raw for tool chaining
expect(details.results?.[0]?.url).toBe("https://example.com/page");
// Title and description should be wrapped
expect(details.results?.[0]?.title).toMatch(
/<<<EXTERNAL_UNTRUSTED_CONTENT id="[a-f0-9]{16}">>>/,
);
expect(details.results?.[0]?.description).toMatch(
/<<<EXTERNAL_UNTRUSTED_CONTENT id="[a-f0-9]{16}">>>/,
);
expect(details.externalContent).toMatchObject({
untrusted: true,
wrapped: true,
});
});

it("handles v2 nested data.web format", async () => {
installMockFetch({
success: true,
data: {
web: [
{
title: "V2 Result",
url: "https://example.com/v2",
description: "From v2 API",
},
],
},
});
const tool = createFirecrawlSearchTool(configWithApiKey("fc-test-key"));
const result = await tool?.execute?.("call-1", { query: "test" });
const details = result?.details as {
results?: Array<{ url?: string }>;
};
expect(details.results).toHaveLength(1);
expect(details.results?.[0]?.url).toBe("https://example.com/v2");
});

it("throws on API error", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 401,
statusText: "Unauthorized",
json: () => Promise.resolve({ success: false, error: "Invalid API key" }),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);

const tool = createFirecrawlSearchTool(configWithApiKey("fc-bad-key"));
await expect(tool?.execute?.("call-1", { query: "test" })).rejects.toThrow(
/Firecrawl search failed \(401\)/,
);
});
});

describe("firecrawl_scrape tool", () => {
const priorFetch = global.fetch;

afterEach(() => {
vi.unstubAllEnvs();
global.fetch = priorFetch;
});

it("returns null when no Firecrawl API key is present", () => {
vi.stubEnv("FIRECRAWL_API_KEY", "");
const tool = createFirecrawlScrapeTool({ config: {} });
expect(tool).toBeNull();
});

it("returns a tool when config API key is present", () => {
const tool = createFirecrawlScrapeTool(configWithApiKey("fc-test-key"));
expect(tool).not.toBeNull();
expect(tool?.name).toBe("firecrawl_scrape");
});

it("calls fetchFirecrawlContent via the Firecrawl scrape API", async () => {
const mockFetch = installMockFetch({
success: true,
data: {
markdown: "# Hello World\n\nSome content here.",
metadata: {
title: "Hello World",
sourceURL: "https://example.com/hello",
statusCode: 200,
},
},
});

const tool = createFirecrawlScrapeTool(configWithApiKey("fc-test-key"));
const result = await tool?.execute?.("call-1", { url: "https://example.com/hello" });

expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/v2/scrape");
expect(init.headers).toMatchObject({
Authorization: "Bearer fc-test-key",
});

const details = result?.details as {
url?: string;
title?: string;
text?: string;
truncated?: boolean;
externalContent?: { untrusted?: boolean; wrapped?: boolean };
};
expect(details.url).toBe("https://example.com/hello");
expect(details.text).toContain("Hello World");
expect(details.text).toMatch(/<<<EXTERNAL_UNTRUSTED_CONTENT id="[a-f0-9]{16}">>>/);
expect(details.truncated).toBe(false);
expect(details.externalContent).toMatchObject({
untrusted: true,
wrapped: true,
});
});

it("truncates content when maxChars is specified", async () => {
const longContent = "x".repeat(1000);
installMockFetch({
success: true,
data: {
markdown: longContent,
metadata: { title: "Long", statusCode: 200 },
},
});

const tool = createFirecrawlScrapeTool(configWithApiKey("fc-test-key"));
const result = await tool?.execute?.("call-1", {
url: "https://example.com",
maxChars: 200,
});

const details = result?.details as { truncated?: boolean };
expect(details.truncated).toBe(true);
});
});
Loading