Skip to content

Commit 13d028a

Browse files
author
bcode
committed
phase-h step 3+4: workspace import smoke tests; cloud-browser tool
Step 3 — packages/bcode-browser/test/workspace-import.test.ts. Four scenarios verifying the BROWSER.md-documented pattern: import abs path, edit-then-cache-bust, syntax-error rejection, nested workspace paths. All four pass with bun test against real fs. Step 4 — packages/bcode-browser/src/cloud-browser.ts (provisions BU cloud browser via /api/v3/browsers, connects the per-opencode-session Session to the cdpUrl, registers stop on session evict). Level-2 wrapper at packages/opencode/src/tool/browser-open-cloud.{ts,txt} + registry registration. Restructured the Session lifecycle as a process-scope SessionStore keyed on opencode sessionID. Both browser_execute and browser_open_cloud share the same Session per session — a snippet that follows browser_open_cloud drives the cloud browser, not a freshly-auto-detected local one. The store also collects per-session cleanup callbacks (cloud-browser stop via PATCH /api/v3/browsers/<id>); these run when SessionStore.evict is called. opencode has no clean session-end hook today so evict is wired but not yet invoked from outside — known gap matching today's subprocess shape. BROWSER_USE_API_KEY required; fail-fast with a one-line error pointing at https://browser-use.com when absent.
1 parent 9811ba1 commit 13d028a

8 files changed

Lines changed: 281 additions & 8 deletions

File tree

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@
3535
// Level-2 hook in packages/opencode is a thin adapter.
3636

3737
import fs from "fs/promises"
38-
import path from "path"
3938
import { Effect, Schema } from "effect"
40-
import { Session } from "./cdp/session"
39+
import { SessionStore } from "./session-store"
4140

4241
const DEFAULT_TIMEOUT_MS = 60 * 1000
4342
const MAX_TIMEOUT_MS = 10 * 60 * 1000
@@ -54,6 +53,10 @@ export const parameters = Schema.Struct({
5453
export type Parameters = Schema.Schema.Type<typeof parameters>
5554

5655
export interface ExecuteContext {
56+
// Identifies the per-opencode-session CDP Session to bind into the snippet.
57+
// Shared with `browser_open_cloud` via the SessionStore so a cloud-attach
58+
// call's Session is driven by subsequent `browser_execute` calls.
59+
readonly sessionID: string
5760
// Per-project workspace dir: <projectDir>/.bcode/agent-workspace/. Created
5861
// on first call. The agent reads/writes/edits .ts files here via the
5962
// standard read/write/edit tools and imports them at runtime via
@@ -92,14 +95,13 @@ const serialize = (v: unknown): string => {
9295
}
9396
}
9497

95-
// Per-opencode-session Session singleton. Connects lazily on first snippet;
96-
// closed on session end via the caller's scope finalizer.
98+
// Snippet executor. The CDP Session is resolved per-call from `SessionStore`
99+
// keyed on `ctx.sessionID` so a Session attached via `browser_open_cloud` is
100+
// the same one a follow-up `browser_execute` drives.
97101
export const make = Effect.fn("BrowserExecute.make")(function* () {
98-
const session = new Session()
99-
yield* Effect.addFinalizer(() => Effect.sync(() => session.close()))
100-
101102
const execute = (args: Parameters, ctx: ExecuteContext) =>
102103
Effect.gen(function* () {
104+
const session = SessionStore.get(ctx.sessionID)
103105
yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true }))
104106

105107
const wrapped = yield* Effect.try({
@@ -144,7 +146,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* () {
144146
}),
145147
)
146148

147-
return { parameters, execute, session }
149+
return { parameters, execute }
148150
})
149151

150152
export * as BrowserExecute from "./browser-execute"
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Cloud-browser attach (decisions §3.3 / §6 — single API key, BU cloud
2+
// surfaces). Provisions a Browser Use cloud browser via the public
3+
// /api/v3/browsers REST surface and connects a `Session` to its `cdpUrl`.
4+
//
5+
// Three calls per attach:
6+
// 1. POST /api/v3/browsers → { id, cdpUrl, liveUrl }
7+
// 2. (caller) session.connect({ wsUrl: cdpUrl })
8+
// 3. PATCH /api/v3/browsers/<id> → { state: "stop" } (finalizer)
9+
//
10+
// `BROWSER_USE_API_KEY` must be set; we fail fast if absent so the Level-2
11+
// wrapper can render a one-line error pointing at our docs without
12+
// constructing a bad request.
13+
14+
import { Effect, Schema } from "effect"
15+
import { SessionStore } from "./session-store"
16+
17+
const API_BASE = "https://api.browser-use.com/api/v3/browsers"
18+
19+
export const provisionParameters = Schema.Struct({
20+
profileId: Schema.optional(Schema.String).annotate({
21+
description: "Existing BU cloud profile id to attach to. Omit for a fresh ephemeral profile.",
22+
}),
23+
proxyCountryCode: Schema.optional(Schema.String).annotate({
24+
description: "ISO-2 country code for the proxy pool (e.g. \"us\", \"de\").",
25+
}),
26+
})
27+
28+
export type ProvisionParameters = Schema.Schema.Type<typeof provisionParameters>
29+
30+
export interface ProvisionResult {
31+
readonly id: string
32+
readonly cdpUrl: string
33+
readonly liveUrl: string
34+
}
35+
36+
const apiKey = () => {
37+
const k = process.env.BROWSER_USE_API_KEY
38+
if (!k) {
39+
throw new Error(
40+
"BROWSER_USE_API_KEY is not set. Cloud browsers require a Browser Use API key — get one at https://browser-use.com.",
41+
)
42+
}
43+
return k
44+
}
45+
46+
const provision = (args: ProvisionParameters) =>
47+
Effect.tryPromise({
48+
try: async () => {
49+
const res = await fetch(API_BASE, {
50+
method: "POST",
51+
headers: {
52+
"X-Browser-Use-API-Key": apiKey(),
53+
"Content-Type": "application/json",
54+
},
55+
body: JSON.stringify({
56+
profile_id: args.profileId,
57+
proxy_country_code: args.proxyCountryCode,
58+
}),
59+
})
60+
if (!res.ok) throw new Error(`provision failed: ${res.status} ${await res.text()}`)
61+
const body = (await res.json()) as { id: string; cdp_url?: string; cdpUrl?: string; live_url?: string; liveUrl?: string }
62+
const cdpUrl = body.cdpUrl ?? body.cdp_url
63+
const liveUrl = body.liveUrl ?? body.live_url
64+
if (!cdpUrl || !liveUrl) throw new Error(`provision response missing cdpUrl/liveUrl: ${JSON.stringify(body)}`)
65+
return { id: body.id, cdpUrl, liveUrl } satisfies ProvisionResult
66+
},
67+
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
68+
})
69+
70+
const stop = (id: string) =>
71+
Effect.tryPromise({
72+
try: async () => {
73+
const res = await fetch(`${API_BASE}/${id}`, {
74+
method: "PATCH",
75+
headers: {
76+
"X-Browser-Use-API-Key": apiKey(),
77+
"Content-Type": "application/json",
78+
},
79+
body: JSON.stringify({ state: "stop" }),
80+
})
81+
if (!res.ok) throw new Error(`stop failed: ${res.status} ${await res.text()}`)
82+
},
83+
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
84+
})
85+
86+
// Provisions a cloud browser, connects the per-opencode-session `Session` to
87+
// it, and registers a stop-callback with `SessionStore` so the browser is
88+
// torn down when the session is evicted (or, in practice, at process exit
89+
// since opencode doesn't currently call evict — that's a known gap matching
90+
// today's `uv run` subprocess shape, where a stuck Python interpreter also
91+
// outlives the bcode session). The Session is shared with `browser_execute`
92+
// via `SessionStore`. Returns the public bits the agent needs.
93+
export const open = Effect.fn("CloudBrowser.open")(function* (
94+
sessionID: string,
95+
args: ProvisionParameters,
96+
) {
97+
const { id, cdpUrl, liveUrl } = yield* provision(args)
98+
const session = SessionStore.get(sessionID)
99+
SessionStore.onEvict(sessionID, () =>
100+
Effect.runPromise(stop(id).pipe(Effect.ignore)),
101+
)
102+
yield* Effect.tryPromise({
103+
try: () => session.connect({ wsUrl: cdpUrl }),
104+
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
105+
})
106+
return { id, liveUrl } as const
107+
})
108+
109+
export * as CloudBrowser from "./cloud-browser"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Process-scope per-opencode-session CDP Session map.
2+
//
3+
// Both `browser_execute` and `browser_open_cloud` look up the same `Session`
4+
// by `sessionID` so a snippet that follows a `browser_open_cloud` call drives
5+
// the cloud browser, not a freshly-auto-detected local one.
6+
//
7+
// Lifetime: Sessions persist for the life of the opencode process (or until
8+
// the underlying WebSocket closes). We don't have a clean session-end hook
9+
// in opencode's tool layer; the WS closes naturally on browser exit and the
10+
// agent can call `session.close()` from a snippet if needed. Sessions held in
11+
// the map after their browser exits become unusable but are cheap (just an
12+
// idle WS reference until the next snippet replaces them).
13+
//
14+
// Evicted via `evict(sessionID)` if a future hook is added.
15+
16+
import { Session } from "./cdp/session"
17+
18+
interface Entry {
19+
readonly session: Session
20+
// Cleanup callbacks registered alongside the Session — e.g. cloud-browser
21+
// stop calls. Run sequentially on `evict`. Each is fire-and-forget and
22+
// must not throw outwards (errors are logged, not propagated).
23+
readonly cleanup: Array<() => Promise<void>>
24+
}
25+
26+
const sessions = new Map<string, Entry>()
27+
28+
export const get = (sessionID: string): Session => {
29+
const existing = sessions.get(sessionID)
30+
if (existing) return existing.session
31+
const fresh = new Session()
32+
sessions.set(sessionID, { session: fresh, cleanup: [] })
33+
return fresh
34+
}
35+
36+
export const onEvict = (sessionID: string, fn: () => Promise<void>): void => {
37+
const entry = sessions.get(sessionID)
38+
if (!entry) throw new Error(`SessionStore.onEvict: no session ${sessionID}`)
39+
entry.cleanup.push(fn)
40+
}
41+
42+
export const evict = async (sessionID: string): Promise<void> => {
43+
const entry = sessions.get(sessionID)
44+
if (!entry) return
45+
sessions.delete(sessionID)
46+
for (const fn of entry.cleanup) {
47+
try { await fn() } catch (err) { console.error(`SessionStore evict cleanup failed for ${sessionID}:`, err) }
48+
}
49+
entry.session.close()
50+
}
51+
52+
export * as SessionStore from "./session-store"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Smoke test for the documented workspace-import pattern: agent writes
2+
// .ts files to <projectDir>/.bcode/agent-workspace/, then imports them
3+
// at runtime from a `browser_execute` snippet via
4+
// `await import("/abs/path?t=" + Date.now())`. We don't run a real
5+
// `browser_execute` here — the point is to verify the dynamic-import
6+
// mechanism behaves as the BROWSER.md prompt claims.
7+
//
8+
// All four scenarios run against a real tmp dir, real .ts files, and
9+
// the real Bun module loader. No mocks.
10+
11+
import { afterAll, beforeAll, expect, test } from "bun:test"
12+
import fs from "fs/promises"
13+
import os from "os"
14+
import path from "path"
15+
16+
let workspace: string
17+
18+
beforeAll(async () => {
19+
workspace = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-ws-"))
20+
})
21+
22+
afterAll(async () => {
23+
await fs.rm(workspace, { recursive: true, force: true })
24+
})
25+
26+
test("await import(absPath) returns the workspace .ts module", async () => {
27+
const file = path.join(workspace, "scrape.ts")
28+
await fs.writeFile(file, `export const run = async () => "v1"\n`, "utf8")
29+
const m = await import(file)
30+
expect(await m.run()).toBe("v1")
31+
})
32+
33+
test("edit + cache-bust returns the fresh module", async () => {
34+
const file = path.join(workspace, "fresh.ts")
35+
await fs.writeFile(file, `export const run = async () => "before"\n`, "utf8")
36+
const a = await import(`${file}?t=${Date.now()}`)
37+
expect(await a.run()).toBe("before")
38+
await fs.writeFile(file, `export const run = async () => "after"\n`, "utf8")
39+
const b = await import(`${file}?t=${Date.now() + 1}`)
40+
expect(await b.run()).toBe("after")
41+
})
42+
43+
test("syntax error in workspace file surfaces a clean rejection", async () => {
44+
const file = path.join(workspace, "broken.ts")
45+
await fs.writeFile(file, `export const run = (\n`, "utf8")
46+
expect(async () => {
47+
await import(`${file}?t=${Date.now()}`)
48+
}).toThrow()
49+
})
50+
51+
test("nested workspace paths import the same way", async () => {
52+
const subdir = path.join(workspace, "scrapers", "social")
53+
await fs.mkdir(subdir, { recursive: true })
54+
const file = path.join(subdir, "tweet.ts")
55+
await fs.writeFile(file, `export const run = async () => "nested"\n`, "utf8")
56+
const m = await import(file)
57+
expect(await m.run()).toBe("nested")
58+
})

‎packages/opencode/src/tool/browser-execute.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const BrowserExecuteTool = Tool.define(
4545
const workspaceDir = workspaceDirOf(instance.directory)
4646

4747
const result = yield* impl.execute(args, {
48+
sessionID: ctx.sessionID,
4849
workspaceDir,
4950
// Stream chunks to the TUI as they arrive — same pattern as bash.
5051
onChunk: (output) =>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// browser_open_cloud — Level-2 hook (decisions.md §1c, §3.3, §6).
2+
//
3+
// Provisions a Browser Use cloud browser and binds the per-opencode-session
4+
// CDP Session to it. After this tool returns, `browser_execute` snippets
5+
// drive the cloud browser instead of any local Chrome.
6+
7+
import { Effect, Schema } from "effect"
8+
import { CloudBrowser } from "@browser-use/bcode-browser/cloud-browser"
9+
import * as Tool from "./tool"
10+
import DESCRIPTION from "./browser-open-cloud.txt"
11+
12+
export const BrowserOpenCloudTool = Tool.define(
13+
"browser_open_cloud",
14+
Effect.gen(function* () {
15+
return {
16+
description: DESCRIPTION,
17+
parameters: CloudBrowser.provisionParameters,
18+
execute: (args: Schema.Schema.Type<typeof CloudBrowser.provisionParameters>, ctx: Tool.Context) =>
19+
Effect.gen(function* () {
20+
yield* ctx.ask({
21+
permission: "browser_open_cloud",
22+
patterns: ["*"],
23+
always: ["*"],
24+
metadata: {},
25+
})
26+
const { id, liveUrl } = yield* CloudBrowser.open(ctx.sessionID, args)
27+
return {
28+
title: "browser_open_cloud",
29+
output: `Cloud browser ready.\nbrowserId: ${id}\nliveUrl: ${liveUrl}`,
30+
metadata: { browserId: id, liveUrl },
31+
}
32+
}).pipe(Effect.orDie),
33+
}
34+
}),
35+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Provision a Browser Use cloud browser and bind it to this session.
2+
3+
After this tool returns, every subsequent `browser_execute` snippet drives the cloud browser via the same `session` object — there is no per-snippet attach step. The cloud browser is automatically stopped when the bcode session ends.
4+
5+
Use this when:
6+
- You need a browser the user cannot see (autonomous, long-running, or background tasks).
7+
- You need a clean profile, geo-located proxy, or cookie isolation.
8+
- You need to scrape sites that fingerprint local Chrome installs.
9+
10+
Requires `BROWSER_USE_API_KEY`. Without it the call fails with a one-line error pointing at https://browser-use.com.
11+
12+
Output includes a `liveUrl` — a Browser Use URL that streams the live browser pixels to a viewer; share it with the user when they want to watch the agent work.

‎packages/opencode/src/tool/registry.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TodoWriteTool } from "./todo"
1111
import { WebFetchTool } from "./webfetch"
1212
import { WriteTool } from "./write"
1313
import { BrowserExecuteTool } from "./browser-execute"
14+
import { BrowserOpenCloudTool } from "./browser-open-cloud"
1415
import { InvalidTool } from "./invalid"
1516
import { SkillTool } from "./skill"
1617
import * as Tool from "./tool"
@@ -115,6 +116,7 @@ export const layer: Layer.Layer<
115116
const patchtool = yield* ApplyPatchTool
116117
const skilltool = yield* SkillTool
117118
const browserExecute = yield* BrowserExecuteTool
119+
const browserOpenCloud = yield* BrowserOpenCloudTool
118120
const agent = yield* Agent.Service
119121

120122
const state = yield* InstanceState.make<State>(
@@ -209,6 +211,7 @@ export const layer: Layer.Layer<
209211
search: Tool.init(websearch),
210212
skill: Tool.init(skilltool),
211213
browserExecute: Tool.init(browserExecute),
214+
browserOpenCloud: Tool.init(browserOpenCloud),
212215
patch: Tool.init(patchtool),
213216
question: Tool.init(question),
214217
lsp: Tool.init(lsptool),
@@ -232,6 +235,7 @@ export const layer: Layer.Layer<
232235
tool.search,
233236
tool.skill,
234237
tool.browserExecute,
238+
tool.browserOpenCloud,
235239
tool.patch,
236240
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
237241
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),

0 commit comments

Comments
 (0)