From b8f651ffff6460f9a3a0809a399f821ce1515d9c Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Thu, 6 Aug 2026 10:27:05 -0700 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] =?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/7] 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/7] 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 From fe459c61919c556c494983b58ca0414fc497b52f Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Mon, 10 Aug 2026 16:44:19 -0700 Subject: [PATCH 7/7] fix(bcode-serve): close web-UI import hole; harden installer and build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworked the variant from scratch and had the result adversarially reviewed. The headline is a real vulnerability in what this branch already shipped. Dropping the embedded web UI did not fail closed. Bun leaves the bare `import("opencode-web-ui.gen.ts")` in server/shared/ui.ts live in the binary and resolves it at runtime against the server's cwd, so a file planted at ./node_modules/opencode-web-ui.gen.ts executed in-process, and its default export was used as a path map that serveUIEffect read and returned over HTTP. PUBLIC_UI_PATHS lets /site.webmanifest and the manifest icons skip auth, so this was reachable unauthenticated. Reproduced end to end: GET /site.webmanifest returned /etc/hosts with HTTP 200 while OPENCODE_SERVER_PASSWORD was set. Fixed by embedding an empty stub, which resolves the specifier hermetically; the same request now 404s and the planted module never loads. The standard binary was never affected — it embeds the real bundle. Installer, all reproduced before fixing: - a bare VERSION env var was honoured, so a common Dockerfile ARG could silently install a different build; only BCODE_VERSION is read now - musl detection could not fire under pipefail, so non-Alpine musl hosts silently took the glibc asset - the binary's own stderr was discarded, so a libc mismatch reported "noexec" as the likely cause instead of the missing loader - unset HOME aborted with a raw unbound-variable error - EXIT-only trap leaked a staged file and temp dir on INT/TERM/HUP under ash/dash; TERM is what docker build cancellation sends - the AVX2 guard tripped on cpuinfo with no flags line (qemu-emulated amd64 on an arm64 host), contradicting its own "unknown is not absent" intent - installing over a directory named bcode reported success and exit 0 - empty and flag-shaped option values were accepted - the curl/tar preflight was missing, so a missing tar exited 127 Also rewritten as POSIX sh: Alpine is a supported target and ships no bash. Build script: - the smoke test's timeout was not a deadline. It killed the child, which does not end the read loop if a descendant holds stdout, so a failing smoke test could hang the build; it now races a rejecting timer and SIGKILLs - musl detection missed Bun's `-musl-baseline` spelling - assert the version define landed: every define is typeof-guarded upstream, so a dropped one degrades silently and the banner alone would not notice - dropped three defines proven inert for this graph (OTUI_TREE_SITTER_WORKER_PATH, OPENCODE_WORKER_PATH, OPENTUI_LIBC) after byte-identical-output checks - targets are plain strings now instead of an object matrix Left alone deliberately: the entrypoint's global options and lifecycle wiring. Trimming them looked attractive but every candidate failed review — --pure and the AGENT/OPENCODE env vars have out-of-repo consumers, and Heap.start() is the only eager path that initialises Global, so removing it would change init ordering rather than just dropping a diagnostic. --- .github/workflows/release.yml | 33 ++- install-bytecode.sh | 308 ++++++++++++--------------- packages/bcode-serve/script/build.ts | 269 ++++++++++------------- packages/bcode-serve/src/index.ts | 6 +- 4 files changed, 267 insertions(+), 349 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fae0da245..90969d9061 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,21 +147,17 @@ jobs: ./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 headless containers. Built from its own package so - # `packages/opencode` — forked from upstream — stays untouched, and the - # canonical build step above is unmodified. + # Additive: publishes `bcode-linux-{arm64,x64}[-musl]-serve.tar.gz` for + # headless containers, from its own package so the upstream-forked + # `packages/opencode` and the canonical step above stay untouched. # - # 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. + # `continue-on-error` so this variant can never block a normal release; + # its asset names differ, so `--clobber` cannot touch the standard ones. + # Invoked via `bun` so a lost executable bit cannot silently disable it. # - # 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. + # No baseline (non-AVX2 x64) target — install-bytecode.sh rejects that + # case with a pointer to /install rather than shipping a binary that + # SIGILLs. continue-on-error: true env: OPENCODE_VERSION: ${{ steps.ver.outputs.version }} @@ -173,13 +169,10 @@ jobs: 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. + # dispatch ref, not the selected tag, while the upload still targets + # that tag. Only build when the tree really is the tag. (The canonical + # step has the same exposure; fixing it means changing `Checkout` for + # the whole job, which is out of scope here.) TAG_SHA=$(git rev-parse -q --verify "refs/tags/${TAG}^{commit}" || true) HEAD_SHA=$(git rev-parse HEAD) if [ "$HEAD_SHA" != "$TAG_SHA" ]; then diff --git a/install-bytecode.sh b/install-bytecode.sh index 17611ad703..2db2710ef1 100755 --- a/install-bytecode.sh +++ b/install-bytecode.sh @@ -1,209 +1,169 @@ -#!/usr/bin/env bash +#!/bin/sh # -# BrowserCode installer — bytecode / serve-only build. +# BrowserCode installer — serve-only build. Hosted at https://bcode.sh/bytecode, +# alongside (not replacing) https://bcode.sh/install, which stays the path for +# the full CLI. # -# Hosted at https://bcode.sh/bytecode, alongside (not replacing) -# https://bcode.sh/install: +# curl -fsSL https://bcode.sh/bytecode | sh -s -- --no-modify-path --version 0.1.19 # -# curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version 0.0.3 +# Installs `bcode-linux-[-musl]-serve`, which provides ONLY `bcode serve`; +# run, tui, web, github and the rest are absent and exit 1. It exists for headless +# containers. # -# 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. -# -# `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. -# -# 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 -VARIANT=serve -REPO=browser-use/browsercode +# POSIX sh, not bash: Alpine is a supported target here and ships no bash. +set -eu -MUTED='\033[0;2m' +REPO=browser-use/browsercode +DIM='\033[0;2m' RED='\033[0;31m' -ORANGE='\033[38;5;214m' -NC='\033[0m' - -usage() { - cat <&2; } +die() { + printf "${RED}%s${OFF}\n" "$1" >&2 + shift + for line in "$@"; do [ -n "$line" ] && printf "${DIM}%s${OFF}\n" "$line" >&2; done + exit 1 +} -Usage: install-bytecode.sh [options] +# Reject empty and flag-shaped values: `--version "$UNSET"` would otherwise +# silently install latest, which is exactly what pinning exists to prevent. +need() { + case $2 in + "" | -*) die "$1 needs a value (got: ${2:-})" ;; + esac +} -Installs a bcode binary that provides ONLY 'bcode serve'. For the full CLI use -https://bcode.sh/install instead. +usage() { + cat >&2 < 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. + -v, --version version to install (default: latest release) + --install-dir where to install (default: \$HOME/.bcode/bin) + --no-modify-path accepted for install.sh parity; this script never edits + shell config files + -h, --help show this help -Examples: - curl -fsSL https://bcode.sh/bytecode | bash - curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version 0.0.3 +Installs a bcode that provides ONLY 'bcode serve'. +For the full CLI: curl -fsSL https://bcode.sh/install | bash 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 +# Namespaced on purpose: a bare VERSION is a common Dockerfile ARG, and picking +# it up here would silently install the wrong build. +version=${BCODE_VERSION:-} +# HOME is routinely unset under `--user`/runAsUser with no passwd entry. +install_dir=${BCODE_INSTALL_DIR:-${HOME:-/root}/.bcode/bin} + +while [ $# -gt 0 ]; do + case $1 in + -v | --version) need "--version" "${2:-}"; version=$2; shift 2 ;; + --install-dir) need "--install-dir" "${2:-}"; install_dir=$2; shift 2 ;; + --no-modify-path) shift ;; + # Tolerated: wrappers relaying args often forward an extra one. + --) shift ;; + -h | --help) usage; exit 0 ;; + # Fail rather than warn: a typo'd flag would otherwise quietly install + # "latest" into a build that meant to pin a version. + *) die "Unknown option: $1" "Run with --help for usage." ;; + 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 + command -v "$tool" >/dev/null 2>&1 || die "'$tool' is required but not installed." done -# 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 +[ "$(uname -s)" = Linux ] || die \ + "The serve build is published for linux only (got $(uname -s))." \ + "Use https://bcode.sh/install for the standard cross-platform binary." + +case $(uname -m) in + aarch64 | arm64) arch=arm64 ;; + x86_64 | amd64) arch=x64 ;; + *) die "Unsupported architecture: $(uname -m)." "Supported: arm64, x64." ;; +esac + +# Non-baseline x64 builds need AVX2 and no baseline serve asset is published. +# Only decide when /proc/cpuinfo actually carries a flags line: emulated or +# redacted cpuinfo (qemu `--platform linux/amd64` on arm64, lxcfs, some +# hypervisors) has none, and unknown is not the same as absent. +if [ "$arch" = x64 ] && grep -qi '^flags' /proc/cpuinfo 2>/dev/null && ! grep -qwi avx2 /proc/cpuinfo; then + die "This CPU has no AVX2 and no baseline serve build is published." \ + "Use https://bcode.sh/install, which ships a baseline binary." 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 +# A glibc binary cannot exec on musl. musl's ldd prints its banner and then exits +# non-zero, so match its output — the pipeline's status is grep's, not ldd's. +target=linux-$arch +if [ -f /etc/alpine-release ] || ldd --version 2>&1 | grep -qi musl; then + target=$target-musl fi +asset=bcode-$target-serve.tar.gz -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}" - 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 +if [ -n "$version" ]; then + version=${version#v} else - specific_version="${requested_version#v}" - url="https://github.com/${REPO}/releases/download/v${specific_version}/${filename}" + version=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null | + sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') + [ -n "$version" ] || die "Could not resolve the latest version." "Pin one with --version ." fi +url=https://github.com/$REPO/releases/download/v$version/$asset -echo -e "${MUTED}Installing ${NC}${APP} ${MUTED}(${VARIANT} build) version: ${NC}${specific_version}" +say "Installing bcode $version ($target, serve build)" -tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bcode_bytecode_install.XXXXXX") -staged="" +tmp=$(mktemp -d) +staged= +# `return 0` is load-bearing: the `&&` above it returns 1 whenever staged is +# empty, which under `set -e` would turn a clean run into a non-zero exit. cleanup() { - rm -rf "$tmp_dir" - [ -n "${staged:-}" ] && rm -f "$staged" - return 0 + rm -rf "$tmp" + [ -n "$staged" ] && rm -f "$staged" + return 0 } trap cleanup EXIT - -# 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 - 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 +# ash/dash do not run the EXIT trap on a signal; `docker build` cancellation +# sends TERM. Exiting from the handler routes through the EXIT trap above. +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +curl -fsSL -o "$tmp/$asset" "$url" || die \ + "Could not download $asset for v$version." \ + "URL: $url" \ + "Releases published before this variant existed do not carry the asset." + +tar -xzf "$tmp/$asset" -C "$tmp" +[ -f "$tmp/bcode" ] || die "Archive did not contain a bcode binary." + +# Stage inside the install dir, validate, then swap. Validating first means a bad +# download never replaces a working bcode; staging here rather than in /tmp avoids +# requiring an exec-capable /tmp (noexec there is common hardening) and makes the +# final step a same-filesystem rename, so the swap is atomic. +case $install_dir in + "") die "Install directory cannot be empty." ;; + -*) die "Install directory must not start with '-' (got: $install_dir)." ;; +esac +mkdir -p -- "$install_dir" +if [ -d "$install_dir/bcode" ]; then + die "$install_dir/bcode is a directory; refusing to install over it." fi - -# 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" +staged=$(mktemp "$install_dir/.bcode.XXXXXX") +mv "$tmp/bcode" "$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 +# Keep the binary's own stderr: on a libc mismatch it names the missing loader, +# which is the actual diagnosis. +if ! installed=$("$staged" --version 2>"$tmp/err"); then + die "Downloaded binary failed to run; existing install left untouched." \ + "$(cat "$tmp/err" 2>/dev/null)" \ + "If $install_dir is mounted noexec, pass --install-dir." fi -mv "$staged" "${install_dir}/${APP}" -staged="" +mv "$staged" "$install_dir/bcode" +staged= -echo -e "${MUTED}Installed ${NC}${install_dir}/${APP}${MUTED} (${installed_version})${NC}" -echo -e "${MUTED}This build provides '${APP} serve' only.${NC}" +say "Installed $install_dir/bcode ($installed) — provides 'bcode serve' only" +case ":$PATH:" in + *":$install_dir:"*) ;; + *) say "Not on PATH. Add it with: export PATH=\"$install_dir:\$PATH\"" ;; +esac diff --git a/packages/bcode-serve/script/build.ts b/packages/bcode-serve/script/build.ts index c90478bf0b..2c514f5844 100755 --- a/packages/bcode-serve/script/build.ts +++ b/packages/bcode-serve/script/build.ts @@ -1,211 +1,174 @@ #!/usr/bin/env bun // -// Builds the `bcode--serve` binary variant. +// Builds the `bcode--serve` binary variant: registers only `bcode serve` +// and is bytecode-compiled, for headless containers. // -// 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. +// Separate from packages/opencode/script/build.ts so that package — forked from +// upstream and synced regularly — stays untouched. Produces additional release +// assets; never writes the standard ones. // -// Differences from the standard build: -// - 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 compilation -// - linux only by default; the only consumer is the container image -// -// 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 -// bun run script/build.ts --targets linux-arm64 # cross-compile -// OPENCODE_RELEASE=1 bun run script/build.ts ... # archive + gh upload +// bun run script/build.ts # host target +// bun run script/build.ts --targets linux-arm64,linux-x64 # cross-compile +// OPENCODE_RELEASE=1 bun run script/build.ts --targets ... # archive + 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") +const opencode = path.resolve(dir, "../opencode") -// `generate.ts` chdirs into packages/opencode as an import side effect; restore -// ours so the skills bundle's relative specifiers resolve. -const generated = await import(path.join(opencodeDir, "script/generate.ts")) +// generate.ts chdirs into packages/opencode on import; restore ours so the +// skills bundle's relative specifiers resolve. +const { modelsData } = await import(path.join(opencode, "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; 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) +const flag = (name: string) => process.argv.includes(`--${name}`) +const opt = (name: string) => (flag(name) ? process.argv[process.argv.indexOf(`--${name}`) + 1] : undefined) + +const host = `${process.platform}-${process.arch}` +const targets = (opt("targets") ?? host) + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + +const skills = 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, - ) +// Cross-compiling needs every platform's native artifacts on disk. Both of these +// are in the serve graph — fff-bun via core/filesystem/fff.bun.ts, @parcel/watcher +// via core/filesystem/watcher.ts — and a plain install only fetches the host's. +if (!flag("skip-install")) { + for (const dep of ["@ff-labs/fff-bun", "@parcel/watcher"] as const) { + await $`bun install --os="*" --cpu="*" ${dep}@${opencodePkg.dependencies[dep]}`.cwd(opencode) + } } -const archives: string[] = [] +// Boot the server and wait for its banner. `--version` alone would pass with a +// broken server graph. +// +// The timeout has to be a racing rejection, not just a kill: killing the child +// does not end the read loop if any descendant inherited stdout, so a hung +// smoke test would hang the build instead of failing it. +async function smoke(bin: string) { + const proc = Bun.spawn([bin, "serve", "--port", "0", "--hostname", "127.0.0.1"], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, OPENCODE_SERVER_PASSWORD: "smoke-test" }, + }) + let timer: ReturnType | undefined + const banner = (async () => { + const reader = proc.stdout.getReader() + const decoder = new TextDecoder() + for (let out = ""; ; ) { + const { value, done } = await reader.read() + if (done) throw new Error(`serve exited before listening:\n${out}\n${await new Response(proc.stderr).text()}`) + out += decoder.decode(value, { stream: true }) + if (out.includes("listening on")) return out.trim().split("\n").at(-1) + } + })() + try { + return await Promise.race([ + banner, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("serve did not print its listening banner within 60s")), 60_000) + }), + ]) + } finally { + clearTimeout(timer) + // SIGKILL, not the default SIGTERM: a wedged child must not outlive us. + proc.kill("SIGKILL") + await proc.exited + } +} -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 archives: string[] = [] - const skillsPath = "bcode-skills.gen.ts" +for (const target of targets) { + const asset = `bcode-${target}-serve` + const bin = path.join(dir, "dist", asset, "bin/bcode") + const musl = /(^|-)musl(-|$)/.test(target) + console.log(`building ${asset}`) - const result = await Bun.build({ + const built = 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"), + // opencode's tsconfig supplies the `@/*` -> packages/opencode/src/* mapping + // that its own sources rely on. + tsconfig: path.join(opencode, "tsconfig.json"), external: ["node-gyp"], format: "esm", minify: true, sourcemap: "none", splitting: true, - bytecode: !noBytecodeFlag, + bytecode: !flag("no-bytecode"), compile: { autoloadBunfig: false, autoloadDotenv: false, autoloadTsconfig: true, autoloadPackageJson: true, - target: `bun-${targetSuffix}` as any, - outfile: path.join(dir, outdir, "bin/bcode"), + target: `bun-${target}` as any, + outfile: bin, execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], windows: {}, }, files: { - [skillsPath]: embeddedSkillsFileMap, + // Hard requirement: skills.ts throws when this is missing in a compiled + // binary, rather than degrading. + "bcode-skills.gen.ts": skills, + // Must stay embedded even though this build ships no web UI. Leaving it + // out does NOT fail closed: Bun keeps the bare `import("opencode-web-ui.gen.ts")` + // in server/shared/ui.ts live and resolves it at runtime against the + // server's cwd. A file planted at ./node_modules/opencode-web-ui.gen.ts + // then executes in-process, and its default export is used as a path map + // that serveUIEffect reads and returns over HTTP. PUBLIC_UI_PATHS lets + // /site.webmanifest and the two manifest icons skip auth entirely, so that + // read is reachable even with a server password set. An empty stub + // resolves the specifier hermetically and every UI path 404s, which is + // what a headless server wants anyway. + "opencode-web-ui.gen.ts": "export default {}", }, - entrypoints: ["./src/index.ts", skillsPath], + entrypoints: ["./src/index.ts", "bcode-skills.gen.ts", "opencode-web-ui.gen.ts"], + // Every consumer of these reads them behind a `typeof` guard, so a missing + // one degrades silently rather than throwing — hence the assertion below. define: { - FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"), OPENCODE_VERSION: `'${Script.version}'`, - OPENCODE_MODELS_DEV: generated.modelsData, - // 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"}'` : "", - // Populated by release CI from LMNR_PROJECT_API_KEY_OSS; empty locally. - // Runtime use is gated in @browser-use/bcode-browser/src/telemetry.ts. + OPENCODE_MODELS_DEV: modelsData, + // Native-binding selectors: fff-bun and @parcel/watcher each pick their + // .so by libc, so musl builds must say so or file watching silently dies. + FFF_LIBC: JSON.stringify(musl ? "musl" : "gnu"), + OPENCODE_LIBC: target.startsWith("linux-") ? `'${musl ? "musl" : "glibc"}'` : "''", + // Release CI supplies the key; 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") } : {}), }, }) - if (!result.success) { - console.error(result.logs) + if (!built.success) { + console.error(built.logs) process.exit(1) } - // 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`) - 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}`) - })() - // 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) => { - timer = 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() + if (target === host) { + const banner = await smoke(bin) + // Assert a define actually landed. The banner above cannot tell: every + // define read upstream is `typeof`-guarded, so a dropped one just falls + // back to a default and the server still starts. + const version = (await $`${bin} --version`.text()).trim() + if (version !== Script.version) { + console.error(`define check failed: --version printed ${version}, expected ${Script.version}`) process.exit(1) - } finally { - clearTimeout(timer) - proc.kill() - await proc.exited } + console.log(`smoke: ${banner}`) } 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`) + await $`tar -czf ../../${asset}.tar.gz *`.cwd(`dist/${asset}/bin`) + archives.push(`./dist/${asset}.tar.gz`) } } diff --git a/packages/bcode-serve/src/index.ts b/packages/bcode-serve/src/index.ts index 6dcdc09ba0..263c0bb64d 100644 --- a/packages/bcode-serve/src/index.ts +++ b/packages/bcode-serve/src/index.ts @@ -10,8 +10,10 @@ // // 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. +// Options added there must be mirrored here, and nothing enforces it — the +// build's smoke test boots `serve` for real, which catches a broken module +// graph but not a missing option, since `.strict()` only rejects a flag at the +// moment a caller passes one. // 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