Skip to content
Merged
23 changes: 20 additions & 3 deletions packages/bcode-browser/script/embed-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
// The build script (`packages/opencode/script/build.ts`) calls
// `createEmbeddedHarnessBundle()` and plumbs the result into
// `Bun.build({ files: { "bcode-harness.gen.ts": <result> } })`. The generated
// virtual module exports `{ "<rel>": "<bunfs path>" }` for every harness file.
// `harness.ts` reads it lazily in compiled mode and extracts the files to a
// per-version cache dir on first use (decisions.md §4.6).
// virtual module exports `{ "<rel>": "<bunfs path>" }` for every harness file
// plus a content-hash `buildHash` used as the on-disk extraction sentinel.
// `harness.ts` reads it in compiled mode and extracts the files to
// `<dataDir>/harness/` on session start, skipping when the sentinel matches.
//
// The walk is glob-driven (not hand-enumerated): when skill files leave the
// repo for the cloud-fetch architecture (decisions.md §4.7) the embed shrinks
// automatically with no script change. Excludes mirror `harness/.gitignore`
// so local artifacts (`.venv/`, `__pycache__/`, `*.egg-info/`, etc.) never
// land in the binary.

import crypto from "crypto"
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"

Expand All @@ -29,6 +32,18 @@ const ignored = [
new Bun.Glob("**/uv.lock"),
]

// SHA-256 over (rel + NUL + content) for each file in sorted order. Stable
// across builds when content is identical, so warm launches skip extraction.
const computeBuildHash = async (files: string[]) => {
const hash = crypto.createHash("sha256")
for (const rel of files) {
hash.update(rel)
hash.update("\0")
hash.update(await fs.readFile(path.join(HARNESS_DIR, rel)))
}
return hash.digest("hex")
}

export const createEmbeddedHarnessBundle = async (buildCwd: string) => {
console.log("Embedding harness files into the binary")
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: HARNESS_DIR, dot: true })))
Expand All @@ -37,6 +52,7 @@ export const createEmbeddedHarnessBundle = async (buildCwd: string) => {
.sort()

console.log(`Embedding ${files.length} harness files`)
const buildHash = await computeBuildHash(files)

const imports = files.map((file, i) => {
const spec = path.relative(buildCwd, path.join(HARNESS_DIR, file)).replaceAll("\\", "/")
Expand All @@ -47,6 +63,7 @@ export const createEmbeddedHarnessBundle = async (buildCwd: string) => {
`// Auto-generated by packages/bcode-browser/script/embed-harness.ts`,
`// Maps "<rel>" -> bunfs path for every embedded harness file.`,
...imports,
`export const buildHash = ${JSON.stringify(buildHash)}`,
`export default {`,
...entries,
`} as Record<string, string>`,
Expand Down
59 changes: 39 additions & 20 deletions packages/bcode-browser/src/browser-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,39 @@
// pipe stdout+stderr back. BU_NAME is namespaced by sessionID so parallel
// sub-agents (each with their own session) get isolated daemons + browsers.
//
// BH_TMP_DIR points at a per-session scratch dir so sock/port/pid/log + screenshot
// output land somewhere predictable per session, instead of all sessions sharing
// /tmp. The Level-2 wrapper supplies the cache root; we own the layout convention.
// Two per-session dirs, separated by lifetime + path-length sensitivity:
// BH_TMP_DIR — screenshots, debug overlays, daemon log. Persistent under
// <dataDir>/sessions/<sid>/. Long path is fine; the cloud
// UI / read tool finds artifacts here.
// BH_RUNTIME_DIR — sock, port, pid. Volatile under <runtimeRoot>/bcode/<sid>/.
// Path-length budgeted on macOS (AF_UNIX sun_path = 104).
//
// Level 1 per decisions.md §1c — substantial implementation lives here. The
// Level-2 hook in packages/opencode is a one-line wrapper.

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 { resolveHarnessDir } from "./harness"
import { harnessArchiveDir, resolveHarnessDir } from "./harness"
import { uvLocate } from "./uv-locate"

// Canonical per-session scratch dir layout. Caller supplies dataDir
// (e.g. opencode's Global.Path.data); we own the `sessions/<id>` shape.
// AF_UNIX sun_path is 104 bytes on macOS — `<dataDir>/sessions/<sessionID>/bu-<sessionID>.sock`
// must fit. SessionID is `ses_` + 26 chars (30 chars). The literal suffix is
// `/sessions/` (10) + 30 + `/bu-` (4) + 30 + `.sock` (5) = 79 chars, leaving
// 25 chars of headroom for dataDir. Typical XDG dataDir is well under that.
// Per-session persistent scratch under <dataDir>/sessions/<sid>/. 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 <runtimeRoot>/bcode/<sid>/. 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)

const DEFAULT_TIMEOUT_MS = 60 * 1000
const MAX_TIMEOUT_MS = 10 * 60 * 1000

Expand All @@ -50,11 +60,12 @@ export type Parameters = Schema.Schema.Type<typeof parameters>

export interface ExecuteContext {
readonly sessionID: string
// Per-session scratch dir, passed to the harness as BH_TMP_DIR. The harness
// mkdirs it on import, but we mkdir-p here too so failures surface as a
// direct effect error rather than a child-process exit. Pre-compute via
// sessionScratchDir(dataDir, sessionID).
readonly bhTmpDir: 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
Expand Down Expand Up @@ -83,29 +94,37 @@ const isUvMissing = (err: unknown): boolean => {
return false
}

export const make = Effect.fn("BrowserExecute.make")(function* () {
// dataDir is opencode's XDG_DATA_HOME for bcode (~/.local/share/bcode/). The
// harness lives at <dataDir>/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))

const execute = (args: Parameters, ctx: ExecuteContext) =>
Effect.gen(function* () {
const harnessDir = yield* Effect.promise(() => resolveHarnessDir())
// 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.bhTmpDir, { recursive: true }))
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.bhTmpDir },
env: {
BU_NAME: ctx.sessionID,
BH_TMP_DIR: ctx.bhScratchDir,
BH_RUNTIME_DIR: ctx.bhRuntimeDir,
},
stdin: "ignore",
},
)
Expand Down Expand Up @@ -138,7 +157,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* () {
}),
)

return { parameters, execute }
return { parameters, execute, harnessDir, harnessArchiveDir: harnessArchiveDir(dataDir) }
})

export * as BrowserExecute from "./browser-execute"
130 changes: 100 additions & 30 deletions packages/bcode-browser/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,31 @@
// `import.meta.url` lives under `/$bunfs/` (or `B:/~BUN/` on Windows), a
// read-only virtual filesystem. uv cannot write `.venv/` there. We extract
// the embedded harness (built into the binary by `script/embed-harness.ts`)
// to a single un-versioned directory at `~/.cache/bcode/harness/`.
// to `<dataDir>/harness/`, where dataDir is opencode's XDG_DATA_HOME for
// bcode (~/.local/share/bcode/ on Linux/Mac). The harness is data, not
// cache: it accumulates agent edits to `agent-workspace/agent_helpers.py`
// that must outlive a `~/.cache` wipe.
//
// Per decisions §4.8, the cache is **un-versioned** so agent edits to
// `agent-workspace/agent_helpers.py` survive binary upgrades. Extraction
// policy on every launch: walk the embed map and write each file out, with
// one exception — `agent-workspace/agent_helpers.py` is preserved if
// already present. Everything else (`src/browser_harness/*.py`,
// `pyproject.toml`, skills, etc.) is overwritten unconditionally; the
// binary is the source of truth for those, and we want curated skill /
// daemon / setup updates to land on upgrade.
// `agent-workspace/agent_helpers.py` is the one Green-zone file (decisions
// §3.7, §4.5) where agent learnings accumulate and must outlive upgrades.
// Upstream moved the agent-editable surface from root `helpers.py` to
// `agent-workspace/agent_helpers.py` in PR #229; the core `helpers.py`
// inside `src/browser_harness/` is now baseline-overwrite.
// A content-hash sentinel at `<harness>/.bcode-build` records the embed
// bundle that produced the on-disk tree. On session start we compare it to
// the bundle hash and skip extraction when they match — warm launches cost
// one stat. Mismatch (binary upgrade) snapshots the active tree to
// `<dataDir>/harness-archive/<old-buildHash>/` (excluding `.venv/` and
// `__pycache__/`) so the agent can read the old skills + helpers when
// migrating its own customizations, then re-extracts every embed file
// except anything under `agent-workspace/` (the Green-zone subtree —
// decisions §3.7, §4.5: agent_helpers.py and any agent-authored files
// like domain-skills/<host>/*.md persist across upgrades). The core
// `src/browser_harness/` package and shipped skill files are
// baseline-overwrite.
//
// Concurrent first-callers are deduplicated via an in-process promise.
// Bun.write is atomic per file; cross-process races just result in the
// same bytes being written, which is fine.
//
// On first launch after the relocation, any pre-existing harness at the
// legacy `~/.cache/bcode/harness/` is moved to the new location so agent
// edits under `agent-workspace/` survive the upgrade.

import fs from "fs/promises"
import os from "os"
Expand All @@ -47,41 +53,105 @@ const isCompiled = (() => {
return d.startsWith("/$bunfs/") || d.startsWith("B:/~BUN/")
})()
const DEV_HARNESS_DIR = path.resolve(__dirname, "..", "harness")
const cachedHarnessDir = path.join(os.homedir(), ".cache", "bcode", "harness")
const LEGACY_CACHE_DIR = path.join(os.homedir(), ".cache", "bcode", "harness")
const SENTINEL_NAME = ".bcode-build"

// Embed paths that are agent-editable and must be preserved across binary
// upgrades. Per decisions §3.7 / §4.5 the entire `agent-workspace/` subtree
// is the Green zone (agent_helpers.py plus any agent-authored files such as
// domain-skills/<host>/*.md). The core `src/browser_harness/` package and
// shipped skill files are baseline-overwrite.
const PRESERVED_PREFIX = "agent-workspace/"

// Compute the harness directory for a given dataDir without touching the
// filesystem. The agent permission whitelist uses this; runtime extraction
// uses `resolveHarnessDir`.
export const harnessDir = (dataDir: string) => path.join(dataDir, "harness")

// Files that are agent-editable and must be preserved across binary upgrades.
// Everything in the embed map that isn't in this set is baseline-overwrite.
// Per decisions §3.7 / §4.5: only `agent-workspace/agent_helpers.py` is
// Green-zone editable inside the harness. The core `src/browser_harness/`
// package (daemon, admin, helpers, run, _ipc) is baseline-only.
const PRESERVED_PATHS = new Set(["agent-workspace/agent_helpers.py"])
// Where past-version snapshots live. Each subdir is named for the buildHash
// of the harness it was extracted from. Read-only after creation.
export const harnessArchiveDir = (dataDir: string) => path.join(dataDir, "harness-archive")

// Skipped during archive copies — regenerable (.venv) or junk (__pycache__).
// Match by basename at any depth so nested __pycache__/ inside src/ is also
// excluded.
const ARCHIVE_EXCLUDE = new Set([".venv", "__pycache__"])

const exists = (p: string) => fs.access(p).then(() => true, () => false)

const extractEmbeddedHarness = async (): Promise<string> => {
const readSentinel = async (dir: string) => {
try { return await fs.readFile(path.join(dir, SENTINEL_NAME), "utf8") }
catch { return null }
}

const migrateLegacyIfPresent = async (target: string) => {
if (!(await exists(LEGACY_CACHE_DIR))) return
if (await exists(target)) return
await fs.mkdir(path.dirname(target), { recursive: true })
try { await fs.rename(LEGACY_CACHE_DIR, target) }
catch (err) {
if ((err as { code?: string }).code !== "EXDEV") throw err
await fs.cp(LEGACY_CACHE_DIR, target, { recursive: true })
await fs.rm(LEGACY_CACHE_DIR, { recursive: true, force: true })
}
}

const archiveExistingHarness = async (dataDir: string, target: string, oldHash: string) => {
const archiveTarget = path.join(harnessArchiveDir(dataDir), oldHash)
if (await exists(archiveTarget)) return // already archived (re-entry); nothing to do
await fs.mkdir(harnessArchiveDir(dataDir), { recursive: true })
await fs.cp(target, archiveTarget, {
recursive: true,
filter: (src) => !ARCHIVE_EXCLUDE.has(path.basename(src)),
})
}

const extractEmbeddedHarness = async (dataDir: string): Promise<string> => {
const target = harnessDir(dataDir)
await migrateLegacyIfPresent(target)

// @ts-expect-error generated at build time
const mod = await import("bcode-harness.gen.ts").catch(() => null)
if (!mod) throw new Error("bcode-harness.gen.ts not found in compiled binary — was the build script updated?")
const fileMap = mod.default as Record<string, string>
const buildHash = mod.buildHash as string

const existing = await readSentinel(target)
if (existing === buildHash) return target
if (existing) await archiveExistingHarness(dataDir, target, existing)

await fs.mkdir(cachedHarnessDir, { recursive: true })
await fs.mkdir(target, { recursive: true })
await Promise.all(
Object.entries(fileMap).map(async ([rel, bunfsPath]) => {
const dest = path.join(cachedHarnessDir, rel)
if (PRESERVED_PATHS.has(rel) && (await exists(dest))) return
const dest = path.join(target, rel)
if (rel.startsWith(PRESERVED_PREFIX) && (await exists(dest))) return
await fs.mkdir(path.dirname(dest), { recursive: true })
await Bun.write(dest, Bun.file(bunfsPath))
}),
)
return cachedHarnessDir
await fs.writeFile(path.join(target, SENTINEL_NAME), buildHash, "utf8")
return target
}

let extractPromise: Promise<string> | null = null
// Per-dataDir cache. In production opencode passes the same Global.Path.data
// every call, so this is effectively a singleton; tests and any future
// multi-instance setup that resolves against multiple dataDirs each get their
// own deduplicated extraction without cross-directory contamination.
const extractCache = new Map<string, Promise<string>>()

export const resolveHarnessDir = (): Promise<string> => {
export const resolveHarnessDir = (dataDir: string): Promise<string> => {
if (!isCompiled) return Promise.resolve(DEV_HARNESS_DIR)
if (!extractPromise) extractPromise = extractEmbeddedHarness()
return extractPromise
const cached = extractCache.get(dataDir)
if (cached) return cached
const fresh = extractEmbeddedHarness(dataDir)
extractCache.set(dataDir, fresh)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// Evict on rejection so a transient failure (FS hiccup, partial write) doesn't
// permanently brick subsequent calls. The `===` guard avoids clobbering a
// retry that started after the failure but before this handler fired.
fresh.catch(() => {
if (extractCache.get(dataDir) === fresh) extractCache.delete(dataDir)
})
return fresh
}

export * as Harness from "./harness"
Loading
Loading