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
5 changes: 5 additions & 0 deletions .changeset/cdp-connection-closed-socket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

`CdpConnection.send()` now rejects straight away when the socket is already closed, instead of leaving the command pending until it times out.
2 changes: 1 addition & 1 deletion design/browser-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Because `disposeExecution` fires only on terminal transitions — never on pause

### Pauses, replay, and attach handles

CDP session ids from `Target.attachToTarget` are scoped to one WebSocket connection, but the runtime's log must replay across reconnects. The connector therefore returns stable **attach handles** (`target:<targetId>`) instead of raw session ids and transparently re-attaches when a handle is used on a new connection — resumed code keeps working without knowing the socket changed.
CDP session ids from `Target.attachToTarget` are scoped to one WebSocket connection, but the runtime's log must replay across reconnects. The connector therefore returns stable **attach handles** (`target:<targetId>`) instead of raw session ids and transparently re-attaches when a handle is used on a new connection — resumed code keeps working without knowing the socket changed. In the persistent-browser connector, a raw `Target.attachToTarget` sent through `cdp.send` still returns Chrome's own id, which only lasts for that run; `Target.detachFromTarget` also accepts a handle.

If Browser Rendering expires a session while a pause waits for a human, the resume surfaces a model-facing error ("browser session expired while awaiting approval") and cleans up the stale store entry.

Expand Down
219 changes: 219 additions & 0 deletions packages/agents/src/browser-tests/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ type StoredSessions = Record<
{ sessionId: string; createdAt: number; updatedAt: number }
>;

interface NamedRunOutput extends RunOutput {
report: {
restarted: boolean;
newTabs: Array<{ targetId: string; url?: string }>;
} | null;
}

/** Run code against the persistent named browser. */
async function runNamed(code: string): Promise<NamedRunOutput> {
return (await callAgent("runNamed", [code])) as NamedRunOutput;
}

async function run(
code: string,
mode: "one-shot" | "reuse" | "dynamic" = "one-shot"
Expand Down Expand Up @@ -589,4 +601,211 @@ describe("browser connector e2e", () => {
await callAgent("closeSession", ["reuse"]);
});
});
// The persistent named browser behind BrowserSessionConnector. These run
// against real Chrome, so they also check what the unit-test fake assumes.
describe("persistent named browser", () => {
afterAll(async () => {
await callAgent("closeNamed");
});

const setTitle = (title: string) => `async () => {
await cdp.send({
method: "Runtime.evaluate",
params: { expression: "document.title = '${title}'" },
sessionId: "active"
});
}`;
const readTitle = `async () => {
const { result } = await cdp.send({
method: "Runtime.evaluate",
params: { expression: "document.title", returnByValue: true },
sessionId: "active"
});
return result.value;
}`;

it("keeps the active tab's page between executions", async () => {
const first = await runNamed(setTitle("kept"));
expect(first.status).toBe("completed");

const second = await runNamed(readTitle);
expect(second.status).toBe("completed");
expect(second.result).toBe("kept");
expect(second.report?.restarted).toBe(false);
});

it("reports a popup as a new tab without switching to it", async () => {
await runNamed(setTitle("opener"));
const output = await runNamed(`async () => {
await cdp.send({
method: "Runtime.evaluate",
params: { expression: "void window.open('about:blank', '_blank')", userGesture: true },
sessionId: "active"
});
}`);
expect(output.status).toBe("completed");
expect(output.report?.newTabs).toHaveLength(1);

const title = await runNamed(readTitle);
expect(title.result).toBe("opener");
});

it("reports noopener popups as new tabs too", async () => {
await runNamed(setTitle("noopener-opener"));
// Chrome still sets openerId on these; only canAccessOpener is false.
const output = await runNamed(`async () => {
const open = (expression) => cdp.send({
method: "Runtime.evaluate",
params: { expression, userGesture: true },
sessionId: "active"
});
await open("void window.open('about:blank#noopener', '_blank', 'noopener')");
await open("(() => { const a = document.createElement('a'); a.href = 'about:blank#rel-noopener'; a.target = '_blank'; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); })()");
}`);
expect(output.status).toBe("completed");
const urls = (output.report?.newTabs ?? []).map((tab) => tab.url);
expect(urls).toEqual(
expect.arrayContaining([
"about:blank#noopener",
"about:blank#rel-noopener"
])
);
});

it("keeps the active tab across a pause for approval", async () => {
const output = await runNamed(`async () => {
await cdp.send({
method: "Runtime.evaluate",
params: { expression: "document.title = 'paused'" },
sessionId: "active"
});
await gate.confirm({ label: "continue" });
const { result } = await cdp.send({
method: "Runtime.evaluate",
params: { expression: "document.title", returnByValue: true },
sessionId: "active"
});
return result.value;
}`);
expect(output.status).toBe("paused");

const resumed = (await callAgent("approveNamed", [
output.executionId
])) as RunOutput;
expect(resumed.status).toBe("completed");
expect(resumed.result).toBe("paused");
});

it("passes a raw Target.attachToTarget through to Chrome", async () => {
await runNamed(setTitle("raw-attach"));
const output = await runNamed(`async () => {
const { targetInfos } = await cdp.send({ method: "Target.getTargets" });
const page = targetInfos.find((t) => t.type === "page" && t.title === "raw-attach");
const { sessionId } = await cdp.send({
method: "Target.attachToTarget",
params: { targetId: page.targetId, flatten: true }
});
const { result } = await cdp.send({
method: "Runtime.evaluate",
params: { expression: "document.title", returnByValue: true },
sessionId
});
return { sessionId, title: result.value };
}`);
expect(output.status).toBe("completed");
const { sessionId, title } = output.result as {
sessionId: string;
title: string;
};
expect(sessionId).not.toMatch(/^target:/);
expect(title).toBe("raw-attach");

const later = await runNamed(`async () => {
await cdp.send({
method: "Runtime.evaluate",
params: { expression: "1" },
sessionId: "${sessionId}"
});
}`);
expect(later.status).toBe("error");
expect(later.error).toContain("don't carry over between runs");
});

it("doesn't report a tab opened with Target.createTarget as a popup", async () => {
const output = await runNamed(`async () => {
const { targetId } = await cdp.send({
method: "Target.createTarget",
params: { url: "about:blank" }
});
return targetId;
}`);
expect(output.status).toBe("completed");
expect(output.report?.newTabs).toEqual([]);
});

it('teaches sessionId: "active" for a page command sent without one', async () => {
const output = await runNamed(`async () => {
await cdp.send({ method: "Runtime.evaluate", params: { expression: "1" } });
}`);
expect(output.status).toBe("error");
expect(output.error).toContain('sessionId: "active"');
});

it("explains that a CDP event cannot be sent, using the live spec", async () => {
const output = await runNamed(`async () => {
await cdp.send({ method: "Page.loadEventFired" });
}`);
expect(output.status).toBe("error");
expect(output.error).toContain("CDP *event*");
});

it("reattaches after the model detaches from the active tab", async () => {
await runNamed(setTitle("detached"));
const output = await runNamed(`async () => {
const { targetInfos } = await cdp.send({ method: "Target.getTargets" });
await cdp.send({
method: "Runtime.evaluate",
params: { expression: "1" },
sessionId: "active"
});
const page = targetInfos.find((t) => t.type === "page" && t.title === "detached");
await cdp.send({
method: "Target.detachFromTarget",
params: { targetId: page.targetId }
});
const { result } = await cdp.send({
method: "Runtime.evaluate",
params: { expression: "document.title", returnByValue: true },
sessionId: "active"
});
return result.value;
}`);
expect(output.status).toBe("completed");
expect(output.result).toBe("detached");
});

it("refuses to close the browser", async () => {
await runNamed(setTitle("survivor"));
const output = await runNamed(`async () => {
await cdp.send({ method: "Browser.close" });
}`);
expect(output.status).toBe("error");
expect(output.error).toContain("managed for you");

const after = await runNamed(readTitle);
expect(after.result).toBe("survivor");
expect(after.report?.restarted).toBe(false);
});

it("reports restarted when the browser was replaced", async () => {
await runNamed(setTitle("lost"));
const key = (await callAgent("namedSessionKey")) as string;
await callAgent("corruptStoredSession", [key]);

const output = await runNamed(readTitle);
expect(output.status).toBe("completed");
expect(output.report?.restarted).toBe(true);
expect(output.result).not.toBe("lost");
});
});
});
68 changes: 68 additions & 0 deletions packages/agents/src/browser-tests/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import {
type ProxyToolOutput
} from "@cloudflare/codemode";
import { BrowserConnector } from "../browser/connector";
import {
BrowserSessionConnector,
type BrowserExecutionReport
} from "../browser/session-connector";
import {
Browser,
browserRun,
namedBrowserSessionKey
} from "../browser/browser";
import {
DurableBrowserSessionStore,
type StoredBrowserSession
Expand All @@ -32,6 +41,9 @@ type Env = {

type SessionMode = "one-shot" | "reuse" | "dynamic";

/** The named session the persistent-browser tests drive. */
const NAMED_SESSION = "e2e";

/** Approval-gated connector used to pause executions mid-run in tests. */
class GateConnector extends CodemodeConnector<Env> {
name(): string {
Expand Down Expand Up @@ -90,6 +102,62 @@ export class BrowserTestAgent extends Agent<Env> {
)) as ProxyToolOutput;
}

// ── Persistent named browser (BrowserSessionConnector) ──

#namedBrowser(): Browser {
return new Browser({
provider: browserRun(this.env.BROWSER),
name: NAMED_SESSION,
store: this.#store()
});
}

#namedConnector(): BrowserSessionConnector {
return new BrowserSessionConnector(this.ctx, {
browser: this.#namedBrowser()
});
}

#namedRuntime(connector: BrowserSessionConnector): CodemodeRuntimeHandle {
return createCodemodeRuntime({
ctx: this.ctx,
executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
connectors: [connector, new GateConnector(this.ctx, this.env)],
name: "browser-named"
});
}

/** Run code against the persistent browser; include the browser report. */
@callable()
async runNamed(
code: string
): Promise<ProxyToolOutput & { report: BrowserExecutionReport | null }> {
const connector = this.#namedConnector();
const output = await this.#execute(this.#namedRuntime(connector), code);
return {
...output,
report: connector.takeReport(output.executionId) ?? null
};
}

@callable()
async approveNamed(executionId: string) {
const connector = this.#namedConnector();
return this.#namedRuntime(connector).approve({ executionId });
}

/** Close the persistent browser so it doesn't outlive the suite. */
@callable()
async closeNamed(): Promise<void> {
await this.#namedBrowser().close();
}

/** Storage key of the named session, for corruptStoredSession. */
@callable()
async namedSessionKey(): Promise<string> {
return namedBrowserSessionKey(NAMED_SESSION);
}

@callable()
async run(
code: string,
Expand Down
41 changes: 37 additions & 4 deletions packages/agents/src/browser/cdp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ export interface CdpConnectionOptions {
}

const DEFAULT_TIMEOUT_MS = 10_000;

/** JSON-RPC's "method not found" code, which Chrome also uses for CDP. */
export const CDP_METHOD_NOT_FOUND = -32601;

/** The browser answered a command with a JSON-RPC error. */
export class CdpProtocolError extends Error {
override readonly name = "CdpProtocolError";
/** JSON-RPC error code, e.g. {@link CDP_METHOD_NOT_FOUND}. */
readonly code: number | undefined;
/** The CDP method that failed. */
readonly method: string;

constructor(method: string, code: number | undefined, message: string) {
super(`CDP error ${code ?? "unknown"}: ${message} for ${method}`);
this.code = code;
this.method = method;
}
}
const MAX_DEBUG_ENTRIES = 400;

/**
Expand Down Expand Up @@ -139,7 +157,20 @@ export class CdpConnection {
});

this.#recordDebug("send", { id, method, sessionId, timeoutMs });
this.#socket.send(JSON.stringify({ id, method, params, sessionId }));
try {
this.#socket.send(JSON.stringify({ id, method, params, sessionId }));
} catch (error) {
// A closed socket throws on send: fail this command now rather than
// leave it pending until it times out.
const pending = this.#pending.get(id);
if (pending) {
clearTimeout(pending.timeoutId);
this.#pending.delete(id);
pending.reject(
error instanceof Error ? error : new Error(String(error))
);
}
}
return result;
}

Expand Down Expand Up @@ -251,10 +282,12 @@ export class CdpConnection {

if (payload.error) {
const err = payload.error as { code?: unknown; message?: string };
const code = err.code ?? "unknown";
const message = err.message ?? "CDP error";
pending.reject(
new Error(`CDP error ${code}: ${message} for ${pending.method}`)
new CdpProtocolError(
pending.method,
typeof err.code === "number" ? err.code : undefined,
err.message ?? "CDP error"
)
);
return;
}
Expand Down
Loading
Loading