Skip to content

Commit a237c77

Browse files
authored
feat(eve): publish native Browserbase extension with session cleanup (#2821)
# why Eve agents need an installable Browserbase extension with persistent browser state and predictable cleanup. This adds the native Stagehand V4 integration as `@browserbasehq/eve` 0.2. # what changed - Add `browserbase__run`, `browserbase__snapshot`, and `browserbase__screenshot` through Eve's native extension API. - Share one Browserbase session across calls, recover unhealthy sessions, and release owned resources on explicit close. The next call can start a fresh session. - Bundle the shared browser facade, including the cleanup hook and sanitized errors used by Eve's session owner, and add package build, release, and Changeset configuration. - Support Eve 0.68.x, resolve environment credentials when the browser starts, and inherit Browserbase proxy defaults unless explicitly overridden. The [build script](https://github.com/browserbase/stagehand/blob/ab25295e96476ee3c13c19a8e936348491da0e04/packages/integrations/eve/scripts/stage-facade.mjs) copies four shared Core files byte-for-byte into Eve's generated, gitignored source directory. `runtime.ts`, `contract.ts`, and `redact.ts` are unchanged from the PR base. `tools.ts` adds the host cleanup hook and error handling; the Eve tool wrappers adapt the existing Stagehand example. The [previous implementation in browserbase/integrations](https://github.com/browserbase/integrations/tree/b7bc81c6fd4e089ab0c0df2b82529b200739e458/packages/eve-browserbase) already uses Stagehand V4. This PR keeps its npm package identity but changes the tool API and session lifecycle; none of the 17 tracked Eve package files is byte-identical to that implementation. The main implementation review areas are session ownership/recovery, the shared cleanup/error changes, and packaging/release wiring. Eve's built-in `web_search` and `web_fetch` remain available; [#2822](#2822) adds optional Browserbase replacements. The separate MCP lifecycle change is in [#3078](#3078); this native extension does not depend on it. Documentation is in [#3079](#3079). Merge that PR before publishing 0.2 so the npm package includes the updated README. # test plan Checked after the `main` update at [`ab25295e9`](ab25295). Refreshed against current `main`, preserving upstream dependencies and Eve’s named catalog. The URL-matching test now resolves Playwright Core through its declared Playwright dependency, so the new examples’ MCP dependency cannot change which version it tests. | Command / flow | Observed output | Coverage | | --- | --- | --- | | Shared facade build, typecheck and unit tests; Pi build/typecheck/tests | 136 facade tests and 3 Pi tests passed | Shared hook/error changes and existing native consumer | | `CHROME_PATH=<Chrome binary> pnpm --filter @browserbasehq/stagehand-integrations test:browser` | 6 tests passed | Browser compatibility against local fixtures | | Eve package typecheck and `test:unit` | Typecheck passed; 39 tests passed | Native session ownership, credentials and tool behavior | | Fresh tarball, strict `publint`, external Eve 0.68.0 consumer | All passed; `eve build` passed with `BROWSERBASE_API_KEY` unset | Installable package and credential-free builds | | Installed SDK and browser extension compared with fresh local build | Byte-identical; Eve resolves the same SDK | Smoke tests cover the SDK and browser extension from this branch | | Packed lifecycle probe and real `eve eval --strict --skip-report --timeout 300000` | 8/8 gates passed; persistent state, error recovery, snapshot-ID navigation, 193,444-byte PNG and close/reopen passed | Native tools with real Browserbase sessions on public Example Domain/IANA pages | | Independent session audit | All 5 sessions `COMPLETED`, including the preliminary harness run; no fallback release needed | Owned-session cleanup |
1 parent 5788121 commit a237c77

34 files changed

Lines changed: 2075 additions & 639 deletions

‎.changeset/gentle-eagles-browse.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/eve": minor
3+
---
4+
5+
Replace the Eve extension's browser and web tools with Stagehand V4 `run`, `snapshot`, and `screenshot`.

‎justfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ _preview commit:
9999

100100
_publish-typescript:
101101
pnpm --filter ./packages/sdk-ts build
102+
pnpm --filter @browserbasehq/eve build
102103
pnpm exec tsx scripts/release/scoped-release.ts publish sdk
103104

104105
# Publishes a commit-addressed alpha of the TypeScript SDK (`<next>-alpha-<sha>`)

‎packages/integrations/core/src/facade/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export {
3636
type CodeModeRunInput,
3737
} from "./contract.js";
3838
export {
39+
StagehandFacadeCleanupError,
40+
StagehandFacadeExecutionError,
41+
StagehandFacadeInputError,
3942
StagehandFacadeTools,
4043
type StagehandFacadeRunReport,
4144
type StagehandFacadeToolsOptions,

‎packages/integrations/core/src/facade/tools.ts‎

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,36 @@ export type StagehandFacadeRunReport = {
3535
closeRequested: boolean;
3636
};
3737

38+
export class StagehandFacadeCleanupError extends Error {
39+
override readonly name = "StagehandFacadeCleanupError";
40+
41+
constructor() {
42+
super("Failed to close the Stagehand browser session.");
43+
}
44+
}
45+
46+
export class StagehandFacadeInputError extends Error {
47+
override readonly name = "StagehandFacadeInputError";
48+
49+
constructor(message: string) {
50+
super(sanitizeErrorMessage(message));
51+
}
52+
}
53+
54+
export class StagehandFacadeExecutionError extends Error {
55+
readonly facadeExecutionError = true;
56+
57+
constructor(error: { name: string; message: string }) {
58+
super(sanitizeErrorMessage(error.message));
59+
const name = sanitizeErrorMessage(error.name);
60+
this.name = /^[A-Za-z][A-Za-z0-9]*Error$/u.test(name) ? name : "Error";
61+
this.stack = undefined;
62+
}
63+
}
64+
3865
export type StagehandFacadeToolsOptions = {
66+
/** Replaces default cleanup when the host owns session release and replacement. */
67+
onCloseRequested?: () => Promise<void>;
3968
/** Owned directory for screenshot artifacts; paths must stay within it. Defaults to process.cwd(). */
4069
artifactRoot?: string;
4170
/** Observes every completed `run` batch (including ones whose code threw). */
@@ -142,19 +171,23 @@ export class StagehandFacadeTools {
142171
close(): Promise<void> {
143172
this.closed = true;
144173
return (this.closePromise ??= (async () => {
145-
const failures: unknown[] = [];
174+
if (this.options.onCloseRequested) {
175+
try {
176+
await this.options.onCloseRequested();
177+
} catch {
178+
throw new StagehandFacadeCleanupError();
179+
}
180+
return;
181+
}
182+
let failed = false;
146183
for (const close of [() => this.stagehand.close(), () => this.stagehand.browser.close()]) {
147184
try {
148185
await close();
149-
} catch (error) {
150-
failures.push(error);
186+
} catch {
187+
failed = true;
151188
}
152189
}
153-
if (failures.length) {
154-
throw new Error("Failed to close the Stagehand facade browser.", {
155-
cause: new AggregateError(failures, "Facade browser cleanup failures"),
156-
});
157-
}
190+
if (failed) throw new StagehandFacadeCleanupError();
158191
})());
159192
}
160193

@@ -211,16 +244,16 @@ export class StagehandFacadeTools {
211244
const parsed = RefActionSchema.array().min(1).parse(actions);
212245
const page = await this.activePage();
213246
const snapshot = this.snapshotsByPage.get(page.pageId);
214-
if (!snapshot) throw new Error(NO_HYDRATED_SNAPSHOT_ERROR);
247+
if (!snapshot) throw new StagehandFacadeInputError(NO_HYDRATED_SNAPSHOT_ERROR);
215248

216249
if ((await page.url()) !== snapshot.url) {
217250
this.snapshotsByPage.delete(page.pageId);
218-
throw new Error(NAVIGATED_SNAPSHOT_ERROR);
251+
throw new StagehandFacadeInputError(NAVIGATED_SNAPSHOT_ERROR);
219252
}
220253

221254
const hydrated = parsed.map((action) => {
222255
const xpath = trimTrailingTextNode(resolveSnapshotXPath(snapshot.xpathById, action.id));
223-
if (!xpath) throw new Error(staleSnapshotIdError(action.id));
256+
if (!xpath) throw new StagehandFacadeInputError(staleSnapshotIdError(action.id));
224257
return { ...action, selector: `xpath=${xpath}` };
225258
});
226259
const result = await this.stagehand.experimentalBatch(
@@ -242,6 +275,8 @@ export class StagehandFacadeTools {
242275
const keeperPageId = await this.keeper;
243276
const input: RunInput = keeperPageId ? { hiddenPageIds: [keeperPageId] } : {};
244277
const envelope = await this.runBatchWithActivePageFallback(callback, input, page);
278+
let runError: unknown;
279+
let runFailed = false;
245280
try {
246281
this.options.onRunReport?.({
247282
telemetry: envelope.telemetry,
@@ -253,20 +288,26 @@ export class StagehandFacadeTools {
253288
if (envelope.executionError) {
254289
// Thrown by the agent's own code inside the browser, so its message can
255290
// never be evidence about this process's connection to the browser.
256-
const error = new Error(envelope.executionError.message) as Error & {
257-
facadeExecutionError: true;
258-
};
259-
error.name = envelope.executionError.name;
260-
if (envelope.executionError.stack) error.stack = envelope.executionError.stack;
261-
error.facadeExecutionError = true;
262-
throw error;
291+
throw new StagehandFacadeExecutionError(envelope.executionError);
292+
}
293+
} catch (error) {
294+
runError = error;
295+
runFailed = true;
296+
}
297+
// Wait until the batch finishes before closing its transport, even when
298+
// agent code, telemetry, or artifact persistence failed.
299+
if (envelope.closeRequested) {
300+
try {
301+
await this.close();
302+
} catch (closeError) {
303+
if (runFailed) {
304+
throw new AggregateError([runError, closeError], "Run failed and cleanup also failed.");
305+
}
306+
throw closeError;
263307
}
264-
return envelope.value;
265-
} finally {
266-
// The batch must finish before closing its own transport. This also
267-
// honors close requests when agent code or artifact persistence failed.
268-
if (envelope.closeRequested) await this.close();
269308
}
309+
if (runFailed) throw runError;
310+
return envelope.value;
270311
}
271312

272313
/**
@@ -311,7 +352,9 @@ export class StagehandFacadeTools {
311352
relative.startsWith(`..${path.sep}`) ||
312353
path.isAbsolute(relative)
313354
) {
314-
throw new Error("Screenshot artifact path must stay within artifactRoot.");
355+
throw new StagehandFacadeInputError(
356+
"Screenshot artifact path must stay within artifactRoot.",
357+
);
315358
}
316359
// Reject symlink traversal before creating nested directories or opening
317360
// the output. O_NOFOLLOW also refuses an existing symlink at the file.
@@ -323,7 +366,9 @@ export class StagehandFacadeTools {
323366
if (error.code !== "EEXIST") throw error;
324367
});
325368
if (!(await fsp.lstat(directory)).isDirectory()) {
326-
throw new Error("Screenshot artifact directory must not be a symlink.");
369+
throw new StagehandFacadeInputError(
370+
"Screenshot artifact directory must not be a symlink.",
371+
);
327372
}
328373
}
329374
const file = await fsp.open(
@@ -384,7 +429,7 @@ export class StagehandFacadeTools {
384429

385430
private enqueue<Result>(_tool: string, operation: () => Promise<Result>): Promise<Result> {
386431
const execute = async (): Promise<Result> => {
387-
if (this.closed) throw new Error("Stagehand facade browser is closed.");
432+
if (this.closed) throw new StagehandFacadeInputError("Stagehand facade browser is closed.");
388433
await this.ensureKeeperPage();
389434
return operation();
390435
};

‎packages/integrations/core/tests/facade-tools.test.ts‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import os from "node:os";
33
import path from "node:path";
44
import { type Stagehand } from "@browserbasehq/stagehand";
55
import { afterEach, describe, expect, it, vi } from "vitest";
6-
import { StagehandFacadeTools, type StagehandFacadeRunReport } from "../src/facade/tools.js";
6+
import {
7+
StagehandFacadeCleanupError,
8+
StagehandFacadeTools,
9+
type StagehandFacadeRunReport,
10+
} from "../src/facade/tools.js";
711

812
type FakePage = ReturnType<typeof createFakePage>;
913

@@ -253,9 +257,10 @@ describe("StagehandFacadeTools.run (Playwright batch surface)", () => {
253257
const { stagehand } = createFakeStagehand(createFakePage());
254258
vi.mocked(stagehand.close).mockRejectedValue(new Error("apiKey=private-key"));
255259
const tools = new StagehandFacadeTools(stagehand);
256-
await expect(tools.run(`await browser.close();`)).rejects.toThrow(
257-
"Failed to close the Stagehand facade browser.",
258-
);
260+
const error = await tools.run(`await browser.close();`).catch((error: unknown) => error);
261+
expect(error).toBeInstanceOf(StagehandFacadeCleanupError);
262+
expect((error as Error).cause).toBeUndefined();
263+
expect(String(error)).not.toContain("private-key");
259264
expect(stagehand.browser.close).toHaveBeenCalledOnce();
260265
await expect(tools.run("return 1")).rejects.toThrow("browser is closed");
261266
});
@@ -269,6 +274,26 @@ describe("StagehandFacadeTools.run (Playwright batch surface)", () => {
269274
expect(stagehand.browser.close).toHaveBeenCalledOnce();
270275
});
271276

277+
it.each([false, true])(
278+
"sanitizes default browser cleanup errors (execution failure: %s)",
279+
async (executionFails) => {
280+
const { stagehand } = createFakeStagehand(createFakePage());
281+
vi.mocked(stagehand.browser.close).mockRejectedValue(new Error("provider-private-detail"));
282+
const tools = new StagehandFacadeTools(stagehand);
283+
const code = `await browser.close(); ${executionFails ? 'throw new Error("agent failure");' : ""}`;
284+
const error = await tools.run(code).catch((error: unknown) => error);
285+
const cleanupError = error instanceof AggregateError ? error.errors[1] : error;
286+
287+
expect(cleanupError).toBeInstanceOf(StagehandFacadeCleanupError);
288+
expect(cleanupError.cause).toBeUndefined();
289+
expect(String(cleanupError)).not.toContain("provider-private-detail");
290+
if (executionFails) {
291+
expect(error).toBeInstanceOf(AggregateError);
292+
expect((error as AggregateError).errors[0]).toMatchObject({ message: "agent failure" });
293+
}
294+
},
295+
);
296+
272297
it("confines artifacts across traversal, absolute paths, and symlinks", async () => {
273298
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "facade-paths-"));
274299
tempDirs.push(root);
@@ -640,3 +665,62 @@ describe("StagehandFacadeTools.run frameLocator", () => {
640665
);
641666
});
642667
});
668+
669+
describe("host-owned facade cleanup", () => {
670+
it("awaits the host once and leaves browser ownership with it", async () => {
671+
const { stagehand } = createFakeStagehand(createFakePage());
672+
let finishCleanup!: () => void;
673+
const cleanup = new Promise<void>((resolve) => {
674+
finishCleanup = resolve;
675+
});
676+
const onCloseRequested = vi.fn(() => cleanup);
677+
const tools = new StagehandFacadeTools(stagehand, { onCloseRequested });
678+
let finished = false;
679+
const run = tools.run('await browser.close(); return "closed";').then((result) => {
680+
finished = true;
681+
return result;
682+
});
683+
await vi.waitFor(() => expect(onCloseRequested).toHaveBeenCalledOnce());
684+
expect(finished).toBe(false);
685+
expect(stagehand.close).not.toHaveBeenCalled();
686+
expect(stagehand.browser.close).not.toHaveBeenCalled();
687+
finishCleanup();
688+
await expect(run).resolves.toBe("closed");
689+
await tools.close();
690+
expect(onCloseRequested).toHaveBeenCalledOnce();
691+
});
692+
693+
it("preserves the model error and a sanitized cleanup failure", async () => {
694+
const { stagehand } = createFakeStagehand(createFakePage());
695+
const tools = new StagehandFacadeTools(stagehand, {
696+
onCloseRequested: async () => {
697+
throw new Error("release bb_live_1234secret failed");
698+
},
699+
});
700+
const error = await tools
701+
.run('await browser.close(); throw new TypeError("model sk-123456secret failed");')
702+
.catch((error: unknown) => error);
703+
expect(error).toBeInstanceOf(AggregateError);
704+
if (!(error instanceof AggregateError)) throw new Error("Expected aggregate failure");
705+
expect(error.errors[0]).toMatchObject({
706+
name: "TypeError",
707+
message: "model sk-123456[redacted] failed",
708+
facadeExecutionError: true,
709+
stack: undefined,
710+
});
711+
expect(error.errors[1]).toBeInstanceOf(StagehandFacadeCleanupError);
712+
expect(error.errors.map(String).join(" ")).not.toContain("secret");
713+
});
714+
715+
it("sanitizes a model-authored error name without treating it as session loss", async () => {
716+
const { stagehand } = createFakeStagehand(createFakePage());
717+
const tools = new StagehandFacadeTools(stagehand);
718+
const error = await tools
719+
.run(
720+
'const error = new Error("model failure"); error.name = "Bearer abcdefghijkl"; throw error;',
721+
)
722+
.catch((error: unknown) => error);
723+
expect(error).toMatchObject({ name: "Error", facadeExecutionError: true, stack: undefined });
724+
expect(stagehand.browser.close).not.toHaveBeenCalled();
725+
});
726+
});

‎packages/integrations/core/tests/facade-wait-for-url.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import path from "node:path";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { createPlaywrightCompatRuntime } from "../src/facade/runtime.js";
55

6-
const playwrightRequire = createRequire(import.meta.url);
6+
const playwrightRequire = createRequire(
7+
createRequire(import.meta.url).resolve("playwright/package.json"),
8+
);
79
const playwrightCore = path.dirname(playwrightRequire.resolve("playwright-core/package.json"));
810
const { urlMatches } = playwrightRequire(
911
path.join(playwrightCore, "lib/utils/isomorphic/urlMatch.js"),
Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,12 @@
1-
.eve/
2-
.output/
1+
node_modules
2+
.env
3+
.env.*
4+
!.env.example
5+
.eve
6+
.vercel
7+
.output
8+
.nitro
9+
dist
10+
extension/lib/core
11+
.DS_Store
12+
*.tsbuildinfo

‎packages/integrations/eve/agent/agent.ts‎

Lines changed: 0 additions & 9 deletions
This file was deleted.

‎packages/integrations/eve/agent/instructions.md‎

Lines changed: 0 additions & 10 deletions
This file was deleted.

‎packages/integrations/eve/agent/tools/snapshot.ts‎

Lines changed: 0 additions & 23 deletions
This file was deleted.

0 commit comments

Comments
 (0)