FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

phase-h step 1+2: vendor CDP layer, rewrite browser_execute in-process · techfundoffice/browsercode@9811ba1 · GitHub

Commit 9811ba1

Browse files
bcode
committed
phase-h step 1+2: vendor CDP layer, rewrite browser_execute in-process
Step 1 — vendor browser-harness-js@95b7a22a CDP layer into packages/bcode-browser/src/cdp/ (session.ts, gen.ts, generated.ts, browser_protocol.json, js_protocol.json) + PROVENANCE.md. Initial copy only; subsequent edits diverge by design (Phase H hard rule browser-use#2 — no sync cadence, we own this from here on). Step 2 — rewrite packages/bcode-browser/src/browser-execute.ts to evaluate JS in-process via new AsyncFunction("session", code) against a per- opencode-session CDP Session singleton (closed via Effect.addFinalizer). console.log/error/warn/info monkey-patched around each snippet; restored in finally even on throw/timeout. Snippet scope binds only `session` plus standard JS globals — nothing auto-loaded (Phase H hard rule browser-use#3: workspace as plain code, no privileged files). Level-2 wrapper resolves the per-project workspace dir <projectDir>/.bcode/agent-workspace/ from InstanceState.context at execute-time; the impl mkdir's it on first call. Prompt rewritten around the no-magic model with the write-once-import-many pattern as the first example. Permission glob in agent.ts: harnessGlob/harnessArchiveGlob/ harnessArchiveEditDeny removed; replaced with project-relative **/.bcode/agent-workspace/**/* edit-allow. TUI rendering: switched from Python REPL prompts (">>> " / "... ") to JS prompts ("> " / " "); input.python -> input.code. Deleted: harness.ts, uv-locate.ts (no subprocess/uv path anymore). Typecheck clean across all browsercode packages.
1 parent 92f3fd2 commit 9811ba1

14 files changed

Lines changed: 50712 additions & 385 deletions

File tree

‎packages/bcode-browser/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"license": "MIT",
88
"private": true,
99
"scripts": {
10-
"typecheck": "tsgo --noEmit"
10+
"typecheck": "tsgo --noEmit",
11+
"cdp:gen": "bun src/cdp/gen.ts"
1112
},
1213
"exports": {
1314
"./*": "./src/*.ts"
Lines changed: 96 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,51 @@
11
// browser_execute — single-tool browser interface (decisions.md §3.2).
22
//
3-
// Spawns the vendored harness with a Python snippet:
3+
// Executes a JavaScript snippet in-process against a per-opencode-session
4+
// `Session` (the CDP transport from `./cdp/session.ts`). No subprocess, no
5+
// daemon, no Unix socket, no `uv` — we wrap the snippet with
6+
// `new AsyncFunction("session", code)` and run it.
47
//
5-
// uv run --project <HARNESS_DIR> browser-harness -c "<code>"
8+
// Snippet scope (Phase H hard rule #3 — workspace-as-plain-code):
9+
// `session` — the live CDP `Session`, persistent across calls.
10+
// standard JS globals.
611
//
7-
// `browser-harness` is the console-script entry point declared in the
8-
// harness's `pyproject.toml` (since upstream PR #229 moved the package to a
9-
// `src/browser_harness/` layout). `uv run --project <dir>` resolves the
10-
// project's venv/dependencies, then dispatches to the entry point.
12+
// Nothing is auto-loaded. To reuse code from a previous snippet the agent
13+
// writes plain `await import("/abs/path/foo.ts?t=" + Date.now())` against a
14+
// `.ts` file it owns under `<projectDir>/.bcode/agent-workspace/`. Same
15+
// mechanism for a 5-line wrapper and a 500-line scrape script. The Level-2
16+
// wrapper supplies `ctx.workspaceDir` so `.ts` files written under it can be
17+
// addressed by absolute path; this resolver creates the dir on first use.
1118
//
12-
// The harness manages the daemon itself via admin.ensure_daemon(). We just
13-
// pipe stdout+stderr back. BU_NAME is namespaced by sessionID so parallel
14-
// sub-agents (each with their own session) get isolated daemons + browsers.
19+
// Output capture: console.log calls inside the snippet stream via a
20+
// monkey-patch around `console.log`/`console.error`/`console.warn`/
21+
// `console.info`. The originals are restored in a `finally` block — even if
22+
// the snippet throws, even on timeout. See
23+
// `memory/browsercode/phase_h_eval_feasibility_findings.md` for the verified
24+
// pattern (compiled-mode `bun build --compile` works on Linux x64; AsyncFunction
25+
// + dynamic import survive bunfs).
1526
//
16-
// Two per-session dirs, separated by lifetime + path-length sensitivity:
17-
// BH_TMP_DIR — screenshots, debug overlays, daemon log. Persistent under
18-
// <dataDir>/sessions/<sid>/. Long path is fine; the cloud
19-
// UI / read tool finds artifacts here.
20-
// BH_RUNTIME_DIR — sock, port, pid. Volatile under <runtimeRoot>/bcode/<sid>/.
21-
// Path-length budgeted on macOS (AF_UNIX sun_path = 104).
27+
// Cancellation: JS Promises are not preemptively cancellable. A snippet
28+
// without `await` yield-points (e.g. `for (let i = 0; i < 1e9; i++) {}`)
29+
// runs to completion before our timeout fiber observes it. `Effect.timeoutOrElse`
30+
// fails the surrounding fiber but the orphan Promise keeps running until it
31+
// finishes. This matches the `uv run` subprocess case (SIGTERM only after
32+
// the Python signal handler yields). Document, don't fix.
2233
//
2334
// Level 1 per decisions.md §1c — substantial implementation lives here. The
24-
// Level-2 hook in packages/opencode is a one-line wrapper.
35+
// Level-2 hook in packages/opencode is a thin adapter.
2536

2637
import fs from "fs/promises"
27-
import os from "os"
2838
import path from "path"
29-
import { Effect, Schema, Stream } from "effect"
30-
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
31-
import { harnessArchiveDir, resolveHarnessDir } from "./harness"
32-
import { uvLocate } from "./uv-locate"
33-
34-
// Per-session persistent scratch under <dataDir>/sessions/<sid>/. Holds
35-
// screenshots, debug overlays, daemon log. Caller supplies dataDir
36-
// (e.g. opencode's Global.Path.data).
37-
export const sessionScratchDir = (dataDir: string, sessionID: string) =>
38-
path.join(dataDir, "sessions", sessionID)
39-
40-
// Per-session volatile runtime dir under <runtimeRoot>/bcode/<sid>/. Holds
41-
// AF_UNIX sock + port file + pid. macOS sun_path is 104 bytes:
42-
// `/tmp/bcode/ses_<26ch>/bu.sock` is 50 chars — well within budget.
43-
// On Windows the daemon listens on TCP so the path doesn't need to be short,
44-
// but using os.tmpdir() keeps the layout consistent.
45-
const RUNTIME_ROOT = process.platform === "win32" ? os.tmpdir() : "/tmp"
46-
export const sessionRuntimeDir = (sessionID: string) =>
47-
path.join(RUNTIME_ROOT, "bcode", sessionID)
39+
import { Effect, Schema } from "effect"
40+
import { Session } from "./cdp/session"
4841

4942
const DEFAULT_TIMEOUT_MS = 60 * 1000
5043
const MAX_TIMEOUT_MS = 10 * 60 * 1000
5144

5245
export const parameters = Schema.Struct({
53-
python: Schema.String.annotate({ description: "Python source to execute against the browser harness." }),
46+
code: Schema.String.annotate({
47+
description: "JavaScript source. Wrapped in an async function with `session` (CDP Session) bound.",
48+
}),
5449
timeout: Schema.optional(Schema.Number).annotate({
5550
description: `Timeout in milliseconds. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
5651
}),
@@ -59,96 +54,88 @@ export const parameters = Schema.Struct({
5954
export type Parameters = Schema.Schema.Type<typeof parameters>
6055

6156
export interface ExecuteContext {
62-
readonly sessionID: string
63-
// BH_TMP_DIR. Persistent per-session dir for screenshots/log. Pre-compute
64-
// via sessionScratchDir(dataDir, sessionID).
65-
readonly bhScratchDir: string
66-
// BH_RUNTIME_DIR. Volatile short-path per-session dir for sock/port/pid.
67-
// Pre-compute via sessionRuntimeDir(sessionID).
68-
readonly bhRuntimeDir: string
69-
// Optional progress callback invoked per output chunk (combined stdout+stderr).
70-
// Level-2 supplies this to drive TUI streaming via opencode's `ctx.metadata`.
71-
// The callback receives the fully accumulated output so far, not just the
57+
// Per-project workspace dir: <projectDir>/.bcode/agent-workspace/. Created
58+
// on first call. The agent reads/writes/edits .ts files here via the
59+
// standard read/write/edit tools and imports them at runtime via
60+
// `await import("<absPath>?t=" + Date.now())`. Resolved by the Level-2
61+
// wrapper from opencode's project-detection (Instance.directory).
62+
readonly workspaceDir: string
63+
// Optional progress callback invoked per output chunk (combined console
64+
// streams). Receives the fully accumulated output so far, not just the
7265
// delta — simpler for consumers that just want to set "current output".
7366
readonly onChunk?: (output: string) => Effect.Effect<void>
7467
}
7568

7669
export interface ExecuteResult {
7770
readonly output: string
78-
readonly exitCode: number
71+
// The snippet's `return` value, JSON-serialized when possible. `undefined`
72+
// serializes as `null` (JSON has no undefined). Non-serializable values
73+
// fall back to `String(v)`.
74+
readonly result: string
7975
}
8076

81-
const UV_MISSING_HINT =
82-
"uv is not installed or not on PATH. Install it once: curl -fsSL https://astral.sh/uv/install.sh | sh " +
83-
"(Windows: irm https://astral.sh/uv/install.ps1 | iex). " +
84-
"If you just installed uv, restart your terminal so PATH picks it up."
85-
86-
// Spawn errors flow through effect's PlatformError; ENOENT lives on the wrapped
87-
// cause's `.code`. Walk the cause chain so we detect it regardless of nesting.
88-
const isUvMissing = (err: unknown): boolean => {
89-
let cur: unknown = err
90-
for (let i = 0; i < 5 && cur; i++) {
91-
if (typeof cur === "object" && cur !== null && (cur as { code?: string }).code === "ENOENT") return true
92-
cur = (cur as { cause?: unknown }).cause
77+
// AsyncFunction is not a global — pull it off an async arrow's constructor.
78+
const AsyncFunction = (async () => {}).constructor as new (
79+
...args: string[]
80+
) => (...injected: unknown[]) => Promise<unknown>
81+
82+
const serialize = (v: unknown): string => {
83+
if (v === undefined) return "null"
84+
try {
85+
return JSON.stringify(
86+
v,
87+
(_k, val) => (typeof val === "bigint" ? val.toString() : val),
88+
2,
89+
) ?? "null"
90+
} catch {
91+
return JSON.stringify(String(v))
9392
}
94-
return false
9593
}
9694

97-
// dataDir is opencode's XDG_DATA_HOME for bcode (~/.local/share/bcode/). The
98-
// harness lives at <dataDir>/harness/. We resolve eagerly at make-time so the
99-
// extraction (compiled mode) happens before the agent reads SKILL.md.
100-
export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) {
101-
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
102-
const locate = yield* uvLocate
103-
const harnessDir = yield* Effect.promise(() => resolveHarnessDir(dataDir))
95+
// Per-opencode-session Session singleton. Connects lazily on first snippet;
96+
// closed on session end via the caller's scope finalizer.
97+
export const make = Effect.fn("BrowserExecute.make")(function* () {
98+
const session = new Session()
99+
yield* Effect.addFinalizer(() => Effect.sync(() => session.close()))
104100

105101
const execute = (args: Parameters, ctx: ExecuteContext) =>
106102
Effect.gen(function* () {
107-
// Pre-flight check on harnessDir: spawn ENOENT on a missing cwd surfaces
108-
// with `path: "uv"` on Bun/Windows, which is indistinguishable from a
109-
// truly-missing uv. Catch it here so the user gets the real cause
110-
// instead of a misleading "uv not on PATH" hint.
111-
if (!(yield* Effect.promise(() => fs.access(harnessDir).then(() => true, () => false)))) {
112-
return yield* Effect.fail(new Error(`harness directory not found at ${harnessDir} — bcode build is broken; please reinstall`))
113-
}
114-
yield* Effect.promise(() => fs.mkdir(ctx.bhScratchDir, { recursive: true }))
115-
yield* Effect.promise(() => fs.mkdir(ctx.bhRuntimeDir, { recursive: true }))
116-
const uv = yield* locate
117-
const proc = ChildProcess.make(
118-
uv,
119-
["run", "--project", harnessDir, "browser-harness", "-c", args.python],
120-
{
121-
cwd: harnessDir,
122-
extendEnv: true,
123-
env: {
124-
BU_NAME: ctx.sessionID,
125-
BH_TMP_DIR: ctx.bhScratchDir,
126-
BH_RUNTIME_DIR: ctx.bhRuntimeDir,
127-
},
128-
stdin: "ignore",
129-
},
130-
)
103+
yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true }))
131104

132-
// uv not on PATH (ENOENT) — surface as exit 127 with the install hint
133-
// so both the agent (via output) and the user (via TUI) can act on it.
134-
// 127 mirrors POSIX "command not found". Other spawn failures rethrow.
135-
const spawned = yield* spawner.spawn(proc).pipe(
136-
Effect.catch((err) =>
137-
isUvMissing(err) ? Effect.succeed("uv-missing" as const) : Effect.fail(new Error(`failed to spawn uv: ${err}`)),
138-
),
139-
)
140-
if (spawned === "uv-missing") return { output: UV_MISSING_HINT, exitCode: 127 } satisfies ExecuteResult
105+
const wrapped = yield* Effect.try({
106+
try: () => new AsyncFunction("session", args.code),
107+
catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`),
108+
})
141109

142110
let output = ""
143-
const drain = Stream.runForEach(Stream.decodeText(spawned.all), (chunk) =>
144-
Effect.gen(function* () {
145-
output += chunk
146-
if (ctx.onChunk) yield* ctx.onChunk(output)
147-
}),
111+
const realLog = console.log
112+
const realErr = console.error
113+
const realWarn = console.warn
114+
const realInfo = console.info
115+
const tee = (...a: unknown[]) => {
116+
output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n"
117+
if (ctx.onChunk) Effect.runFork(ctx.onChunk(output))
118+
}
119+
console.log = tee
120+
console.error = tee
121+
console.warn = tee
122+
console.info = tee
123+
124+
const ran = yield* Effect.tryPromise({
125+
try: () => wrapped(session),
126+
catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`),
127+
}).pipe(
128+
Effect.ensuring(
129+
Effect.sync(() => {
130+
console.log = realLog
131+
console.error = realErr
132+
console.warn = realWarn
133+
console.info = realInfo
134+
}),
135+
),
148136
)
149-
const [, exitCode] = yield* Effect.all([drain, spawned.exitCode], { concurrency: 2 })
150137

151-
return { output, exitCode } satisfies ExecuteResult
138+
return { output, result: serialize(ran) } satisfies ExecuteResult
152139
}).pipe(
153140
Effect.scoped,
154141
Effect.timeoutOrElse({
@@ -157,7 +144,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
157144
}),
158145
)
159146

160-
return { parameters, execute, harnessDir, harnessArchiveDir: harnessArchiveDir(dataDir) }
147+
return { parameters, execute, session }
161148
})
162149

163150
export * as BrowserExecute from "./browser-execute"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# CDP layer provenance
2+
3+
Initial copy from `browser-use/browser-harness-js@95b7a22a923714c45d2f7234b2bfa8fa6322c2eb` (`sdk/`), 2026-05-07.
4+
5+
| File | Source |
6+
|---|---|
7+
| `session.ts` | `sdk/session.ts` |
8+
| `gen.ts` | `sdk/gen.ts` |
9+
| `generated.ts` | `sdk/generated.ts` (output of `bun gen.ts` against the protocol JSONs) |
10+
| `browser_protocol.json` | `sdk/browser_protocol.json` (mirror of `chromedevtools/devtools-protocol`) |
11+
| `js_protocol.json` | `sdk/js_protocol.json` (mirror of `chromedevtools/devtools-protocol`) |
12+
13+
**Initial copy only.** Subsequent edits diverge from upstream by design — see Phase H hard rule #2 in `memory/browsercode/phase_h_migration_plan.md`. The `browser-harness-js` repo was a proof of concept that informed this architecture; it is not a future source of truth. Behaviors from `browser-use/browser-harness` (the Python harness) are tracked separately in `memory/browsercode/harness_watchlist.md` and ported individually as needed.
14+
15+
To regenerate `generated.ts` after a protocol JSON refresh:
16+
17+
```
18+
bun run cdp:gen
19+
```
20+
21+
(from `packages/bcode-browser/`).

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL