From 9811ba170626f205d35dc32fc401a805ba9fc5d7 Mon Sep 17 00:00:00 2001 From: bcode Date: Thu, 7 May 2026 23:31:53 +0000 Subject: [PATCH 1/7] phase-h step 1+2: vendor CDP layer, rewrite browser_execute in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #3: workspace as plain code, no privileged files). Level-2 wrapper resolves the per-project workspace dir /.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. --- packages/bcode-browser/package.json | 3 +- packages/bcode-browser/src/browser-execute.ts | 205 +- packages/bcode-browser/src/cdp/PROVENANCE.md | 21 + .../src/cdp/browser_protocol.json | 30909 ++++++++++++++++ packages/bcode-browser/src/cdp/gen.ts | 274 + packages/bcode-browser/src/cdp/generated.ts | 15160 ++++++++ .../bcode-browser/src/cdp/js_protocol.json | 3810 ++ packages/bcode-browser/src/cdp/session.ts | 379 + packages/bcode-browser/src/harness.ts | 157 - packages/bcode-browser/src/uv-locate.ts | 60 - packages/opencode/src/agent/agent.ts | 39 +- .../src/cli/cmd/tui/routes/session/index.tsx | 12 +- packages/opencode/src/tool/browser-execute.ts | 44 +- .../opencode/src/tool/browser-execute.txt | 24 +- 14 files changed, 50712 insertions(+), 385 deletions(-) create mode 100644 packages/bcode-browser/src/cdp/PROVENANCE.md create mode 100644 packages/bcode-browser/src/cdp/browser_protocol.json create mode 100644 packages/bcode-browser/src/cdp/gen.ts create mode 100644 packages/bcode-browser/src/cdp/generated.ts create mode 100644 packages/bcode-browser/src/cdp/js_protocol.json create mode 100644 packages/bcode-browser/src/cdp/session.ts delete mode 100644 packages/bcode-browser/src/harness.ts delete mode 100644 packages/bcode-browser/src/uv-locate.ts diff --git a/packages/bcode-browser/package.json b/packages/bcode-browser/package.json index 7f5492046..747df5e54 100644 --- a/packages/bcode-browser/package.json +++ b/packages/bcode-browser/package.json @@ -7,7 +7,8 @@ "license": "MIT", "private": true, "scripts": { - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "cdp:gen": "bun src/cdp/gen.ts" }, "exports": { "./*": "./src/*.ts" diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index 6aa59d058..760f3e9cb 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -1,56 +1,51 @@ // browser_execute — single-tool browser interface (decisions.md §3.2). // -// Spawns the vendored harness with a Python snippet: +// Executes a JavaScript snippet in-process against a per-opencode-session +// `Session` (the CDP transport from `./cdp/session.ts`). No subprocess, no +// daemon, no Unix socket, no `uv` — we wrap the snippet with +// `new AsyncFunction("session", code)` and run it. // -// uv run --project browser-harness -c "" +// Snippet scope (Phase H hard rule #3 — workspace-as-plain-code): +// `session` — the live CDP `Session`, persistent across calls. +// standard JS globals. // -// `browser-harness` is the console-script entry point declared in the -// harness's `pyproject.toml` (since upstream PR #229 moved the package to a -// `src/browser_harness/` layout). `uv run --project ` resolves the -// project's venv/dependencies, then dispatches to the entry point. +// Nothing is auto-loaded. To reuse code from a previous snippet the agent +// writes plain `await import("/abs/path/foo.ts?t=" + Date.now())` against a +// `.ts` file it owns under `/.bcode/agent-workspace/`. Same +// mechanism for a 5-line wrapper and a 500-line scrape script. The Level-2 +// wrapper supplies `ctx.workspaceDir` so `.ts` files written under it can be +// addressed by absolute path; this resolver creates the dir on first use. // -// The harness manages the daemon itself via admin.ensure_daemon(). We just -// pipe stdout+stderr back. BU_NAME is namespaced by sessionID so parallel -// sub-agents (each with their own session) get isolated daemons + browsers. +// Output capture: console.log calls inside the snippet stream via a +// monkey-patch around `console.log`/`console.error`/`console.warn`/ +// `console.info`. The originals are restored in a `finally` block — even if +// the snippet throws, even on timeout. See +// `memory/browsercode/phase_h_eval_feasibility_findings.md` for the verified +// pattern (compiled-mode `bun build --compile` works on Linux x64; AsyncFunction +// + dynamic import survive bunfs). // -// Two per-session dirs, separated by lifetime + path-length sensitivity: -// BH_TMP_DIR — screenshots, debug overlays, daemon log. Persistent under -// /sessions//. Long path is fine; the cloud -// UI / read tool finds artifacts here. -// BH_RUNTIME_DIR — sock, port, pid. Volatile under /bcode//. -// Path-length budgeted on macOS (AF_UNIX sun_path = 104). +// Cancellation: JS Promises are not preemptively cancellable. A snippet +// without `await` yield-points (e.g. `for (let i = 0; i < 1e9; i++) {}`) +// runs to completion before our timeout fiber observes it. `Effect.timeoutOrElse` +// fails the surrounding fiber but the orphan Promise keeps running until it +// finishes. This matches the `uv run` subprocess case (SIGTERM only after +// the Python signal handler yields). Document, don't fix. // // Level 1 per decisions.md §1c — substantial implementation lives here. The -// Level-2 hook in packages/opencode is a one-line wrapper. +// Level-2 hook in packages/opencode is a thin adapter. import fs from "fs/promises" -import os from "os" import path from "path" -import { Effect, Schema, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { harnessArchiveDir, resolveHarnessDir } from "./harness" -import { uvLocate } from "./uv-locate" - -// Per-session persistent scratch under /sessions//. Holds -// screenshots, debug overlays, daemon log. Caller supplies dataDir -// (e.g. opencode's Global.Path.data). -export const sessionScratchDir = (dataDir: string, sessionID: string) => - path.join(dataDir, "sessions", sessionID) - -// Per-session volatile runtime dir under /bcode//. Holds -// AF_UNIX sock + port file + pid. macOS sun_path is 104 bytes: -// `/tmp/bcode/ses_<26ch>/bu.sock` is 50 chars — well within budget. -// On Windows the daemon listens on TCP so the path doesn't need to be short, -// but using os.tmpdir() keeps the layout consistent. -const RUNTIME_ROOT = process.platform === "win32" ? os.tmpdir() : "/tmp" -export const sessionRuntimeDir = (sessionID: string) => - path.join(RUNTIME_ROOT, "bcode", sessionID) +import { Effect, Schema } from "effect" +import { Session } from "./cdp/session" const DEFAULT_TIMEOUT_MS = 60 * 1000 const MAX_TIMEOUT_MS = 10 * 60 * 1000 export const parameters = Schema.Struct({ - python: Schema.String.annotate({ description: "Python source to execute against the browser harness." }), + code: Schema.String.annotate({ + description: "JavaScript source. Wrapped in an async function with `session` (CDP Session) bound.", + }), timeout: Schema.optional(Schema.Number).annotate({ description: `Timeout in milliseconds. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`, }), @@ -59,96 +54,88 @@ export const parameters = Schema.Struct({ export type Parameters = Schema.Schema.Type export interface ExecuteContext { - readonly sessionID: string - // BH_TMP_DIR. Persistent per-session dir for screenshots/log. Pre-compute - // via sessionScratchDir(dataDir, sessionID). - readonly bhScratchDir: string - // BH_RUNTIME_DIR. Volatile short-path per-session dir for sock/port/pid. - // Pre-compute via sessionRuntimeDir(sessionID). - readonly bhRuntimeDir: string - // Optional progress callback invoked per output chunk (combined stdout+stderr). - // Level-2 supplies this to drive TUI streaming via opencode's `ctx.metadata`. - // The callback receives the fully accumulated output so far, not just the + // Per-project workspace dir: /.bcode/agent-workspace/. Created + // on first call. The agent reads/writes/edits .ts files here via the + // standard read/write/edit tools and imports them at runtime via + // `await import("?t=" + Date.now())`. Resolved by the Level-2 + // wrapper from opencode's project-detection (Instance.directory). + readonly workspaceDir: string + // Optional progress callback invoked per output chunk (combined console + // streams). Receives the fully accumulated output so far, not just the // delta — simpler for consumers that just want to set "current output". readonly onChunk?: (output: string) => Effect.Effect } export interface ExecuteResult { readonly output: string - readonly exitCode: number + // The snippet's `return` value, JSON-serialized when possible. `undefined` + // serializes as `null` (JSON has no undefined). Non-serializable values + // fall back to `String(v)`. + readonly result: string } -const UV_MISSING_HINT = - "uv is not installed or not on PATH. Install it once: curl -fsSL https://astral.sh/uv/install.sh | sh " + - "(Windows: irm https://astral.sh/uv/install.ps1 | iex). " + - "If you just installed uv, restart your terminal so PATH picks it up." - -// Spawn errors flow through effect's PlatformError; ENOENT lives on the wrapped -// cause's `.code`. Walk the cause chain so we detect it regardless of nesting. -const isUvMissing = (err: unknown): boolean => { - let cur: unknown = err - for (let i = 0; i < 5 && cur; i++) { - if (typeof cur === "object" && cur !== null && (cur as { code?: string }).code === "ENOENT") return true - cur = (cur as { cause?: unknown }).cause +// AsyncFunction is not a global — pull it off an async arrow's constructor. +const AsyncFunction = (async () => {}).constructor as new ( + ...args: string[] +) => (...injected: unknown[]) => Promise + +const serialize = (v: unknown): string => { + if (v === undefined) return "null" + try { + return JSON.stringify( + v, + (_k, val) => (typeof val === "bigint" ? val.toString() : val), + 2, + ) ?? "null" + } catch { + return JSON.stringify(String(v)) } - return false } -// dataDir is opencode's XDG_DATA_HOME for bcode (~/.local/share/bcode/). The -// harness lives at /harness/. We resolve eagerly at make-time so the -// extraction (compiled mode) happens before the agent reads SKILL.md. -export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const locate = yield* uvLocate - const harnessDir = yield* Effect.promise(() => resolveHarnessDir(dataDir)) +// Per-opencode-session Session singleton. Connects lazily on first snippet; +// closed on session end via the caller's scope finalizer. +export const make = Effect.fn("BrowserExecute.make")(function* () { + const session = new Session() + yield* Effect.addFinalizer(() => Effect.sync(() => session.close())) const execute = (args: Parameters, ctx: ExecuteContext) => Effect.gen(function* () { - // Pre-flight check on harnessDir: spawn ENOENT on a missing cwd surfaces - // with `path: "uv"` on Bun/Windows, which is indistinguishable from a - // truly-missing uv. Catch it here so the user gets the real cause - // instead of a misleading "uv not on PATH" hint. - if (!(yield* Effect.promise(() => fs.access(harnessDir).then(() => true, () => false)))) { - return yield* Effect.fail(new Error(`harness directory not found at ${harnessDir} — bcode build is broken; please reinstall`)) - } - yield* Effect.promise(() => fs.mkdir(ctx.bhScratchDir, { recursive: true })) - yield* Effect.promise(() => fs.mkdir(ctx.bhRuntimeDir, { recursive: true })) - const uv = yield* locate - const proc = ChildProcess.make( - uv, - ["run", "--project", harnessDir, "browser-harness", "-c", args.python], - { - cwd: harnessDir, - extendEnv: true, - env: { - BU_NAME: ctx.sessionID, - BH_TMP_DIR: ctx.bhScratchDir, - BH_RUNTIME_DIR: ctx.bhRuntimeDir, - }, - stdin: "ignore", - }, - ) + yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true })) - // uv not on PATH (ENOENT) — surface as exit 127 with the install hint - // so both the agent (via output) and the user (via TUI) can act on it. - // 127 mirrors POSIX "command not found". Other spawn failures rethrow. - const spawned = yield* spawner.spawn(proc).pipe( - Effect.catch((err) => - isUvMissing(err) ? Effect.succeed("uv-missing" as const) : Effect.fail(new Error(`failed to spawn uv: ${err}`)), - ), - ) - if (spawned === "uv-missing") return { output: UV_MISSING_HINT, exitCode: 127 } satisfies ExecuteResult + const wrapped = yield* Effect.try({ + try: () => new AsyncFunction("session", args.code), + catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`), + }) let output = "" - const drain = Stream.runForEach(Stream.decodeText(spawned.all), (chunk) => - Effect.gen(function* () { - output += chunk - if (ctx.onChunk) yield* ctx.onChunk(output) - }), + const realLog = console.log + const realErr = console.error + const realWarn = console.warn + const realInfo = console.info + const tee = (...a: unknown[]) => { + output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n" + if (ctx.onChunk) Effect.runFork(ctx.onChunk(output)) + } + console.log = tee + console.error = tee + console.warn = tee + console.info = tee + + const ran = yield* Effect.tryPromise({ + try: () => wrapped(session), + catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`), + }).pipe( + Effect.ensuring( + Effect.sync(() => { + console.log = realLog + console.error = realErr + console.warn = realWarn + console.info = realInfo + }), + ), ) - const [, exitCode] = yield* Effect.all([drain, spawned.exitCode], { concurrency: 2 }) - return { output, exitCode } satisfies ExecuteResult + return { output, result: serialize(ran) } satisfies ExecuteResult }).pipe( Effect.scoped, Effect.timeoutOrElse({ @@ -157,7 +144,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) }), ) - return { parameters, execute, harnessDir, harnessArchiveDir: harnessArchiveDir(dataDir) } + return { parameters, execute, session } }) export * as BrowserExecute from "./browser-execute" diff --git a/packages/bcode-browser/src/cdp/PROVENANCE.md b/packages/bcode-browser/src/cdp/PROVENANCE.md new file mode 100644 index 000000000..f15088b5d --- /dev/null +++ b/packages/bcode-browser/src/cdp/PROVENANCE.md @@ -0,0 +1,21 @@ +# CDP layer provenance + +Initial copy from `browser-use/browser-harness-js@95b7a22a923714c45d2f7234b2bfa8fa6322c2eb` (`sdk/`), 2026-05-07. + +| File | Source | +|---|---| +| `session.ts` | `sdk/session.ts` | +| `gen.ts` | `sdk/gen.ts` | +| `generated.ts` | `sdk/generated.ts` (output of `bun gen.ts` against the protocol JSONs) | +| `browser_protocol.json` | `sdk/browser_protocol.json` (mirror of `chromedevtools/devtools-protocol`) | +| `js_protocol.json` | `sdk/js_protocol.json` (mirror of `chromedevtools/devtools-protocol`) | + +**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. + +To regenerate `generated.ts` after a protocol JSON refresh: + +``` +bun run cdp:gen +``` + +(from `packages/bcode-browser/`). diff --git a/packages/bcode-browser/src/cdp/browser_protocol.json b/packages/bcode-browser/src/cdp/browser_protocol.json new file mode 100644 index 000000000..99a2d6dd5 --- /dev/null +++ b/packages/bcode-browser/src/cdp/browser_protocol.json @@ -0,0 +1,30909 @@ +{ + "version": { + "major": "1", + "minor": "3" + }, + "domains": [ + { + "domain": "Accessibility", + "experimental": true, + "dependencies": [ + "DOM" + ], + "types": [ + { + "id": "AXNodeId", + "description": "Unique accessibility node identifier.", + "type": "string" + }, + { + "id": "AXValueType", + "description": "Enum of possible property types.", + "type": "string", + "enum": [ + "boolean", + "tristate", + "booleanOrUndefined", + "idref", + "idrefList", + "integer", + "node", + "nodeList", + "number", + "string", + "computedString", + "token", + "tokenList", + "domRelation", + "role", + "internalRole", + "valueUndefined" + ] + }, + { + "id": "AXValueSourceType", + "description": "Enum of possible property sources.", + "type": "string", + "enum": [ + "attribute", + "implicit", + "style", + "contents", + "placeholder", + "relatedElement" + ] + }, + { + "id": "AXValueNativeSourceType", + "description": "Enum of possible native property sources (as a subtype of a particular AXValueSourceType).", + "type": "string", + "enum": [ + "description", + "figcaption", + "label", + "labelfor", + "labelwrapped", + "legend", + "rubyannotation", + "tablecaption", + "title", + "other" + ] + }, + { + "id": "AXValueSource", + "description": "A single source for a computed AX property.", + "type": "object", + "properties": [ + { + "name": "type", + "description": "What type of source this is.", + "$ref": "AXValueSourceType" + }, + { + "name": "value", + "description": "The value of this property source.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "attribute", + "description": "The name of the relevant attribute, if any.", + "optional": true, + "type": "string" + }, + { + "name": "attributeValue", + "description": "The value of the relevant attribute, if any.", + "optional": true, + "$ref": "AXValue" + }, + { + "name": "superseded", + "description": "Whether this source is superseded by a higher priority source.", + "optional": true, + "type": "boolean" + }, + { + "name": "nativeSource", + "description": "The native markup source for this value, e.g. a `