From b8f651ffff6460f9a3a0809a399f821ce1515d9c Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Thu, 6 Aug 2026 10:27:05 -0700 Subject: [PATCH 1/6] feat(bcode-serve): add serve-only binary variant for headless containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V4 worker spends most of its cold startup waiting for `bcode serve` to become ready: `worker.bcode.listen` is ~1.50s p50 in production, ~54% of the measured worker cold path (ENG-5671). Profiling the compiled binary shows the cost is spread across the whole server import graph rather than concentrated in a few deferrable modules — whichever of `provider`, `session/processor` or `tool/registry` is imported first pays ~200ms and the rest then cost ~0ms. So there is no cheap module to defer, and restructuring 112 imports is not worth the merge friction against upstream. Two levers that do work, applied together: - bytecode compilation: skips JS parse at boot, -33% spawn-to-listening - dropping the 23 unused command modules and the embedded web UI The second matters mostly for size, and the two compound: excluding the unused commands saves 16MB without bytecode but 81MB with it, since dead code also carries compiled bytecode. Measured on darwin-arm64, medians of 7, no web UI: entrypoint plain bytecode opencode index.ts 108MB / 412ms 310MB / 266ms bcode-serve 92MB / 381ms 229MB / 257ms On linux-arm64 (the container target) the variant is 257MB vs 148MB standard — +110MB on a 744MB image, well inside AgentCore's 2GB cap. This lives in its own package so `packages/opencode`, which is forked from upstream and synced regularly, stays byte-for-byte untouched. Everything is additive: the canonical build step still runs first and unmodified, the new step is `continue-on-error` and writes different asset names, so it can never block or clobber a normal release. Cloud consumes the release asset through the install one-liner, so install.sh gains `--variant serve`. `check_version` had to learn about it too — a variant swap keeps the same version string, so the "already installed" short-circuit would otherwise skip a standard -> serve switch. The new build script duplicates the Bun.build config rather than editing the upstream one; to catch drift its smoke test boots `serve` and waits for the listening banner instead of just running `--version`, which a missing build-time `define` would sail straight past. --- .github/workflows/release.yml | 28 ++++ bun.lock | 18 +++ install.sh | 30 ++++ package.json | 2 +- packages/bcode-serve/package.json | 25 ++++ packages/bcode-serve/script/build.ts | 212 +++++++++++++++++++++++++++ packages/bcode-serve/src/index.ts | 149 +++++++++++++++++++ packages/bcode-serve/tsconfig.json | 20 +++ 8 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 packages/bcode-serve/package.json create mode 100644 packages/bcode-serve/script/build.ts create mode 100644 packages/bcode-serve/src/index.ts create mode 100644 packages/bcode-serve/tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a5b2f8530..25c294baff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,34 @@ jobs: run: | ./packages/opencode/script/build.ts + - name: Build serve-only variant and upload to release + # Additive: produces `bcode-linux-{arm64,x64}-serve.tar.gz` alongside the + # standard assets, for the headless V4 container (ENG-5671). It registers + # only `bcode serve`, drops the embedded web UI, and is bytecode-compiled + # — roughly -36% spawn-to-listening versus the standard binary. + # + # Built from its own package (`packages/bcode-serve`) so that + # `packages/opencode` — forked from upstream and synced regularly — + # stays untouched. The canonical build step above is unmodified. + # + # Runs AFTER the canonical build and is `continue-on-error` on purpose: + # this variant must never be able to block or alter a normal release. It + # writes different asset names, so `--clobber` cannot overwrite the + # standard archives. + # + # Linux only — the only consumer is the container image. Add targets here + # if that changes. + continue-on-error: true + env: + OPENCODE_VERSION: ${{ steps.ver.outputs.version }} + OPENCODE_RELEASE: "1" + OPENCODE_CHANNEL: latest + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BCODE_DEFAULT_LMNR_KEY: ${{ secrets.LMNR_PROJECT_API_KEY_OSS }} + run: | + ./packages/bcode-serve/script/build.ts --targets linux-arm64,linux-x64 + - name: Summarise uploaded assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/bun.lock b/bun.lock index a90b74132e..836eb116c7 100644 --- a/bun.lock +++ b/bun.lock @@ -123,6 +123,22 @@ "@types/bun": "catalog:", }, }, + "packages/bcode-serve": { + "name": "@browser-use/bcode-serve", + "version": "0.0.0", + "dependencies": { + "@browser-use/bcode-browser": "workspace:*", + "@browser-use/browsercode-core": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/script": "workspace:*", + "yargs": "18.0.0", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/yargs": "17.0.33", + }, + }, "packages/cli": { "name": "@opencode-ai/cli", "version": "1.18.4", @@ -1483,6 +1499,8 @@ "@browser-use/bcode-laminar": ["@browser-use/bcode-laminar@workspace:packages/bcode-laminar"], + "@browser-use/bcode-serve": ["@browser-use/bcode-serve@workspace:packages/bcode-serve"], + "@browser-use/browsercode-core": ["@browser-use/browsercode-core@workspace:packages/opencode"], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], diff --git a/install.sh b/install.sh index 2106a38bb2..adeb565f66 100755 --- a/install.sh +++ b/install.sh @@ -34,11 +34,16 @@ Options: -h, --help Display this help message -v, --version Install a specific version (e.g. 0.0.3) -b, --binary Install from a local binary instead of downloading + --variant Install a build variant instead of the standard binary. + Currently: 'serve' — a headless, serve-only build for + containers. It provides ONLY 'bcode serve'; every other + subcommand is absent. Linux only. --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) Examples: curl -fsSL https://bcode.sh/install | bash curl -fsSL https://bcode.sh/install | bash -s -- --version 0.0.3 + curl -fsSL https://bcode.sh/install | bash -s -- --version 0.0.3 --variant serve ./install.sh --binary /path/to/bcode EOF } @@ -46,6 +51,7 @@ EOF requested_version=${VERSION:-} no_modify_path=false binary_path="" +variant=${BCODE_VARIANT:-} while [[ $# -gt 0 ]]; do case "$1" in @@ -71,6 +77,15 @@ while [[ $# -gt 0 ]]; do exit 1 fi ;; + --variant) + if [[ -n "${2:-}" ]]; then + variant="$2" + shift 2 + else + echo -e "${RED}Error: --variant requires a name argument${NC}" + exit 1 + fi + ;; --no-modify-path) no_modify_path=true shift @@ -180,6 +195,15 @@ else if [ "$is_musl" = "true" ]; then target="$target-musl" fi + # Variant marker goes last, matching the asset naming in + # packages/bcode-serve/script/build.ts (`-[-baseline][-musl][-serve]`). + if [ -n "$variant" ]; then + if [ "$os" != "linux" ]; then + echo -e "${RED}Error: --variant ${variant} is only published for linux (detected: ${os})${NC}" + exit 1 + fi + target="$target-$variant" + fi filename="$APP-$target$archive_ext" @@ -233,6 +257,12 @@ print_message() { } check_version() { + # A variant swap keeps the same version string, so the "already installed" + # short-circuit would skip a standard -> serve switch. Always reinstall when + # a variant is requested. + if [ -n "$variant" ]; then + return + fi if command -v bcode >/dev/null 2>&1; then installed_version=$(bcode --version 2>/dev/null || echo "") diff --git a/package.json b/package.json index a19a602479..6d051b539a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", - "typecheck": "bun turbo typecheck --filter='@browser-use/browsercode-core...' --filter='@browser-use/bcode-browser' --filter='@browser-use/bcode-laminar'", + "typecheck": "bun turbo typecheck --filter='@browser-use/browsercode-core...' --filter='@browser-use/bcode-browser' --filter='@browser-use/bcode-laminar' --filter='@browser-use/bcode-serve'", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", "prepare": "husky", diff --git a/packages/bcode-serve/package.json b/packages/bcode-serve/package.json new file mode 100644 index 0000000000..1ef766ac85 --- /dev/null +++ b/packages/bcode-serve/package.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "0.0.0", + "name": "@browser-use/bcode-serve", + "description": "Serve-only bcode binary variant for headless containers (ENG-5671)", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "bun run script/build.ts" + }, + "dependencies": { + "@browser-use/bcode-browser": "workspace:*", + "@browser-use/browsercode-core": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/script": "workspace:*", + "yargs": "18.0.0" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/yargs": "17.0.33" + } +} diff --git a/packages/bcode-serve/script/build.ts b/packages/bcode-serve/script/build.ts new file mode 100644 index 0000000000..9987f59197 --- /dev/null +++ b/packages/bcode-serve/script/build.ts @@ -0,0 +1,212 @@ +#!/usr/bin/env bun +// +// Builds the `bcode--serve` binary variant (ENG-5671). +// +// Deliberately separate from `packages/opencode/script/build.ts`: that package +// is forked from upstream and synced regularly, so it stays byte-for-byte +// untouched. This script produces an *additional* release asset and never +// writes to the standard one — different dist dir, different asset name. +// +// Differences from the standard build: +// - entrypoint is this package's serve-only `src/index.ts` (1 command, not 24) +// - no embedded web UI (headless; `embeddedUI()` already handles its absence) +// - no TUI worker entrypoint (reachable only from `cli/cmd/tui.ts`) +// - `bytecode: true` — skips JS parse at boot, ~-33% spawn-to-listening +// - linux only by default; the only consumer is the container image +// +// The Bun.build config below mirrors the standard build's. It has to be kept in +// sync by hand — the alternative is editing the upstream file, which is what +// this package exists to avoid. The smoke test boots `serve` for real, so a +// missing `define` or a broken graph fails here rather than in production. +// +// Usage: +// bun run script/build.ts # host target, no upload +// bun run script/build.ts --targets linux-arm64 # cross-compile +// OPENCODE_RELEASE=1 bun run script/build.ts ... # archive + gh upload + +import { $ } from "bun" +import path from "path" +import { fileURLToPath } from "url" + +const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const opencodeDir = path.resolve(dir, "../opencode") + +// `generate.ts` chdirs into packages/opencode as an import side effect; restore +// ours afterwards so the skills bundle's relative specifiers resolve correctly. +const generated = await import(path.join(opencodeDir, "script/generate.ts")) +process.chdir(dir) + +import { Script } from "@opencode-ai/script" +import { createEmbeddedSkillsBundle } from "../../bcode-browser/script/embed-skills.ts" +import opencodePkg from "../../opencode/package.json" + +const skipInstall = process.argv.includes("--skip-install") +const noBytecodeFlag = process.argv.includes("--no-bytecode") + +// Target allowlist, matched against `-[-baseline][-musl]`. Defaults to +// the host target so a local `bun run build` is quick; release CI passes linux. +const targetsArg = process.argv.includes("--targets") + ? process.argv[process.argv.indexOf("--targets") + 1] + : process.env.BCODE_SERVE_TARGETS + +const allTargets: { + os: string + arch: "arm64" | "x64" + abi?: "musl" + avx2?: false +}[] = [ + { os: "linux", arch: "arm64" }, + { os: "linux", arch: "x64" }, + { os: "linux", arch: "arm64", abi: "musl" }, + { os: "linux", arch: "x64", abi: "musl" }, + { os: "darwin", arch: "arm64" }, + { os: "darwin", arch: "x64" }, +] + +const targetSuffixFor = (item: (typeof allTargets)[number]) => + [item.os, item.arch, item.avx2 === false ? "baseline" : undefined, item.abi] + .filter(Boolean) + .join("-") + +const targets = targetsArg + ? (() => { + const wanted = new Set( + targetsArg + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + ) + const matched = allTargets.filter((item) => wanted.has(targetSuffixFor(item))) + if (matched.length === 0) { + console.error( + `No targets matched --targets ${targetsArg}. Known: ${allTargets.map(targetSuffixFor).join(", ")}`, + ) + process.exit(1) + } + return matched + })() + : allTargets.filter((item) => item.os === process.platform && item.arch === process.arch && !item.abi) + +const embeddedSkillsFileMap = await createEmbeddedSkillsBundle(dir) + +await $`rm -rf dist` + +if (!skipInstall) { + // Cross-compiling needs the other platforms' native artifacts present. + await $`bun install --os="*" --cpu="*" @ff-labs/fff-bun@${opencodePkg.dependencies["@ff-labs/fff-bun"]}`.cwd( + opencodeDir, + ) + await $`bun install --os="*" --cpu="*" @parcel/watcher@${opencodePkg.dependencies["@parcel/watcher"]}`.cwd( + opencodeDir, + ) +} + +const archives: string[] = [] + +for (const item of targets) { + const targetSuffix = targetSuffixFor(item) + const assetName = `bcode-${targetSuffix}-serve` // release archive basename + const outdir = `dist/${assetName}` + console.log(`building ${assetName}`) + await $`mkdir -p ${outdir}/bin` + + const skillsPath = "bcode-skills.gen.ts" + + const result = await Bun.build({ + conditions: ["bun", "node"], + // The opencode tsconfig supplies the `@/*` -> packages/opencode/src/* + // path mapping that its own sources rely on. + tsconfig: path.join(opencodeDir, "tsconfig.json"), + external: ["node-gyp"], + format: "esm", + minify: true, + sourcemap: "none", + splitting: true, + bytecode: !noBytecodeFlag, + compile: { + autoloadBunfig: false, + autoloadDotenv: false, + autoloadTsconfig: true, + autoloadPackageJson: true, + target: `bun-${targetSuffix}` as any, + outfile: path.join(dir, outdir, "bin/bcode"), + execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], + windows: {}, + }, + files: { + [skillsPath]: embeddedSkillsFileMap, + }, + entrypoints: ["./src/index.ts", skillsPath], + define: { + FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"), + OPENCODE_VERSION: `'${Script.version}'`, + OPENCODE_MODELS_DEV: generated.modelsData, + // TUI-only, but referenced behind a `typeof` guard in opencode sources — + // define it so a stray reference cannot throw ReferenceError. + OTUI_TREE_SITTER_WORKER_PATH: `''`, + OPENCODE_WORKER_PATH: `''`, + OPENCODE_CHANNEL: `'${Script.channel}'`, + OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", + // Build-time-embedded Laminar project key. Populated by release CI from + // the LMNR_PROJECT_API_KEY_OSS secret; empty for local builds. Runtime use + // is gated in @browser-use/bcode-browser/src/telemetry.ts. + BCODE_DEFAULT_LMNR_KEY: JSON.stringify(process.env.BCODE_DEFAULT_LMNR_KEY ?? ""), + ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), + }, + }) + if (!result.success) { + console.error(result.logs) + process.exit(1) + } + + // Smoke test: boot the server for real, not just `--version`. A missing + // `define` or a module dropped from the graph shows up as a failure to reach + // the listening banner, which `--version` would sail straight past. + if (item.os === process.platform && item.arch === process.arch && !item.abi) { + const binaryPath = path.join(dir, outdir, "bin/bcode") + console.log(`Smoke test: ${assetName} serve`) + const proc = Bun.spawn([binaryPath, "serve", "--port", "0", "--hostname", "127.0.0.1"], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, OPENCODE_SERVER_PASSWORD: "smoke-test" }, + }) + const listening = (async () => { + const reader = proc.stdout.getReader() + const decoder = new TextDecoder() + let buffered = "" + while (true) { + const { value, done } = await reader.read() + if (done) break + buffered += decoder.decode(value, { stream: true }) + if (buffered.includes("listening on")) return buffered + } + const stderr = await new Response(proc.stderr).text() + throw new Error(`server exited before listening.\nstdout:\n${buffered}\nstderr:\n${stderr}`) + })() + try { + const banner = await Promise.race([ + listening, + new Promise((_, reject) => setTimeout(() => reject(new Error("timed out after 30s")), 30_000)), + ]) + console.log(`Smoke test passed: ${banner.trim().split("\n").at(-1)}`) + } catch (e) { + console.error(`Smoke test failed for ${assetName}:`, e) + proc.kill() + process.exit(1) + } finally { + proc.kill() + await proc.exited + } + } + + if (Script.release) { + // linux only, so tar for everything; add a zip branch if darwin ships. + await $`tar -czf ../../${assetName}.tar.gz *`.cwd(`${outdir}/bin`) + archives.push(`./dist/${assetName}.tar.gz`) + } +} + +if (Script.release) { + await $`gh release upload v${Script.version} ${archives} --clobber --repo ${process.env.GH_REPO}` + console.log(`uploaded: ${archives.join(", ")}`) +} diff --git a/packages/bcode-serve/src/index.ts b/packages/bcode-serve/src/index.ts new file mode 100644 index 0000000000..315ab6f5c4 --- /dev/null +++ b/packages/bcode-serve/src/index.ts @@ -0,0 +1,149 @@ +// Serve-only entrypoint for the `bcode--serve` binary variant (ENG-5671). +// +// `packages/opencode/src/index.ts` eagerly imports all 24 command modules before +// yargs parses. The headless V4 runtime only ever invokes `bcode serve`, so the +// other 23 — TUI, run, web, github, pr, stats, import/export, db — are dead +// weight in its binary. They cost little at boot (the serve module graph already +// pulls the expensive shared core), but they cost real bytes, and bytes decide +// whether bytecode compilation is affordable: +// +// entrypoint plain bytecode +// opencode index.ts 108 MB / 412 ms 310 MB / 266 ms +// this file 92 MB / 381 ms 229 MB / 257 ms +// +// (darwin-arm64, no embedded web UI, spawn-to-listening-banner, medians of 7.) +// Excluding the unused commands saves 16 MB without bytecode but 81 MB with it — +// dead code is ~5x more expensive once every function also carries compiled +// bytecode. +// +// This package exists so none of that touches `packages/opencode`, which is +// forked from upstream and synced regularly. Everything here is additive: a new +// package, a new release asset, and an opt-in installer flag. The standard +// binary is built and published exactly as before. +// +// DRIFT WARNING: the global-option and lifecycle wiring below is duplicated from +// `packages/opencode/src/index.ts`, which stays the source of truth. Global +// options added there must be mirrored here. The build script's smoke test boots +// `serve` for real (not just `--version`) so that a missing build-time `define` +// or a broken module graph fails the build rather than the deployment. + +// Telemetry key injection runs as an import side effect of this module, before +// any subsequent import is evaluated. Keep this as the FIRST import so the +// LMNR_PROJECT_API_KEY env var is settled before any downstream module-load code +// reads it. (Same ordering contract as packages/opencode/src/index.ts.) +import "@browser-use/bcode-browser/telemetry" + +import yargs from "yargs" +import { hideBin } from "yargs/helpers" +import { EOL } from "os" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ServeCommand } from "@browser-use/browsercode-core/cli/cmd/serve" +import { UI } from "@browser-use/browsercode-core/cli/ui" +import { FormatError } from "@browser-use/browsercode-core/cli/error" +import { errorMessage } from "@browser-use/browsercode-core/util/error" +import { Heap } from "@browser-use/browsercode-core/cli/heap" + +const args = hideBin(process.argv) + +function show(out: string) { + const text = out.trimStart() + if (!text.startsWith("bcode ")) { + process.stderr.write(UI.logo() + EOL + EOL) + process.stderr.write(text + EOL) + return + } + process.stderr.write(out) +} + +const cli = yargs(args) + .parserConfiguration({ "populate--": true }) + .scriptName("bcode") + .wrap(100) + .help("help", "show help") + .alias("help", "h") + .version("version", "show version number", InstallationVersion) + .alias("version", "v") + .option("print-logs", { + describe: "print logs to stderr", + type: "boolean", + }) + .option("log-level", { + describe: "log level", + type: "string", + choices: ["DEBUG", "INFO", "WARN", "ERROR"], + }) + .option("pure", { + describe: "run without external plugins", + type: "boolean", + }) + .middleware(async (opts) => { + if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" + if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel + if (opts.pure) { + process.env.OPENCODE_PURE = "1" + } + + Heap.start() + + process.env.AGENT = "1" + process.env.OPENCODE = "1" + process.env.OPENCODE_PID = String(process.pid) + }) + .usage("") + .command(ServeCommand) + .fail((msg, err) => { + if ( + msg?.startsWith("Unknown argument") || + msg?.startsWith("Not enough non-option arguments") || + msg?.startsWith("Invalid values:") + ) { + if (err) throw err + cli.showHelp(show) + } + if (err) throw err + process.exit(1) + }) + .strict() + +try { + if (args.includes("-h") || args.includes("--help")) { + await cli.parse(args, (err: Error | undefined, _argv: unknown, out: string) => { + if (err) throw err + if (!out) return + show(out) + }) + } else { + await cli.parse() + } +} catch (e) { + const formatted = FormatError(e) + if (formatted) UI.error(formatted) + if (formatted === undefined) { + UI.error("Unexpected error" + EOL) + process.stderr.write(errorMessage(e) + EOL) + } + process.exitCode = 1 +} finally { + // Plugin shutdown hooks are the single drain point for OTel-based plugins + // (e.g. bcode-laminar) — without this the V4 worker loses trailing spans. + // Mirrors the drain in packages/opencode/src/index.ts; see the note there for + // why the host-side forceFlush fallback was dropped. + try { + const { pluginShutdownHooks } = await import("@browser-use/browsercode-core/plugin/index") + await Promise.race([ + Promise.allSettled( + Array.from(pluginShutdownHooks).map((hook) => + Promise.resolve() + .then(hook) + .catch((err: Error) => console.error("plugin shutdown hook failed", err)), + ), + ), + new Promise((resolve) => setTimeout(resolve, 3000)), + ]) + } catch (err) { + console.error("plugin shutdown import failed", err) + } + // Some subprocesses don't react properly to SIGTERM and similar signals. + // Explicitly exit to avoid any hanging subprocesses. + process.exit() +} diff --git a/packages/bcode-serve/tsconfig.json b/packages/bcode-serve/tsconfig.json new file mode 100644 index 0000000000..898ae660e8 --- /dev/null +++ b/packages/bcode-serve/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "noUncheckedIndexedAccess": false, + "customConditions": ["browser"], + // `packages/opencode` sources reach each other through `@/*`. We import a + // handful of them directly, so tsc needs the same mapping the bundler gets + // from `packages/opencode/tsconfig.json`. + "paths": { + "@/*": ["../opencode/src/*"] + } + }, + // `packages/opencode/src/*.d.ts` carries ambient module declarations (`*.wasm`, + // `*.sql`, `*.md`) that its sources rely on. They're picked up automatically + // inside that package; pulling them in explicitly keeps typecheck honest here. + "include": ["src/**/*", "script/**/*", "../opencode/src/*.d.ts"] +} From cd05aaec66325c3305e28f43b28d57679a0af9d3 Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Thu, 6 Aug 2026 10:36:02 -0700 Subject: [PATCH 2/6] refactor(install): serve the bytecode build from its own URL, leave install.sh alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the `--variant serve` flag added to install.sh in the previous commit and ships a separate `install-bytecode.sh` instead, intended to be hosted at bcode.sh/bytecode alongside (not replacing) bcode.sh/install. install.sh is the published, documented entrypoint that the README and every existing doc point at. Adding a flag to it meant every future change to the variant touched the script humans install with. A second script and a second URL keeps the blast radius at zero: nothing can change what /install serves. The new script takes install.sh's flags, so switching is a one-word change to the URL in a Dockerfile: curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version $V It is deliberately much shorter than install.sh: the variant ships one build per arch (no baseline/AVX2 or musl detection), is linux-only, and targets containers that set PATH in the Dockerfile (no shell-rc editing, no uv hint). `--no-modify-path` is accepted as a no-op so an existing invocation works verbatim. Releases predating this variant do not publish the asset, so a missing asset reports that explicitly rather than letting tar fail on a 404 body. Note: the bcode.sh domain and its path routing are not configured in this repo — only the scripts live here. Mapping /bytecode to this file has to happen wherever /install is currently mapped. --- install-bytecode.sh | 186 ++++++++++++++++++++++++++++++++++++++++++++ install.sh | 30 ------- 2 files changed, 186 insertions(+), 30 deletions(-) create mode 100755 install-bytecode.sh diff --git a/install-bytecode.sh b/install-bytecode.sh new file mode 100755 index 0000000000..e101af7bce --- /dev/null +++ b/install-bytecode.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# BrowserCode installer — bytecode / serve-only build. +# +# Intended to be hosted at https://bcode.sh/bytecode, alongside (not replacing) +# https://bcode.sh/install. One-liner: +# +# curl -fsSL https://bcode.sh/bytecode | bash +# curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version 0.0.3 +# +# WHAT THIS INSTALLS, AND HOW IT DIFFERS +# +# The `bcode-linux--serve` release asset. It is bytecode-compiled and +# built from a serve-only entrypoint, which makes it roughly 35% faster from +# spawn to listening — but it provides ONLY `bcode serve`. `run`, `tui`, `web`, +# `github`, `auth`, and every other subcommand are absent and exit 1. +# +# That is the whole point: it exists for headless containers that shell out to +# `bcode serve` and nothing else. If you are a human installing bcode on a +# laptop, you want https://bcode.sh/install instead. +# +# Linux only — the only consumer is the container image, so the release workflow +# only builds linux targets for this variant. +# +# WHY A SEPARATE SCRIPT +# +# `install.sh` is the published, documented path that people and docs point at. +# It stays untouched. This is additive: a second script, a second URL, a second +# release asset. Nothing here can change what `bcode.sh/install` serves. +# +# Deliberately much shorter than install.sh: no baseline/AVX2 detection (the +# variant ships one build per arch), no shell-rc editing (containers set PATH in +# the Dockerfile), no uv hint. Accepts install.sh's flags so switching is a +# one-word change to the URL in a Dockerfile. +set -euo pipefail + +APP=bcode +VARIANT=serve +REPO=browser-use/browsercode + +MUTED='\033[0;2m' +RED='\033[0;31m' +ORANGE='\033[38;5;214m' +NC='\033[0m' + +usage() { + cat < Install a specific version (e.g. 0.0.3) + --install-dir Install to (default: \$HOME/.bcode/bin) + --no-modify-path Accepted for parity with install.sh; this script + never edits shell config files. + +Examples: + curl -fsSL https://bcode.sh/bytecode | bash + curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version 0.0.3 +EOF +} + +requested_version=${VERSION:-} +install_dir=${BCODE_INSTALL_DIR:-$HOME/.bcode/bin} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + -v|--version) + if [[ -n "${2:-}" ]]; then + requested_version="$2" + shift 2 + else + echo -e "${RED}Error: --version requires a version argument${NC}" >&2 + exit 1 + fi + ;; + --install-dir) + if [[ -n "${2:-}" ]]; then + install_dir="$2" + shift 2 + else + echo -e "${RED}Error: --install-dir requires a path argument${NC}" >&2 + exit 1 + fi + ;; + --no-modify-path) + # No-op: this script never touches shell config. Accepted so an + # existing `install.sh` invocation works verbatim against this URL. + shift + ;; + *) + echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2 + shift + ;; + esac +done + +raw_os=$(uname -s) +case "$raw_os" in + Linux*) os="linux" ;; + *) + echo -e "${RED}Error: the ${VARIANT} build is published for linux only (detected: ${raw_os}).${NC}" >&2 + echo -e "${MUTED}Use https://bcode.sh/install for the standard cross-platform binary.${NC}" >&2 + exit 1 + ;; +esac + +arch=$(uname -m) +case "$arch" in + aarch64|arm64) arch="arm64" ;; + x86_64|amd64) arch="x64" ;; + *) + echo -e "${RED}Error: unsupported architecture '${arch}'. Supported: arm64, x64.${NC}" >&2 + exit 1 + ;; +esac + +for tool in curl tar; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo -e "${RED}Error: '${tool}' is required but not installed.${NC}" >&2 + exit 1 + fi +done + +filename="${APP}-${os}-${arch}-${VARIANT}.tar.gz" + +if [ -z "$requested_version" ]; then + url="https://github.com/${REPO}/releases/latest/download/${filename}" + specific_version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null \ + | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' || true) + if [ -z "$specific_version" ]; then + echo -e "${RED}Failed to resolve the latest version.${NC}" >&2 + echo -e "${MUTED}If the repo is private, pin a version: --version ${NC}" >&2 + exit 1 + fi +else + specific_version="${requested_version#v}" + url="https://github.com/${REPO}/releases/download/v${specific_version}/${filename}" +fi + +echo -e "${MUTED}Installing ${NC}${APP} ${MUTED}(${VARIANT} build) version: ${NC}${specific_version}" + +tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bcode_bytecode_install.XXXXXX") +cleanup() { rm -rf "$tmp_dir"; } +trap cleanup EXIT + +# Fail loudly on a missing asset rather than unpacking a 404 body. The variant +# is only published from the release after this script landed, so an older tag +# legitimately won't have it — say so instead of erroring out of tar. +if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url"; then + echo -e "${RED}Error: could not download ${filename} for v${specific_version}.${NC}" >&2 + echo -e "${MUTED}URL: ${url}${NC}" >&2 + echo -e "${MUTED}Releases predating the ${VARIANT} variant do not publish this asset.${NC}" >&2 + echo -e "${MUTED}Check https://github.com/${REPO}/releases for one that does, or use https://bcode.sh/install.${NC}" >&2 + exit 1 +fi + +tar -xzf "${tmp_dir}/${filename}" -C "$tmp_dir" + +if [ ! -f "${tmp_dir}/${APP}" ]; then + echo -e "${RED}Error: archive did not contain a '${APP}' binary.${NC}" >&2 + exit 1 +fi + +mkdir -p "$install_dir" +mv "${tmp_dir}/${APP}" "${install_dir}/${APP}" +chmod 755 "${install_dir}/${APP}" + +# Sanity check: a binary that cannot report its own version is not worth leaving +# on disk for the container to discover at runtime. +if ! installed_version=$("${install_dir}/${APP}" --version 2>/dev/null); then + echo -e "${RED}Error: installed binary failed to run.${NC}" >&2 + exit 1 +fi + +echo -e "${MUTED}Installed ${NC}${install_dir}/${APP}${MUTED} (${installed_version})${NC}" +echo -e "${MUTED}This build provides '${APP} serve' only.${NC}" diff --git a/install.sh b/install.sh index adeb565f66..2106a38bb2 100755 --- a/install.sh +++ b/install.sh @@ -34,16 +34,11 @@ Options: -h, --help Display this help message -v, --version Install a specific version (e.g. 0.0.3) -b, --binary Install from a local binary instead of downloading - --variant Install a build variant instead of the standard binary. - Currently: 'serve' — a headless, serve-only build for - containers. It provides ONLY 'bcode serve'; every other - subcommand is absent. Linux only. --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) Examples: curl -fsSL https://bcode.sh/install | bash curl -fsSL https://bcode.sh/install | bash -s -- --version 0.0.3 - curl -fsSL https://bcode.sh/install | bash -s -- --version 0.0.3 --variant serve ./install.sh --binary /path/to/bcode EOF } @@ -51,7 +46,6 @@ EOF requested_version=${VERSION:-} no_modify_path=false binary_path="" -variant=${BCODE_VARIANT:-} while [[ $# -gt 0 ]]; do case "$1" in @@ -77,15 +71,6 @@ while [[ $# -gt 0 ]]; do exit 1 fi ;; - --variant) - if [[ -n "${2:-}" ]]; then - variant="$2" - shift 2 - else - echo -e "${RED}Error: --variant requires a name argument${NC}" - exit 1 - fi - ;; --no-modify-path) no_modify_path=true shift @@ -195,15 +180,6 @@ else if [ "$is_musl" = "true" ]; then target="$target-musl" fi - # Variant marker goes last, matching the asset naming in - # packages/bcode-serve/script/build.ts (`-[-baseline][-musl][-serve]`). - if [ -n "$variant" ]; then - if [ "$os" != "linux" ]; then - echo -e "${RED}Error: --variant ${variant} is only published for linux (detected: ${os})${NC}" - exit 1 - fi - target="$target-$variant" - fi filename="$APP-$target$archive_ext" @@ -257,12 +233,6 @@ print_message() { } check_version() { - # A variant swap keeps the same version string, so the "already installed" - # short-circuit would skip a standard -> serve switch. Always reinstall when - # a variant is requested. - if [ -n "$variant" ]; then - return - fi if command -v bcode >/dev/null 2>&1; then installed_version=$(bcode --version 2>/dev/null || echo "") From 91cd4ae286a8704b1e74281a159582d0bb4f72c0 Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Thu, 6 Aug 2026 10:50:31 -0700 Subject: [PATCH 3/6] docs: trim comments, drop internal ticket refs and benchmark numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo is public; internal ticket IDs don't belong in it. Benchmark figures belonged in the PR discussion, not inline — they go stale and were restating the rationale at length. Comments now say why something is the way it is and stop there. --- .github/workflows/release.yml | 20 ++++-------- install-bytecode.sh | 48 ++++++++++----------------- packages/bcode-serve/package.json | 2 +- packages/bcode-serve/script/build.ts | 42 +++++++++++------------- packages/bcode-serve/src/index.ts | 49 +++++++++------------------- packages/bcode-serve/tsconfig.json | 10 +++--- 6 files changed, 64 insertions(+), 107 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25c294baff..6dd62d943a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,21 +148,15 @@ jobs: - name: Build serve-only variant and upload to release # Additive: produces `bcode-linux-{arm64,x64}-serve.tar.gz` alongside the - # standard assets, for the headless V4 container (ENG-5671). It registers - # only `bcode serve`, drops the embedded web UI, and is bytecode-compiled - # — roughly -36% spawn-to-listening versus the standard binary. + # standard assets, for headless containers. Built from its own package so + # `packages/opencode` — forked from upstream — stays untouched, and the + # canonical build step above is unmodified. # - # Built from its own package (`packages/bcode-serve`) so that - # `packages/opencode` — forked from upstream and synced regularly — - # stays untouched. The canonical build step above is unmodified. + # Runs after that step and is `continue-on-error` on purpose: this variant + # must never block or alter a normal release. Different asset names, so + # `--clobber` cannot overwrite the standard archives. # - # Runs AFTER the canonical build and is `continue-on-error` on purpose: - # this variant must never be able to block or alter a normal release. It - # writes different asset names, so `--clobber` cannot overwrite the - # standard archives. - # - # Linux only — the only consumer is the container image. Add targets here - # if that changes. + # Linux only — the only consumer is the container image. continue-on-error: true env: OPENCODE_VERSION: ${{ steps.ver.outputs.version }} diff --git a/install-bytecode.sh b/install-bytecode.sh index e101af7bce..274cae687d 100755 --- a/install-bytecode.sh +++ b/install-bytecode.sh @@ -2,36 +2,23 @@ # # BrowserCode installer — bytecode / serve-only build. # -# Intended to be hosted at https://bcode.sh/bytecode, alongside (not replacing) -# https://bcode.sh/install. One-liner: +# Hosted at https://bcode.sh/bytecode, alongside (not replacing) +# https://bcode.sh/install: # -# curl -fsSL https://bcode.sh/bytecode | bash # curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version 0.0.3 # -# WHAT THIS INSTALLS, AND HOW IT DIFFERS +# Installs the `bcode-linux--serve` asset, which provides ONLY +# `bcode serve` — `run`, `tui`, `web`, `github` and the rest are absent and +# exit 1. It exists for headless containers; anyone installing bcode on a +# laptop wants https://bcode.sh/install. # -# The `bcode-linux--serve` release asset. It is bytecode-compiled and -# built from a serve-only entrypoint, which makes it roughly 35% faster from -# spawn to listening — but it provides ONLY `bcode serve`. `run`, `tui`, `web`, -# `github`, `auth`, and every other subcommand are absent and exit 1. +# `install.sh` is the published path docs point at and stays untouched. This is +# additive: a second script, a second URL, a second release asset. # -# That is the whole point: it exists for headless containers that shell out to -# `bcode serve` and nothing else. If you are a human installing bcode on a -# laptop, you want https://bcode.sh/install instead. -# -# Linux only — the only consumer is the container image, so the release workflow -# only builds linux targets for this variant. -# -# WHY A SEPARATE SCRIPT -# -# `install.sh` is the published, documented path that people and docs point at. -# It stays untouched. This is additive: a second script, a second URL, a second -# release asset. Nothing here can change what `bcode.sh/install` serves. -# -# Deliberately much shorter than install.sh: no baseline/AVX2 detection (the -# variant ships one build per arch), no shell-rc editing (containers set PATH in -# the Dockerfile), no uv hint. Accepts install.sh's flags so switching is a -# one-word change to the URL in a Dockerfile. +# Linux only. Deliberately much shorter than install.sh: one build per arch (no +# baseline/AVX2 detection), no shell-rc editing (containers set PATH in the +# Dockerfile), no uv hint. Takes install.sh's flags so switching is a one-word +# change to the URL in a Dockerfile. set -euo pipefail APP=bcode @@ -94,7 +81,7 @@ while [[ $# -gt 0 ]]; do ;; --no-modify-path) # No-op: this script never touches shell config. Accepted so an - # existing `install.sh` invocation works verbatim against this URL. + # existing install.sh invocation works verbatim against this URL. shift ;; *) @@ -153,9 +140,8 @@ tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bcode_bytecode_install.XXXXXX") cleanup() { rm -rf "$tmp_dir"; } trap cleanup EXIT -# Fail loudly on a missing asset rather than unpacking a 404 body. The variant -# is only published from the release after this script landed, so an older tag -# legitimately won't have it — say so instead of erroring out of tar. +# Fail loudly rather than unpacking a 404 body. Releases predating this variant +# legitimately lack the asset, so say that instead of erroring out of tar. if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url"; then echo -e "${RED}Error: could not download ${filename} for v${specific_version}.${NC}" >&2 echo -e "${MUTED}URL: ${url}${NC}" >&2 @@ -175,8 +161,8 @@ mkdir -p "$install_dir" mv "${tmp_dir}/${APP}" "${install_dir}/${APP}" chmod 755 "${install_dir}/${APP}" -# Sanity check: a binary that cannot report its own version is not worth leaving -# on disk for the container to discover at runtime. +# A binary that cannot report its own version is not worth leaving on disk for +# the container to discover at runtime. if ! installed_version=$("${install_dir}/${APP}" --version 2>/dev/null); then echo -e "${RED}Error: installed binary failed to run.${NC}" >&2 exit 1 diff --git a/packages/bcode-serve/package.json b/packages/bcode-serve/package.json index 1ef766ac85..bfcea6dafb 100644 --- a/packages/bcode-serve/package.json +++ b/packages/bcode-serve/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "version": "0.0.0", "name": "@browser-use/bcode-serve", - "description": "Serve-only bcode binary variant for headless containers (ENG-5671)", + "description": "Serve-only bcode binary variant for headless containers", "type": "module", "license": "MIT", "private": true, diff --git a/packages/bcode-serve/script/build.ts b/packages/bcode-serve/script/build.ts index 9987f59197..1e2e8e9740 100644 --- a/packages/bcode-serve/script/build.ts +++ b/packages/bcode-serve/script/build.ts @@ -1,23 +1,22 @@ #!/usr/bin/env bun // -// Builds the `bcode--serve` binary variant (ENG-5671). +// Builds the `bcode--serve` binary variant. // -// Deliberately separate from `packages/opencode/script/build.ts`: that package -// is forked from upstream and synced regularly, so it stays byte-for-byte -// untouched. This script produces an *additional* release asset and never -// writes to the standard one — different dist dir, different asset name. +// Separate from `packages/opencode/script/build.ts` so that package, forked +// from upstream and synced regularly, stays untouched. Produces an additional +// release asset and never writes to the standard one — different dist dir, +// different asset name. // // Differences from the standard build: -// - entrypoint is this package's serve-only `src/index.ts` (1 command, not 24) -// - no embedded web UI (headless; `embeddedUI()` already handles its absence) +// - serve-only entrypoint (1 command, not 24) +// - no embedded web UI (headless; `embeddedUI()` handles its absence) // - no TUI worker entrypoint (reachable only from `cli/cmd/tui.ts`) -// - `bytecode: true` — skips JS parse at boot, ~-33% spawn-to-listening +// - bytecode compilation // - linux only by default; the only consumer is the container image // -// The Bun.build config below mirrors the standard build's. It has to be kept in -// sync by hand — the alternative is editing the upstream file, which is what -// this package exists to avoid. The smoke test boots `serve` for real, so a -// missing `define` or a broken graph fails here rather than in production. +// The Bun.build config below mirrors the standard build's and has to be kept in +// sync by hand. The smoke test boots `serve` for real, so a missing `define` or +// a broken graph fails here rather than in production. // // Usage: // bun run script/build.ts # host target, no upload @@ -32,7 +31,7 @@ const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const opencodeDir = path.resolve(dir, "../opencode") // `generate.ts` chdirs into packages/opencode as an import side effect; restore -// ours afterwards so the skills bundle's relative specifiers resolve correctly. +// ours so the skills bundle's relative specifiers resolve. const generated = await import(path.join(opencodeDir, "script/generate.ts")) process.chdir(dir) @@ -43,8 +42,8 @@ import opencodePkg from "../../opencode/package.json" const skipInstall = process.argv.includes("--skip-install") const noBytecodeFlag = process.argv.includes("--no-bytecode") -// Target allowlist, matched against `-[-baseline][-musl]`. Defaults to -// the host target so a local `bun run build` is quick; release CI passes linux. +// Target allowlist, matched against `-[-baseline][-musl]`. Defaults +// to the host target; release CI passes linux. const targetsArg = process.argv.includes("--targets") ? process.argv[process.argv.indexOf("--targets") + 1] : process.env.BCODE_SERVE_TARGETS @@ -141,15 +140,13 @@ for (const item of targets) { FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"), OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_MODELS_DEV: generated.modelsData, - // TUI-only, but referenced behind a `typeof` guard in opencode sources — - // define it so a stray reference cannot throw ReferenceError. + // TUI-only; defined so a stray reference cannot throw ReferenceError. OTUI_TREE_SITTER_WORKER_PATH: `''`, OPENCODE_WORKER_PATH: `''`, OPENCODE_CHANNEL: `'${Script.channel}'`, OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", - // Build-time-embedded Laminar project key. Populated by release CI from - // the LMNR_PROJECT_API_KEY_OSS secret; empty for local builds. Runtime use - // is gated in @browser-use/bcode-browser/src/telemetry.ts. + // Populated by release CI from LMNR_PROJECT_API_KEY_OSS; empty locally. + // Runtime use is gated in @browser-use/bcode-browser/src/telemetry.ts. BCODE_DEFAULT_LMNR_KEY: JSON.stringify(process.env.BCODE_DEFAULT_LMNR_KEY ?? ""), ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), }, @@ -159,9 +156,8 @@ for (const item of targets) { process.exit(1) } - // Smoke test: boot the server for real, not just `--version`. A missing - // `define` or a module dropped from the graph shows up as a failure to reach - // the listening banner, which `--version` would sail straight past. + // Boot the server for real, not just `--version`: a missing `define` or a + // module dropped from the graph would sail straight past `--version`. if (item.os === process.platform && item.arch === process.arch && !item.abi) { const binaryPath = path.join(dir, outdir, "bin/bcode") console.log(`Smoke test: ${assetName} serve`) diff --git a/packages/bcode-serve/src/index.ts b/packages/bcode-serve/src/index.ts index 315ab6f5c4..6dcdc09ba0 100644 --- a/packages/bcode-serve/src/index.ts +++ b/packages/bcode-serve/src/index.ts @@ -1,36 +1,21 @@ -// Serve-only entrypoint for the `bcode--serve` binary variant (ENG-5671). +// Serve-only entrypoint for the `bcode--serve` binary variant. // -// `packages/opencode/src/index.ts` eagerly imports all 24 command modules before -// yargs parses. The headless V4 runtime only ever invokes `bcode serve`, so the -// other 23 — TUI, run, web, github, pr, stats, import/export, db — are dead -// weight in its binary. They cost little at boot (the serve module graph already -// pulls the expensive shared core), but they cost real bytes, and bytes decide -// whether bytecode compilation is affordable: +// `packages/opencode/src/index.ts` eagerly imports all 24 command modules. +// Headless containers only ever invoke `bcode serve`, so registering just that +// one keeps the other 23 out of the bundle — which is what makes bytecode +// compilation affordable for this variant. // -// entrypoint plain bytecode -// opencode index.ts 108 MB / 412 ms 310 MB / 266 ms -// this file 92 MB / 381 ms 229 MB / 257 ms +// Lives here rather than in `packages/opencode` so that tree, forked from +// upstream and synced regularly, stays untouched. // -// (darwin-arm64, no embedded web UI, spawn-to-listening-banner, medians of 7.) -// Excluding the unused commands saves 16 MB without bytecode but 81 MB with it — -// dead code is ~5x more expensive once every function also carries compiled -// bytecode. -// -// This package exists so none of that touches `packages/opencode`, which is -// forked from upstream and synced regularly. Everything here is additive: a new -// package, a new release asset, and an opt-in installer flag. The standard -// binary is built and published exactly as before. -// -// DRIFT WARNING: the global-option and lifecycle wiring below is duplicated from -// `packages/opencode/src/index.ts`, which stays the source of truth. Global -// options added there must be mirrored here. The build script's smoke test boots -// `serve` for real (not just `--version`) so that a missing build-time `define` -// or a broken module graph fails the build rather than the deployment. +// DRIFT WARNING: the global-option and lifecycle wiring below is duplicated +// from `packages/opencode/src/index.ts`, which stays the source of truth. +// Options added there must be mirrored here. The build's smoke test boots +// `serve` for real, so a missing `define` fails the build, not the deploy. -// Telemetry key injection runs as an import side effect of this module, before -// any subsequent import is evaluated. Keep this as the FIRST import so the -// LMNR_PROJECT_API_KEY env var is settled before any downstream module-load code -// reads it. (Same ordering contract as packages/opencode/src/index.ts.) +// Must stay the FIRST import: this module sets LMNR_PROJECT_API_KEY as an +// import side effect, before any downstream module-load code reads it. Same +// ordering contract as packages/opencode/src/index.ts. import "@browser-use/bcode-browser/telemetry" import yargs from "yargs" @@ -124,10 +109,8 @@ try { } process.exitCode = 1 } finally { - // Plugin shutdown hooks are the single drain point for OTel-based plugins - // (e.g. bcode-laminar) — without this the V4 worker loses trailing spans. - // Mirrors the drain in packages/opencode/src/index.ts; see the note there for - // why the host-side forceFlush fallback was dropped. + // Single drain point for OTel-based plugins (e.g. bcode-laminar); without it + // trailing spans are lost. Mirrors the drain in packages/opencode/src/index.ts. try { const { pluginShutdownHooks } = await import("@browser-use/browsercode-core/plugin/index") await Promise.race([ diff --git a/packages/bcode-serve/tsconfig.json b/packages/bcode-serve/tsconfig.json index 898ae660e8..7b2471d0f3 100644 --- a/packages/bcode-serve/tsconfig.json +++ b/packages/bcode-serve/tsconfig.json @@ -6,15 +6,13 @@ "types": [], "noUncheckedIndexedAccess": false, "customConditions": ["browser"], - // `packages/opencode` sources reach each other through `@/*`. We import a - // handful of them directly, so tsc needs the same mapping the bundler gets - // from `packages/opencode/tsconfig.json`. + // opencode sources reach each other through `@/*`; tsc needs the same + // mapping the bundler gets from packages/opencode/tsconfig.json. "paths": { "@/*": ["../opencode/src/*"] } }, - // `packages/opencode/src/*.d.ts` carries ambient module declarations (`*.wasm`, - // `*.sql`, `*.md`) that its sources rely on. They're picked up automatically - // inside that package; pulling them in explicitly keeps typecheck honest here. + // opencode's ambient module declarations (`*.wasm`, `*.sql`, `*.md`) are + // implicit inside that package; pull them in so typecheck here is honest. "include": ["src/**/*", "script/**/*", "../opencode/src/*.d.ts"] } From 2b2cf34e08ab08ec9d72aeea5c442d6d01bf9c5c Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Thu, 6 Aug 2026 10:58:46 -0700 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20execu?= =?UTF-8?q?table=20bit,=20musl=20assets,=20timer=20leak,=20install=20order?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four issues from automated review, all confirmed: build.ts was committed 100644 while the canonical build script is 100755, so `./packages/bcode-serve/script/build.ts` exited 126 (permission denied). With continue-on-error on that step, every release would have shipped without the variant and still looked green. Fixed the mode, and invoke via `bun` so a lost executable bit can't silently disable the step again. The smoke test's Promise.race left its 30s timeout armed after the server came up. An armed timer keeps the event loop alive, so a ~2s build took 32s. Clear it in `finally`. The installer requested the glibc asset unconditionally, so an Alpine host downloaded a binary that cannot exec. It now detects musl and selects the matching asset; the release step publishes the musl targets to go with it (the build script already enumerated them). Non-AVX2 x64 has no baseline asset for this variant, so that case is rejected with a pointer to /install rather than installing a binary that SIGILLs. The AVX2 probe only trips when /proc/cpuinfo is readable — unknown is not the same as absent. The `--version` check ran after the binary was already moved into place, so a bad download replaced a working install and then failed. Validate in the temp dir first and only move on success. --- .github/workflows/release.yml | 10 ++++++-- install-bytecode.sh | 38 ++++++++++++++++++++++------ packages/bcode-serve/script/build.ts | 9 ++++++- 3 files changed, 46 insertions(+), 11 deletions(-) mode change 100644 => 100755 packages/bcode-serve/script/build.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6dd62d943a..3a783857b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,7 +156,12 @@ jobs: # must never block or alter a normal release. Different asset names, so # `--clobber` cannot overwrite the standard archives. # - # Linux only — the only consumer is the container image. + # Linux only — the only consumer is the container image. glibc + musl, + # matching what install-bytecode.sh can select; no baseline (non-AVX2 + # x64), which the installer rejects with a pointer to /install. + # + # Invoked through `bun` rather than as `./...` so a lost executable bit + # cannot silently disable the whole step under continue-on-error. continue-on-error: true env: OPENCODE_VERSION: ${{ steps.ver.outputs.version }} @@ -166,7 +171,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BCODE_DEFAULT_LMNR_KEY: ${{ secrets.LMNR_PROJECT_API_KEY_OSS }} run: | - ./packages/bcode-serve/script/build.ts --targets linux-arm64,linux-x64 + bun ./packages/bcode-serve/script/build.ts \ + --targets linux-arm64,linux-x64,linux-arm64-musl,linux-x64-musl - name: Summarise uploaded assets env: diff --git a/install-bytecode.sh b/install-bytecode.sh index 274cae687d..f9fef8ce68 100755 --- a/install-bytecode.sh +++ b/install-bytecode.sh @@ -118,7 +118,28 @@ for tool in curl tar; do fi done -filename="${APP}-${os}-${arch}-${VARIANT}.tar.gz" +# A glibc binary will not start on musl, so pick the matching build rather than +# installing something that dies at exec. +is_musl=false +if [ -f /etc/alpine-release ]; then + is_musl=true +elif command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then + is_musl=true +fi + +# Non-baseline x64 builds require AVX2. This variant publishes no baseline +# asset, so say that rather than installing a binary that SIGILLs on first run. +# (Adding it is one entry in packages/bcode-serve/script/build.ts if ever needed.) +# Only trip when /proc/cpuinfo is actually readable — unknown is not "absent". +if [ "$arch" = "x64" ] && [ -r /proc/cpuinfo ] && ! grep -qwi avx2 /proc/cpuinfo; then + echo -e "${RED}Error: this CPU lacks AVX2, and no baseline ${VARIANT} build is published.${NC}" >&2 + echo -e "${MUTED}Use https://bcode.sh/install, which ships a baseline binary.${NC}" >&2 + exit 1 +fi + +target="${os}-${arch}" +[ "$is_musl" = true ] && target="${target}-musl" +filename="${APP}-${target}-${VARIANT}.tar.gz" if [ -z "$requested_version" ]; then url="https://github.com/${REPO}/releases/latest/download/${filename}" @@ -157,16 +178,17 @@ if [ ! -f "${tmp_dir}/${APP}" ]; then exit 1 fi +# Validate before installing, not after: a corrupt or wrong-libc download that +# fails here must not have already overwritten a working bcode. +chmod 755 "${tmp_dir}/${APP}" +if ! installed_version=$("${tmp_dir}/${APP}" --version 2>/dev/null); then + echo -e "${RED}Error: downloaded binary failed to run; leaving any existing install untouched.${NC}" >&2 + exit 1 +fi + mkdir -p "$install_dir" mv "${tmp_dir}/${APP}" "${install_dir}/${APP}" chmod 755 "${install_dir}/${APP}" -# A binary that cannot report its own version is not worth leaving on disk for -# the container to discover at runtime. -if ! installed_version=$("${install_dir}/${APP}" --version 2>/dev/null); then - echo -e "${RED}Error: installed binary failed to run.${NC}" >&2 - exit 1 -fi - echo -e "${MUTED}Installed ${NC}${install_dir}/${APP}${MUTED} (${installed_version})${NC}" echo -e "${MUTED}This build provides '${APP} serve' only.${NC}" diff --git a/packages/bcode-serve/script/build.ts b/packages/bcode-serve/script/build.ts old mode 100644 new mode 100755 index 1e2e8e9740..c90478bf0b --- a/packages/bcode-serve/script/build.ts +++ b/packages/bcode-serve/script/build.ts @@ -179,10 +179,16 @@ for (const item of targets) { const stderr = await new Response(proc.stderr).text() throw new Error(`server exited before listening.\nstdout:\n${buffered}\nstderr:\n${stderr}`) })() + // Timer handle is cleared in `finally`: an armed timer keeps the event loop + // alive, which would stall the build for the full timeout after every + // successful smoke test. + let timer: ReturnType | undefined try { const banner = await Promise.race([ listening, - new Promise((_, reject) => setTimeout(() => reject(new Error("timed out after 30s")), 30_000)), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timed out after 30s")), 30_000) + }), ]) console.log(`Smoke test passed: ${banner.trim().split("\n").at(-1)}`) } catch (e) { @@ -190,6 +196,7 @@ for (const item of targets) { proc.kill() process.exit(1) } finally { + clearTimeout(timer) proc.kill() await proc.exited } From 7c1664b12ccde11cf389c06f0f1ae1dabcb86bc1 Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Thu, 6 Aug 2026 11:07:13 -0700 Subject: [PATCH 5/6] fix(install): stage the binary in the install dir, not the temp dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My previous fix validated the download by running `--version` from the temp dir, which broke installs on hosts that mount /tmp noexec — common hardening, and a regression: before that change the binary was only ever executed from the install dir. Stage inside the install dir instead. That keeps the safety property (a corrupt or wrong-libc download can no longer replace a working bcode) without requiring an exec-capable /tmp, since the install dir has to allow exec anyway. The final step becomes a same-filesystem `mv`, so the swap is atomic and no reader can observe a half-written binary. The staging file is cleaned up on every exit path, and the failure message now names noexec on the install dir as a cause. --- install-bytecode.sh | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/install-bytecode.sh b/install-bytecode.sh index f9fef8ce68..17611ad703 100755 --- a/install-bytecode.sh +++ b/install-bytecode.sh @@ -158,7 +158,12 @@ fi echo -e "${MUTED}Installing ${NC}${APP} ${MUTED}(${VARIANT} build) version: ${NC}${specific_version}" tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bcode_bytecode_install.XXXXXX") -cleanup() { rm -rf "$tmp_dir"; } +staged="" +cleanup() { + rm -rf "$tmp_dir" + [ -n "${staged:-}" ] && rm -f "$staged" + return 0 +} trap cleanup EXIT # Fail loudly rather than unpacking a 404 body. Releases predating this variant @@ -178,17 +183,27 @@ if [ ! -f "${tmp_dir}/${APP}" ]; then exit 1 fi -# Validate before installing, not after: a corrupt or wrong-libc download that -# fails here must not have already overwritten a working bcode. -chmod 755 "${tmp_dir}/${APP}" -if ! installed_version=$("${tmp_dir}/${APP}" --version 2>/dev/null); then +# Stage inside the install dir, validate, then swap into place. +# +# Validating before the swap keeps a corrupt or wrong-libc download from +# replacing a working bcode. Staging in the install dir rather than the temp dir +# keeps that safety without requiring an exec-capable /tmp — `noexec` there is +# common hardening, and the install dir has to allow exec regardless. It also +# makes the final step a same-filesystem `mv`, so the swap is atomic and no +# reader can observe a half-written binary. +mkdir -p "$install_dir" +staged=$(mktemp "${install_dir}/.${APP}.XXXXXX") +mv "${tmp_dir}/${APP}" "$staged" +chmod 755 "$staged" + +if ! installed_version=$("$staged" --version 2>/dev/null); then echo -e "${RED}Error: downloaded binary failed to run; leaving any existing install untouched.${NC}" >&2 + echo -e "${MUTED}If ${install_dir} is mounted noexec, install elsewhere with --install-dir.${NC}" >&2 exit 1 fi -mkdir -p "$install_dir" -mv "${tmp_dir}/${APP}" "${install_dir}/${APP}" -chmod 755 "${install_dir}/${APP}" +mv "$staged" "${install_dir}/${APP}" +staged="" echo -e "${MUTED}Installed ${NC}${install_dir}/${APP}${MUTED} (${installed_version})${NC}" echo -e "${MUTED}This build provides '${APP} serve' only.${NC}" From b72e02722b63846bce78060ed7399a15b6a6b132 Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Thu, 6 Aug 2026 11:12:50 -0700 Subject: [PATCH 6/6] fix(release): don't publish serve assets built from the wrong commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Checkout` has no `ref:`, so on `workflow_dispatch` the tree is the dispatch ref (usually main) while the upload still targets `inputs.tag`. Publishing from there would put main's code inside that tag's assets. The serve step now compares HEAD against the tag's commit and skips with a visible warning when they differ, rather than uploading mislabelled binaries. The warning matters because the step is continue-on-error and would otherwise skip silently. The canonical build step has the same exposure — this is pre-existing, not introduced here, and fixing it properly means adding `ref:` to `Checkout` for the whole job. That changes the standard release path, which this PR otherwise leaves alone, so it belongs in its own change. --- .github/workflows/release.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a783857b3..4fae0da245 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,8 +169,23 @@ jobs: OPENCODE_CHANNEL: latest GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.ver.outputs.tag }} BCODE_DEFAULT_LMNR_KEY: ${{ secrets.LMNR_PROJECT_API_KEY_OSS }} run: | + # Checkout has no `ref:`, so on `workflow_dispatch` the tree is the + # dispatch ref (usually main), not the selected tag — while the upload + # still targets that tag. Publishing then would put main's code inside + # the tag's assets. Build only when the tree really is the tag. + # + # The canonical build step above has the same exposure; fixing that + # means changing `Checkout` for the whole job, which is out of scope + # here. Tracked separately. + TAG_SHA=$(git rev-parse -q --verify "refs/tags/${TAG}^{commit}" || true) + HEAD_SHA=$(git rev-parse HEAD) + if [ "$HEAD_SHA" != "$TAG_SHA" ]; then + echo "::warning::Skipped the serve variant: checkout $(git rev-parse --short HEAD) is not tag ${TAG}. Re-run from a state where they match to publish it." + exit 0 + fi bun ./packages/bcode-serve/script/build.ts \ --targets linux-arm64,linux-x64,linux-arm64-musl,linux-x64-musl