Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,21 @@ The plugin reuses Firecrawl's standard env vars — the **same ones the Firecraw
| `FIRECRAWL_PROFILE_NAME` | no | — | Persistent browser profile to load (cookies/localStorage/login state). |
| `FIRECRAWL_PROFILE_SAVE_CHANGES` | no | `true` | Save browser state back to the profile on close. Set `false`/`0` for read-only. |

\* Not required when `FIRECRAWL_API_URL` points at a self-hosted instance.
\* Not required if you've run `firecrawl login` (see below) or are pointing `FIRECRAWL_API_URL` at a self-hosted instance.

### Credential resolution

The plugin resolves its Firecrawl API key in this order:

1. **`FIRECRAWL_API_KEY` env var** (and `FIRECRAWL_API_URL`) — explicit, always wins.
2. **`firecrawl login` session** — if you've authenticated with the [Firecrawl CLI](https://github.com/firecrawl/cli) (browser or API key), the plugin reads the key it stored in `credentials.json` (`~/Library/Application Support/firecrawl-cli` on macOS, `~/.config/firecrawl-cli` on Linux, `%AppData%/firecrawl-cli` on Windows). No env var needed.
3. Otherwise it returns a clear error: *set `FIRECRAWL_API_KEY` or run `firecrawl login`*.

One key serves both the browser provider and the scrape/search/crawl/map commands.

### Two auth layers

- **API auth (plugin → Firecrawl):** the `FIRECRAWL_API_KEY` env var — same convention as the built-in Browserbase/Browserless/Kernel providers.
- **API auth (plugin → Firecrawl):** `FIRECRAWL_API_KEY` env var, or a reused `firecrawl login` session (see above) — same env-var convention as the built-in Browserbase/Browserless/Kernel providers.
- **Browser auth (session → target site):** Firecrawl **persistent profiles**. Set `FIRECRAWL_PROFILE_NAME` (or pass `profile` in the launch request) to log in once and reuse the session:

```bash
Expand Down
49 changes: 44 additions & 5 deletions bin/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,45 @@
* one JSON response to stdout, and exit 0. Nothing else may go to stdout.
*/

const fs = require("node:fs");
const os = require("node:os");
const nodePath = require("node:path");

const PROTOCOL = "agent-browser.plugin.v1";
const API_BASE = (process.env.FIRECRAWL_API_URL || "https://api.firecrawl.dev").replace(/\/+$/, "");
const API_KEY = process.env.FIRECRAWL_API_KEY || "";
const DEFAULT_API_BASE = "https://api.firecrawl.dev";

// Location where firecrawl-cli stores credentials after `firecrawl login`
// (mirrors cli/src/utils/credentials.ts). Lets a logged-in user skip the env var.
function cliConfigDir() {
const home = os.homedir();
switch (os.platform()) {
case "darwin":
return nodePath.join(home, "Library", "Application Support", "firecrawl-cli");
case "win32":
return nodePath.join(home, "AppData", "Roaming", "firecrawl-cli");
default:
return nodePath.join(home, ".config", "firecrawl-cli");
}
}

function loadCliCredentials() {
try {
const raw = fs.readFileSync(nodePath.join(cliConfigDir(), "credentials.json"), "utf8");
const c = JSON.parse(raw);
return c && typeof c === "object" ? c : null;
} catch {
return null; // missing/unreadable/corrupt -> no stored creds
}
}

// Resolve credentials: explicit env wins, then fall back to the `firecrawl login`
// session stored by the CLI. Same key serves browser.provider and command.run.
function resolveCredentials() {
const stored = loadCliCredentials() || {};
const apiKey = process.env.FIRECRAWL_API_KEY || stored.apiKey || "";
const apiUrl = process.env.FIRECRAWL_API_URL || stored.apiUrl || DEFAULT_API_BASE;
return { apiKey, apiBase: String(apiUrl).replace(/\/+$/, "") };
}

const CAPABILITIES = [
"browser.provider",
Expand Down Expand Up @@ -56,18 +92,21 @@ async function readStdin() {
}

async function api(method, path, body) {
if (!API_KEY) throw new Error("FIRECRAWL_API_KEY is not set");
const { apiKey, apiBase } = resolveCredentials();
if (!apiKey) {
throw new Error("No Firecrawl credentials: set FIRECRAWL_API_KEY or run `firecrawl login`");
}
// Bound the request so a stalled call can't block the plugin from emitting
// its single response. Overridable via FIRECRAWL_HTTP_TIMEOUT_MS.
const controller = new AbortController();
const timeoutMs = Number(process.env.FIRECRAWL_HTTP_TIMEOUT_MS) || 60000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(`${API_BASE}${path}`, {
res = await fetch(`${apiBase}${path}`, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
Authorization: `Bearer ${apiKey}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
Expand Down
57 changes: 56 additions & 1 deletion test/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,34 @@ const assert = require("node:assert");
const { spawn } = require("node:child_process");
const http = require("node:http");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");

const BIN = path.join(__dirname, "..", "bin", "plugin.js");
const PROTOCOL = "agent-browser.plugin.v1";

// Mirror the plugin's per-OS credential dir so we can plant a fake credentials.json.
function cliConfigDirFor(home) {
switch (process.platform) {
case "darwin":
return path.join(home, "Library", "Application Support", "firecrawl-cli");
case "win32":
return path.join(home, "AppData", "Roaming", "firecrawl-cli");
default:
return path.join(home, ".config", "firecrawl-cli");
}
}

let server;
let baseUrl;
let lastReq;
let emptyHome; // HOME with no stored creds
let credsHome; // HOME containing a firecrawl-cli credentials.json

before(async () => {
// Isolated HOMEs so tests never read the dev machine's real ~/.../credentials.json
emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "ab-empty-"));
credsHome = fs.mkdtempSync(path.join(os.tmpdir(), "ab-creds-"));
server = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
Expand Down Expand Up @@ -53,9 +72,21 @@ before(async () => {
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
baseUrl = `http://127.0.0.1:${server.address().port}`;

// Plant a CLI credentials.json (apiUrl -> mock) in credsHome.
const dir = cliConfigDirFor(credsHome);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, "credentials.json"),
JSON.stringify({ apiKey: "fc-stored", apiUrl: baseUrl })
);
});

after(() => server.close());
after(() => {
server.close();
fs.rmSync(emptyHome, { recursive: true, force: true });
fs.rmSync(credsHome, { recursive: true, force: true });
});

// Spawn the bin with a clean env (no inherited FIRECRAWL_* unless overridden).
function run(envelope, extraEnv = {}) {
Expand All @@ -68,6 +99,9 @@ function run(envelope, extraEnv = {}) {
]) {
delete env[k];
}
// Default to a creds-free HOME; a test can override via extraEnv.HOME.
env.HOME = emptyHome;
env.USERPROFILE = emptyHome;
Object.assign(env, extraEnv);
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [BIN], { env });
Expand Down Expand Up @@ -172,6 +206,27 @@ test("browser.launch fails if Firecrawl returns no session id", async () => {
assert.match(json.error, /session id/);
});

// --- credential resolution (env -> CLI credentials.json -> error) ---

test("falls back to firecrawl-cli credentials.json when no env key", async () => {
// no FIRECRAWL_API_KEY/URL; HOME points at the planted credentials.json
const { json } = await run(
{ protocol: PROTOCOL, type: "browser.launch", request: { launchOptions: {} } },
{ HOME: credsHome, USERPROFILE: credsHome }
);
assert.equal(json.success, true);
assert.equal(lastReq.auth, "Bearer fc-stored"); // key came from the file
});

test("env FIRECRAWL_API_KEY overrides stored CLI credentials", async () => {
const { json } = await run(
{ protocol: PROTOCOL, type: "browser.launch", request: { launchOptions: {} } },
{ HOME: credsHome, USERPROFILE: credsHome, FIRECRAWL_API_KEY: "fc-env", FIRECRAWL_API_URL: baseUrl }
);
assert.equal(json.success, true);
assert.equal(lastReq.auth, "Bearer fc-env"); // env wins over the file
});

test("browser.close issues DELETE for the session id", async () => {
const { json } = await run(
{ protocol: PROTOCOL, type: "browser.close", request: { sessionId: "sess-123" } },
Expand Down
Loading