Skip to content

Commit fe459c6

Browse files
committed
fix(bcode-serve): close web-UI import hole; harden installer and build
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.
1 parent b72e027 commit fe459c6

4 files changed

Lines changed: 267 additions & 349 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -147,21 +147,17 @@ jobs:
147147
./packages/opencode/script/build.ts
148148
149149
- name: Build serve-only variant and upload to release
150-
# Additive: produces `bcode-linux-{arm64,x64}-serve.tar.gz` alongside the
151-
# standard assets, for headless containers. Built from its own package so
152-
# `packages/opencode` — forked from upstream — stays untouched, and the
153-
# canonical build step above is unmodified.
150+
# Additive: publishes `bcode-linux-{arm64,x64}[-musl]-serve.tar.gz` for
151+
# headless containers, from its own package so the upstream-forked
152+
# `packages/opencode` and the canonical step above stay untouched.
154153
#
155-
# Runs after that step and is `continue-on-error` on purpose: this variant
156-
# must never block or alter a normal release. Different asset names, so
157-
# `--clobber` cannot overwrite the standard archives.
154+
# `continue-on-error` so this variant can never block a normal release;
155+
# its asset names differ, so `--clobber` cannot touch the standard ones.
156+
# Invoked via `bun` so a lost executable bit cannot silently disable it.
158157
#
159-
# Linux only — the only consumer is the container image. glibc + musl,
160-
# matching what install-bytecode.sh can select; no baseline (non-AVX2
161-
# x64), which the installer rejects with a pointer to /install.
162-
#
163-
# Invoked through `bun` rather than as `./...` so a lost executable bit
164-
# cannot silently disable the whole step under continue-on-error.
158+
# No baseline (non-AVX2 x64) target — install-bytecode.sh rejects that
159+
# case with a pointer to /install rather than shipping a binary that
160+
# SIGILLs.
165161
continue-on-error: true
166162
env:
167163
OPENCODE_VERSION: ${{ steps.ver.outputs.version }}
@@ -173,13 +169,10 @@ jobs:
173169
BCODE_DEFAULT_LMNR_KEY: ${{ secrets.LMNR_PROJECT_API_KEY_OSS }}
174170
run: |
175171
# Checkout has no `ref:`, so on `workflow_dispatch` the tree is the
176-
# dispatch ref (usually main), not the selected tag — while the upload
177-
# still targets that tag. Publishing then would put main's code inside
178-
# the tag's assets. Build only when the tree really is the tag.
179-
#
180-
# The canonical build step above has the same exposure; fixing that
181-
# means changing `Checkout` for the whole job, which is out of scope
182-
# here. Tracked separately.
172+
# dispatch ref, not the selected tag, while the upload still targets
173+
# that tag. Only build when the tree really is the tag. (The canonical
174+
# step has the same exposure; fixing it means changing `Checkout` for
175+
# the whole job, which is out of scope here.)
183176
TAG_SHA=$(git rev-parse -q --verify "refs/tags/${TAG}^{commit}" || true)
184177
HEAD_SHA=$(git rev-parse HEAD)
185178
if [ "$HEAD_SHA" != "$TAG_SHA" ]; then

‎install-bytecode.sh‎

Lines changed: 134 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -1,209 +1,169 @@
1-
#!/usr/bin/env bash
1+
#!/bin/sh
22
#
3-
# BrowserCode installer — bytecode / serve-only build.
3+
# BrowserCode installer — serve-only build. Hosted at https://bcode.sh/bytecode,
4+
# alongside (not replacing) https://bcode.sh/install, which stays the path for
5+
# the full CLI.
46
#
5-
# Hosted at https://bcode.sh/bytecode, alongside (not replacing)
6-
# https://bcode.sh/install:
7+
# curl -fsSL https://bcode.sh/bytecode | sh -s -- --no-modify-path --version 0.1.19
78
#
8-
# curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version 0.0.3
9+
# Installs `bcode-linux-<arch>[-musl]-serve`, which provides ONLY `bcode serve`;
10+
# run, tui, web, github and the rest are absent and exit 1. It exists for headless
11+
# containers.
912
#
10-
# Installs the `bcode-linux-<arch>-serve` asset, which provides ONLY
11-
# `bcode serve` — `run`, `tui`, `web`, `github` and the rest are absent and
12-
# exit 1. It exists for headless containers; anyone installing bcode on a
13-
# laptop wants https://bcode.sh/install.
14-
#
15-
# `install.sh` is the published path docs point at and stays untouched. This is
16-
# additive: a second script, a second URL, a second release asset.
17-
#
18-
# Linux only. Deliberately much shorter than install.sh: one build per arch (no
19-
# baseline/AVX2 detection), no shell-rc editing (containers set PATH in the
20-
# Dockerfile), no uv hint. Takes install.sh's flags so switching is a one-word
21-
# change to the URL in a Dockerfile.
22-
set -euo pipefail
23-
24-
APP=bcode
25-
VARIANT=serve
26-
REPO=browser-use/browsercode
13+
# POSIX sh, not bash: Alpine is a supported target here and ships no bash.
14+
set -eu
2715

28-
MUTED='\033[0;2m'
16+
REPO=browser-use/browsercode
17+
DIM='\033[0;2m'
2918
RED='\033[0;31m'
30-
ORANGE='\033[38;5;214m'
31-
NC='\033[0m'
32-
33-
usage() {
34-
cat <<EOF
35-
BrowserCode Installer (bytecode / serve-only build)
19+
OFF='\033[0m'
20+
21+
say() { printf "${DIM}%s${OFF}\n" "$1" >&2; }
22+
die() {
23+
printf "${RED}%s${OFF}\n" "$1" >&2
24+
shift
25+
for line in "$@"; do [ -n "$line" ] && printf "${DIM}%s${OFF}\n" "$line" >&2; done
26+
exit 1
27+
}
3628

37-
Usage: install-bytecode.sh [options]
29+
# Reject empty and flag-shaped values: `--version "$UNSET"` would otherwise
30+
# silently install latest, which is exactly what pinning exists to prevent.
31+
need() {
32+
case $2 in
33+
"" | -*) die "$1 needs a value (got: ${2:-<missing>})" ;;
34+
esac
35+
}
3836

39-
Installs a bcode binary that provides ONLY 'bcode serve'. For the full CLI use
40-
https://bcode.sh/install instead.
37+
usage() {
38+
cat >&2 <<EOF
39+
BrowserCode installer (serve-only build)
4140
42-
Options:
43-
-h, --help Display this help message
44-
-v, --version <version> Install a specific version (e.g. 0.0.3)
45-
--install-dir <dir> Install to <dir> (default: \$HOME/.bcode/bin)
46-
--no-modify-path Accepted for parity with install.sh; this script
47-
never edits shell config files.
41+
-v, --version <ver> version to install (default: latest release)
42+
--install-dir <d> where to install (default: \$HOME/.bcode/bin)
43+
--no-modify-path accepted for install.sh parity; this script never edits
44+
shell config files
45+
-h, --help show this help
4846
49-
Examples:
50-
curl -fsSL https://bcode.sh/bytecode | bash
51-
curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version 0.0.3
47+
Installs a bcode that provides ONLY 'bcode serve'.
48+
For the full CLI: curl -fsSL https://bcode.sh/install | bash
5249
EOF
5350
}
5451

55-
requested_version=${VERSION:-}
56-
install_dir=${BCODE_INSTALL_DIR:-$HOME/.bcode/bin}
57-
58-
while [[ $# -gt 0 ]]; do
59-
case "$1" in
60-
-h|--help)
61-
usage
62-
exit 0
63-
;;
64-
-v|--version)
65-
if [[ -n "${2:-}" ]]; then
66-
requested_version="$2"
67-
shift 2
68-
else
69-
echo -e "${RED}Error: --version requires a version argument${NC}" >&2
70-
exit 1
71-
fi
72-
;;
73-
--install-dir)
74-
if [[ -n "${2:-}" ]]; then
75-
install_dir="$2"
76-
shift 2
77-
else
78-
echo -e "${RED}Error: --install-dir requires a path argument${NC}" >&2
79-
exit 1
80-
fi
81-
;;
82-
--no-modify-path)
83-
# No-op: this script never touches shell config. Accepted so an
84-
# existing install.sh invocation works verbatim against this URL.
85-
shift
86-
;;
87-
*)
88-
echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2
89-
shift
90-
;;
91-
esac
52+
# Namespaced on purpose: a bare VERSION is a common Dockerfile ARG, and picking
53+
# it up here would silently install the wrong build.
54+
version=${BCODE_VERSION:-}
55+
# HOME is routinely unset under `--user`/runAsUser with no passwd entry.
56+
install_dir=${BCODE_INSTALL_DIR:-${HOME:-/root}/.bcode/bin}
57+
58+
while [ $# -gt 0 ]; do
59+
case $1 in
60+
-v | --version) need "--version" "${2:-}"; version=$2; shift 2 ;;
61+
--install-dir) need "--install-dir" "${2:-}"; install_dir=$2; shift 2 ;;
62+
--no-modify-path) shift ;;
63+
# Tolerated: wrappers relaying args often forward an extra one.
64+
--) shift ;;
65+
-h | --help) usage; exit 0 ;;
66+
# Fail rather than warn: a typo'd flag would otherwise quietly install
67+
# "latest" into a build that meant to pin a version.
68+
*) die "Unknown option: $1" "Run with --help for usage." ;;
69+
esac
9270
done
9371

94-
raw_os=$(uname -s)
95-
case "$raw_os" in
96-
Linux*) os="linux" ;;
97-
*)
98-
echo -e "${RED}Error: the ${VARIANT} build is published for linux only (detected: ${raw_os}).${NC}" >&2
99-
echo -e "${MUTED}Use https://bcode.sh/install for the standard cross-platform binary.${NC}" >&2
100-
exit 1
101-
;;
102-
esac
103-
104-
arch=$(uname -m)
105-
case "$arch" in
106-
aarch64|arm64) arch="arm64" ;;
107-
x86_64|amd64) arch="x64" ;;
108-
*)
109-
echo -e "${RED}Error: unsupported architecture '${arch}'. Supported: arm64, x64.${NC}" >&2
110-
exit 1
111-
;;
112-
esac
113-
11472
for tool in curl tar; do
115-
if ! command -v "$tool" >/dev/null 2>&1; then
116-
echo -e "${RED}Error: '${tool}' is required but not installed.${NC}" >&2
117-
exit 1
118-
fi
73+
command -v "$tool" >/dev/null 2>&1 || die "'$tool' is required but not installed."
11974
done
12075

121-
# A glibc binary will not start on musl, so pick the matching build rather than
122-
# installing something that dies at exec.
123-
is_musl=false
124-
if [ -f /etc/alpine-release ]; then
125-
is_musl=true
126-
elif command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then
127-
is_musl=true
76+
[ "$(uname -s)" = Linux ] || die \
77+
"The serve build is published for linux only (got $(uname -s))." \
78+
"Use https://bcode.sh/install for the standard cross-platform binary."
79+
80+
case $(uname -m) in
81+
aarch64 | arm64) arch=arm64 ;;
82+
x86_64 | amd64) arch=x64 ;;
83+
*) die "Unsupported architecture: $(uname -m)." "Supported: arm64, x64." ;;
84+
esac
85+
86+
# Non-baseline x64 builds need AVX2 and no baseline serve asset is published.
87+
# Only decide when /proc/cpuinfo actually carries a flags line: emulated or
88+
# redacted cpuinfo (qemu `--platform linux/amd64` on arm64, lxcfs, some
89+
# hypervisors) has none, and unknown is not the same as absent.
90+
if [ "$arch" = x64 ] && grep -qi '^flags' /proc/cpuinfo 2>/dev/null && ! grep -qwi avx2 /proc/cpuinfo; then
91+
die "This CPU has no AVX2 and no baseline serve build is published." \
92+
"Use https://bcode.sh/install, which ships a baseline binary."
12893
fi
12994

130-
# Non-baseline x64 builds require AVX2. This variant publishes no baseline
131-
# asset, so say that rather than installing a binary that SIGILLs on first run.
132-
# (Adding it is one entry in packages/bcode-serve/script/build.ts if ever needed.)
133-
# Only trip when /proc/cpuinfo is actually readable — unknown is not "absent".
134-
if [ "$arch" = "x64" ] && [ -r /proc/cpuinfo ] && ! grep -qwi avx2 /proc/cpuinfo; then
135-
echo -e "${RED}Error: this CPU lacks AVX2, and no baseline ${VARIANT} build is published.${NC}" >&2
136-
echo -e "${MUTED}Use https://bcode.sh/install, which ships a baseline binary.${NC}" >&2
137-
exit 1
95+
# A glibc binary cannot exec on musl. musl's ldd prints its banner and then exits
96+
# non-zero, so match its output — the pipeline's status is grep's, not ldd's.
97+
target=linux-$arch
98+
if [ -f /etc/alpine-release ] || ldd --version 2>&1 | grep -qi musl; then
99+
target=$target-musl
138100
fi
101+
asset=bcode-$target-serve.tar.gz
139102

140-
target="${os}-${arch}"
141-
[ "$is_musl" = true ] && target="${target}-musl"
142-
filename="${APP}-${target}-${VARIANT}.tar.gz"
143-
144-
if [ -z "$requested_version" ]; then
145-
url="https://github.com/${REPO}/releases/latest/download/${filename}"
146-
specific_version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null \
147-
| sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' || true)
148-
if [ -z "$specific_version" ]; then
149-
echo -e "${RED}Failed to resolve the latest version.${NC}" >&2
150-
echo -e "${MUTED}If the repo is private, pin a version: --version <ver>${NC}" >&2
151-
exit 1
152-
fi
103+
if [ -n "$version" ]; then
104+
version=${version#v}
153105
else
154-
specific_version="${requested_version#v}"
155-
url="https://github.com/${REPO}/releases/download/v${specific_version}/${filename}"
106+
version=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null |
107+
sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
108+
[ -n "$version" ] || die "Could not resolve the latest version." "Pin one with --version <ver>."
156109
fi
110+
url=https://github.com/$REPO/releases/download/v$version/$asset
157111

158-
echo -e "${MUTED}Installing ${NC}${APP} ${MUTED}(${VARIANT} build) version: ${NC}${specific_version}"
112+
say "Installing bcode $version ($target, serve build)"
159113

160-
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bcode_bytecode_install.XXXXXX")
161-
staged=""
114+
tmp=$(mktemp -d)
115+
staged=
116+
# `return 0` is load-bearing: the `&&` above it returns 1 whenever staged is
117+
# empty, which under `set -e` would turn a clean run into a non-zero exit.
162118
cleanup() {
163-
rm -rf "$tmp_dir"
164-
[ -n "${staged:-}" ] && rm -f "$staged"
165-
return 0
119+
rm -rf "$tmp"
120+
[ -n "$staged" ] && rm -f "$staged"
121+
return 0
166122
}
167123
trap cleanup EXIT
168-
169-
# Fail loudly rather than unpacking a 404 body. Releases predating this variant
170-
# legitimately lack the asset, so say that instead of erroring out of tar.
171-
if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url"; then
172-
echo -e "${RED}Error: could not download ${filename} for v${specific_version}.${NC}" >&2
173-
echo -e "${MUTED}URL: ${url}${NC}" >&2
174-
echo -e "${MUTED}Releases predating the ${VARIANT} variant do not publish this asset.${NC}" >&2
175-
echo -e "${MUTED}Check https://github.com/${REPO}/releases for one that does, or use https://bcode.sh/install.${NC}" >&2
176-
exit 1
177-
fi
178-
179-
tar -xzf "${tmp_dir}/${filename}" -C "$tmp_dir"
180-
181-
if [ ! -f "${tmp_dir}/${APP}" ]; then
182-
echo -e "${RED}Error: archive did not contain a '${APP}' binary.${NC}" >&2
183-
exit 1
124+
# ash/dash do not run the EXIT trap on a signal; `docker build` cancellation
125+
# sends TERM. Exiting from the handler routes through the EXIT trap above.
126+
trap 'exit 130' INT
127+
trap 'exit 143' TERM
128+
trap 'exit 129' HUP
129+
130+
curl -fsSL -o "$tmp/$asset" "$url" || die \
131+
"Could not download $asset for v$version." \
132+
"URL: $url" \
133+
"Releases published before this variant existed do not carry the asset."
134+
135+
tar -xzf "$tmp/$asset" -C "$tmp"
136+
[ -f "$tmp/bcode" ] || die "Archive did not contain a bcode binary."
137+
138+
# Stage inside the install dir, validate, then swap. Validating first means a bad
139+
# download never replaces a working bcode; staging here rather than in /tmp avoids
140+
# requiring an exec-capable /tmp (noexec there is common hardening) and makes the
141+
# final step a same-filesystem rename, so the swap is atomic.
142+
case $install_dir in
143+
"") die "Install directory cannot be empty." ;;
144+
-*) die "Install directory must not start with '-' (got: $install_dir)." ;;
145+
esac
146+
mkdir -p -- "$install_dir"
147+
if [ -d "$install_dir/bcode" ]; then
148+
die "$install_dir/bcode is a directory; refusing to install over it."
184149
fi
185-
186-
# Stage inside the install dir, validate, then swap into place.
187-
#
188-
# Validating before the swap keeps a corrupt or wrong-libc download from
189-
# replacing a working bcode. Staging in the install dir rather than the temp dir
190-
# keeps that safety without requiring an exec-capable /tmp — `noexec` there is
191-
# common hardening, and the install dir has to allow exec regardless. It also
192-
# makes the final step a same-filesystem `mv`, so the swap is atomic and no
193-
# reader can observe a half-written binary.
194-
mkdir -p "$install_dir"
195-
staged=$(mktemp "${install_dir}/.${APP}.XXXXXX")
196-
mv "${tmp_dir}/${APP}" "$staged"
150+
staged=$(mktemp "$install_dir/.bcode.XXXXXX")
151+
mv "$tmp/bcode" "$staged"
197152
chmod 755 "$staged"
198153

199-
if ! installed_version=$("$staged" --version 2>/dev/null); then
200-
echo -e "${RED}Error: downloaded binary failed to run; leaving any existing install untouched.${NC}" >&2
201-
echo -e "${MUTED}If ${install_dir} is mounted noexec, install elsewhere with --install-dir.${NC}" >&2
202-
exit 1
154+
# Keep the binary's own stderr: on a libc mismatch it names the missing loader,
155+
# which is the actual diagnosis.
156+
if ! installed=$("$staged" --version 2>"$tmp/err"); then
157+
die "Downloaded binary failed to run; existing install left untouched." \
158+
"$(cat "$tmp/err" 2>/dev/null)" \
159+
"If $install_dir is mounted noexec, pass --install-dir."
203160
fi
204161

205-
mv "$staged" "${install_dir}/${APP}"
206-
staged=""
162+
mv "$staged" "$install_dir/bcode"
163+
staged=
207164

208-
echo -e "${MUTED}Installed ${NC}${install_dir}/${APP}${MUTED} (${installed_version})${NC}"
209-
echo -e "${MUTED}This build provides '${APP} serve' only.${NC}"
165+
say "Installed $install_dir/bcode ($installed) — provides 'bcode serve' only"
166+
case ":$PATH:" in
167+
*":$install_dir:"*) ;;
168+
*) say "Not on PATH. Add it with: export PATH=\"$install_dir:\$PATH\"" ;;
169+
esac

0 commit comments

Comments
 (0)