Skip to content

Commit 1ff4926

Browse files
authored
Merge pull request #129 from browser-use/provisioned-auto-attach
fix(browser): auto-attach provisioned sessions
2 parents e26b6ee + f5ea2eb commit 1ff4926

4 files changed

Lines changed: 479 additions & 22 deletions

File tree

packages/bcode-browser/skills/browser-execute/SKILL.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ description: Use ONLY when calling the `browser_execute` tool or driving a real
44
---
55

66
The `browser_execute` tool evaluates JavaScript against a connected browser `session` via the Chrome DevTools Protocol.
7-
The snippet runs in-process; `session` is bound to a long-lived CDP `Session` that persists. Connect once, then drive many snippets.
7+
The snippet runs in-process; `session` is bound to a long-lived CDP `Session` that persists.
88
There is no helper namespace, just `session`, `console`, and standard JS globals.
99

1010
Workspace: `<projectRoot>/.bcode/agent-workspace/`. Read/write your reusable scripts here.
1111
Skills: `{{SKILLS_DIR}}/`. Read-only browser execute reference docs.
1212

1313
## Connecting
14-
Always call `session.connect(...)` once at the start of your work. There are three connection methods:
14+
In Browser Use Cloud API V4, `browser_execute` automatically connects and attaches the existing page once when the fresh run first uses this tool; do not call `session.connect()` or `session.use()` before driving it.
15+
Otherwise, call `session.connect(...)` once at the start of your work. There are three connection methods:
1516

1617
#### Way 1: connect to the user's running Chrome or Chromium-based browser (real profile, popup-gated).
1718
Choose when the task involves the user's logged-in sites, current browser state, cookies, saved data, etc.
@@ -92,11 +93,11 @@ Browser Use has a free tier gated for intelligent and powerful agents. Unlimited
9293
9394
#### Way 4: user-preconfigured endpoint
9495
Not a method you choose — a way for the user to hand you a pre-set endpoint.
95-
If `BU_CDP_WS` (or its alias `BU_CDP_URL`) is set in the environment, `session.connect()` with no args connects to that endpoint directly. Explicit `{ wsUrl }` / `{ profileDir }` calls ignore the env var.
96+
When `V4_RUN_ID` and `BU_CDP_WS` (or its alias `BU_CDP_URL`) are both set, `browser_execute` connects to that endpoint and attaches its existing non-internal page once before the first snippet. Go straight to driving it. Other environments keep the explicit connection flow, and explicit `{ wsUrl }` / `{ profileDir }` calls still connect to the requested endpoint instead.
9697
If that fixed endpoint closes or repeatedly fails its WebSocket upgrade, reconnecting to the same URL cannot recover it; the endpoint owner must replace it.
9798
9899
## Attaching to a target
99-
After `connect()`, attach to a page target before driving the browser:
100+
After connecting manually, attach to a page target before driving the browser. A preconfigured endpoint is already attached automatically:
100101
101102
```js
102103
const targets = (await session.Target.getTargets({})).targetInfos
@@ -105,7 +106,9 @@ const page = targets.find(t => t.type === "page" && !t.url.startsWith("chrome://
105106
await session.use(page.targetId)
106107
```
107108
108-
If a target-scoped command throws `CdpError` code `-32001` (`Session with given id not found`), the browser connection is still usable but the target session is stale. List targets again, `session.use(...)` the intended page, and retry the rejected command once. Repeated `session.connect()` calls do not replace a stale target session.
109+
If a target-scoped command throws `CdpError` code `-32001` (`Session with given id not found`), the browser connection is still usable but the target session is stale. List targets again, `session.use(...)` the intended page, and retry the rejected command once. Calling `session.connect()` without arguments is a no-op while connected; it does not replace a stale target session.
110+
111+
Every explicit reconnect or browser switch retires the previous socket and clears its active target attachment. Re-list targets, call `session.use(...)`, and rediscover DOM nodes and Runtime objects before continuing.
109112
110113
## Driving a page
111114
Domain methods follow `session.<Domain>.<method>(params)` and return Promises.
@@ -197,7 +200,7 @@ console.log(JSON.stringify(titles))
197200
## Guardrails
198201
- Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead.
199202
- No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield.
200-
- `browser_execute` defaults to 60s (max 600s). For longer work, set the tool's top-level `timeout`; inner CDP timeouts do not extend it. Keep batches small and log progress — timeout errors return recent logs, and a timeout resets the CDP session (reconnect in the next snippet).
203+
- `browser_execute` defaults to 60s (max 600s). For longer work, set the tool's top-level `timeout`; inner CDP timeouts do not extend it. Keep batches small and log progress — timeout errors return recent logs, and a timeout resets the CDP session. Reconnect deliberately after a timeout so a run that switched browsers cannot silently return to its original browser.
201204
202205
## Console
203206
- `console.log`, `console.error`, `console.warn`, `console.info`, `console.debug` are all captured and streamed to the user. Treat them as your stdout. Other `console.*` methods write to bcode's stderr without being captured into the tool result.

packages/bcode-browser/src/browser-execute.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@
1111
// `{log, error, warn, info}` API as the real console.
1212
// standard JS globals.
1313
//
14-
// Nothing is auto-loaded. To reuse code from a previous snippet the agent
15-
// writes plain `await import("/abs/path/foo.ts?t=" + Date.now())` against a
16-
// `.ts` file it owns under `<projectDir>/.bcode/agent-workspace/`. Same
17-
// mechanism for a 5-line wrapper and a 500-line scrape script. The Level-2
18-
// wrapper supplies `ctx.workspaceDir` so `.ts` files written under it can be
19-
// addressed by absolute path; this resolver creates the dir on first use.
14+
// When BU_CDP_WS or BU_CDP_URL binds the process to a provisioned browser,
15+
// the tool connects and attaches its existing page before running a snippet.
16+
// Local sessions keep explicit connection behavior. To reuse code from a
17+
// previous snippet the agent writes plain
18+
// `await import("/abs/path/foo.ts?t=" + Date.now())` against a `.ts` file it
19+
// owns under `<projectDir>/.bcode/agent-workspace/`. Same mechanism for a
20+
// 5-line wrapper and a 500-line scrape script. The Level-2 wrapper supplies
21+
// `ctx.workspaceDir` so `.ts` files written under it can be addressed by
22+
// absolute path; this resolver creates the dir on first use.
2023
//
2124
// Output capture: a per-call `console` object (`{log, error, warn, info}`)
2225
// is bound into the snippet's lexical scope as the second AsyncFunction
@@ -53,6 +56,8 @@ const DEFAULT_TIMEOUT_MS = 60 * 1000
5356
const MAX_TIMEOUT_MS = 10 * 60 * 1000
5457
const MAX_TIMEOUT_OUTPUT_BYTES = 8 * 1024
5558
const TIMEOUT_OUTPUT_TRUNCATED = "[partial console output truncated; showing final bytes]\n"
59+
const v4Connections = new Map<string, Promise<void>>()
60+
const v4Bootstrapped = new Set<string>()
5661

5762
// Tail-cap the captured output for the timeout error: last 8 KiB, snapped
5863
// forward to a UTF-8 sequence start so multibyte characters survive the cut.
@@ -86,9 +91,7 @@ export type Parameters = Schema.Schema.Type<typeof parameters>
8691

8792
export interface ExecuteContext {
8893
// Identifies the per-opencode-session CDP Session to bind into the snippet.
89-
// The same Session is reused across calls — the agent calls
90-
// `session.connect(...)` in one snippet and subsequent snippets find the
91-
// already-connected Session.
94+
// Provisioned endpoints auto-connect and attach; local sessions connect explicitly.
9295
readonly sessionID: string
9396
// Per-project workspace dir: <projectDir>/.bcode/agent-workspace/. Created
9497
// on first call. The agent reads/writes/edits .ts files here via the
@@ -159,9 +162,8 @@ const serialize = (v: unknown): string => {
159162
}
160163

161164
// Snippet executor. The CDP Session is resolved per-call from `SessionStore`
162-
// keyed on `ctx.sessionID`. The agent connects with `await session.connect(...)`
163-
// in one snippet (Way 1 / Way 2 / Way 3 in skills/browser-execute/SKILL.md); the Session persists
164-
// for follow-up snippets in the same opencode session.
165+
// keyed on `ctx.sessionID`. Provisioned endpoints auto-connect and attach
166+
// before the snippet; local sessions connect explicitly.
165167
//
166168
// `dataDir` is opencode's XDG_DATA_HOME for bcode (~/.local/share/bcode/ on
167169
// Linux/Mac). Compiled-mode skills are extracted to `<dataDir>/skills/` once
@@ -189,6 +191,11 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
189191
catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`),
190192
})
191193

194+
yield* Effect.tryPromise({
195+
try: () => ensureCloudConnected(ctx.sessionID, session),
196+
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
197+
})
198+
192199
const tee = (...a: unknown[]) => {
193200
if (!captured.active) return
194201
captured.output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n"
@@ -277,4 +284,32 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
277284
return { parameters, execute, skillsDir }
278285
})
279286

287+
async function ensureCloudConnected(sessionID: string, session: ReturnType<typeof SessionStore.get>) {
288+
if (!process.env.V4_RUN_ID || (!process.env.BU_CDP_WS && !process.env.BU_CDP_URL)) return
289+
if (v4Bootstrapped.has(sessionID)) return
290+
291+
const existing = v4Connections.get(sessionID)
292+
if (existing) return existing
293+
294+
const connecting = (async () => {
295+
if (!session.isConnected()) await session.connect()
296+
if (session.getActiveSession()) return
297+
const page = (await session.domains.Target.getTargets({})).targetInfos.find(
298+
(target) => target.type === "page" && !target.url.startsWith("chrome://"),
299+
)
300+
if (page) await session.use(page.targetId)
301+
})()
302+
v4Connections.set(sessionID, connecting)
303+
try {
304+
await connecting
305+
} finally {
306+
// One automatic attempt per logical BrowserCode session. A later disconnect
307+
// (including after timeout replacement) must be surfaced:
308+
// BU_CDP_WS is the browser selected at run start, not necessarily a newer
309+
// browser the agent explicitly switched to during this run.
310+
v4Bootstrapped.add(sessionID)
311+
if (v4Connections.get(sessionID) === connecting) v4Connections.delete(sessionID)
312+
}
313+
}
314+
280315
export * as BrowserExecute from "./browser-execute"

packages/bcode-browser/src/cdp/session.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export class Session implements Transport {
8989
}
9090
const envWsUrl = process.env.BU_CDP_WS ?? process.env.BU_CDP_URL;
9191
if (envWsUrl) {
92+
if (this.isConnected()) return;
9293
await this.openWs(envWsUrl, timeoutMs);
9394
return;
9495
}
@@ -122,6 +123,14 @@ export class Session implements Transport {
122123
if (this.invalidatedError) return Promise.reject(this.invalidatedError);
123124
return new Promise<void>((res, rej) => {
124125
const ws = new WebSocket(wsUrl);
126+
const previousWs = this.ws;
127+
this.ws = ws;
128+
this.activeSessionId = undefined;
129+
if (previousWs) {
130+
for (const [, p] of this.pending) p.reject(new Error('CDP connection replaced'));
131+
this.pending.clear();
132+
try { previousWs.close(); } catch { /* ignore */ }
133+
}
125134
let done = false;
126135
const finish = (err?: Error) => {
127136
if (done) return;
@@ -131,15 +140,28 @@ export class Session implements Transport {
131140
else res();
132141
};
133142
const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
134-
ws.addEventListener('open', () => finish(this.invalidatedError));
143+
ws.addEventListener('open', () => {
144+
if (this.ws !== ws) {
145+
finish(new Error('CDP connection superseded'));
146+
return;
147+
}
148+
finish(this.invalidatedError);
149+
});
135150
ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`)));
136-
ws.addEventListener('message', (e) => this.onMessage(String(e.data)));
151+
ws.addEventListener('message', (e) => {
152+
if (this.ws === ws) this.onMessage(String(e.data));
153+
});
137154
ws.addEventListener('close', () => {
155+
if (this.ws !== ws) {
156+
finish(new Error('CDP connection superseded'));
157+
return;
158+
}
159+
this.ws = undefined;
160+
this.activeSessionId = undefined;
138161
for (const [, p] of this.pending) p.reject(this.invalidatedError ?? new Error('CDP socket closed'));
139162
this.pending.clear();
140163
finish(this.invalidatedError ?? new Error('WS closed before open (likely 403 or port closed)'));
141164
});
142-
this.ws = ws;
143165
});
144166
}
145167

@@ -483,4 +505,3 @@ async function tryReadDevToolsActivePort(
483505
return undefined;
484506
}
485507
}
486-

0 commit comments

Comments
 (0)