diff --git a/.gitignore b/.gitignore
index 168a4441..2e8e8814 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,7 @@ release/
.DS_Store
.claude/settings.local.json
.claude/scheduled_tasks.lock
+# Opt-in cross-platform server tarballs staged for packaging (see
+# scripts/stage-server-tarballs.mjs); the .gitkeep itself is tracked.
+resources/headless/*.tar.gz
+resources/headless/*.tar.gz.sha256
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8020a452..76d46572 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,6 +26,58 @@ npm run dev
The `--legacy-peer-deps` flag is required because of an `electron-vite@5` peer range.
+## Building and testing on Linux
+
+A few Docker-based helpers let you build and exercise Harness on Linux without leaving your Mac — the standalone `harness-server` (see the README's [Headless server](README.md#headless-server) section for the user-facing install + connect flow) and the full desktop UI over VNC. All of them need Docker running.
+
+### Building Linux tarballs — `pack:headless:linux`
+
+`harness-server` can't be cross-compiled the easy way: it bundles `node-pty` (a native C++ addon) and a platform-gated `@anthropic-ai/claude-code-` prebuilt, so each Linux tarball has to be assembled with a Linux toolchain. The script does that inside a per-arch container:
+
+```sh
+npm run pack:headless:linux # both linux/arm64 + linux/amd64
+npm run pack:headless:linux linux/arm64 # just one arch
+```
+
+Tarballs land in `release/headless/` as `harness-server--linux-.tar.gz` (+ `.sha256`).
+
+On Apple Silicon the `linux/arm64` build is VM-native and quick; `linux/amd64` runs under emulation. The script keeps that cheap by running the heavy `npm ci` + bundle step **once** on the native arch into a shared volume and only compiling the small per-arch bits (`node-pty`) under emulation — both arches together build in ~5 min. Pass `linux/arm64` alone when amd64 isn't what you're testing, and turning on Docker Desktop's "Use Rosetta for x86/amd64 emulation" speeds the amd64 path further.
+
+To build a tarball for the platform you're already on (the `darwin-arm64` tarball on your Mac, or natively on a Linux box), skip Docker and run `npm run pack:headless` directly.
+
+### Running the server in a container — `run-headless-container.sh`
+
+Once the matching tarball exists, this spins up an Ubuntu container, installs Node + `claude` + `codex`, installs the tarball, and prints how to start the server and connect:
+
+```sh
+./scripts/run-headless-container.sh linux/arm64 # server :37291, ssh :2222
+./scripts/run-headless-container.sh linux/amd64 # server :37292, ssh :2223
+```
+
+Each arch gets its own ports and container name, so you can run both at once. The script injects your `~/.ssh` public key so you can `ssh -p root@localhost` into the box (handy for authenticating `claude`/`codex`). It stops short of starting the server so you choose when — it echoes the exact `docker exec … harness-server --host 0.0.0.0 --port ` command plus the connect URL (open it in a browser, or paste it into the Electron app's `File → Add Backend…`).
+
+Tear down when finished:
+
+```sh
+docker rm -f harness_linux-arm64 harness_linux-amd64
+```
+
+### Running the full UI over VNC — `run-ui-container.sh`
+
+To exercise the actual Electron desktop app on Linux (not just the headless server), this builds Harness from source in a `linux/arm64` container and runs it on a virtual display, served over VNC:
+
+```sh
+./scripts/run-ui-container.sh
+```
+
+It installs Electron's runtime libraries + Node + `claude`/`codex`, builds the app (`electron-vite build`), and launches it under Xvfb + fluxbox with `x11vnc` (the app runs as root with the sandbox disabled, the same `ELECTRON_DISABLE_SANDBOX` the `dev` script uses). Connect from the host with a VNC client:
+
+```sh
+open vnc://localhost:5901 # macOS Screen Sharing; password: harness
+```
+
+Override the repo with `HARNESS_CLONE_URL`, the host port with `HARNESS_VNC_PORT`, the password with `HARNESS_VNC_PASSWORD`, and the screen size with `HARNESS_UI_GEOMETRY`. The Electron log is at `/var/log/harness-ui.log` inside the container. Tear down with `docker rm -f harness_ui`.
+
## How to edit code in this codebase
Honestly - every single line of code in this codebase is written by claude. (at least all the lines I wrote). So I highly recommend using claude code to make changes (I keep harness itself open at all times)
diff --git a/package.json b/package.json
index d455df8d..d2ea22bc 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"build:headless": "vite build --config vite.headless.config.ts && vite build --config vite.headless-web.config.ts",
"dev:headless": "npm run build:headless && HARNESS_DATA_DIR=./.headless-data HARNESS_WS_HOST=${HARNESS_WS_HOST:-127.0.0.1} node dist-headless/main/index.js",
"pack:headless": "node scripts/pack-headless.mjs",
- "pack:headless:all": "echo 'Cross-platform packaging happens in CI. Run pack:headless to build for the current host.'",
+ "pack:headless:linux": "bash scripts/pack-headless-linux.sh",
+ "pack:headless:all": "npm run pack:headless && npm run pack:headless:linux",
"preview": "electron-vite preview",
"typecheck": "tsc -b --force",
"test": "vitest run",
@@ -29,6 +30,8 @@
"rebuild:dev": "electron-rebuild -f -w node-pty",
"pack": "npm run build && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --dir --arm64 --config.mac.identity=null",
"postpack": "npm run rebuild:dev",
+ "bundle:servers": "node scripts/stage-server-tarballs.mjs",
+ "pack:servers": "npm run bundle:servers && npm run pack",
"dist": "npm run build && dotenv -e .env -- electron-builder",
"postdist": "npm run rebuild:dev",
"dist:mac": "npm run build && dotenv -e .env -- electron-builder --mac",
@@ -70,6 +73,15 @@
{
"from": "resources/permission-prompt-mcp.js",
"to": "permission-prompt-mcp.js"
+ },
+ {
+ "from": "scripts/install-headless.sh",
+ "to": "install-headless.sh"
+ },
+ {
+ "from": "resources/headless",
+ "to": "headless",
+ "filter": ["**/*.tar.gz", "**/*.tar.gz.sha256"]
}
],
"mac": {
diff --git a/resources/headless/.gitkeep b/resources/headless/.gitkeep
new file mode 100644
index 00000000..68cad7b4
--- /dev/null
+++ b/resources/headless/.gitkeep
@@ -0,0 +1,10 @@
+# Staging dir for opt-in cross-platform harness-server tarballs.
+#
+# `scripts/stage-server-tarballs.mjs` (npm run bundle:servers / pack:servers)
+# copies release/headless/harness-server-*-.tar.gz (+ .sha256) here
+# so electron-builder's extraResources picks them up into the packaged app's
+# resources/headless/. The SSH bootstrap's upload mode then finds them via
+# resolveBundledServerDir() and pushes the matching one to a remote.
+#
+# Default builds leave this dir empty (the *.tar.gz* are gitignored), so the
+# packaged app carries no extra ~130MB-per-platform payload unless you opt in.
diff --git a/scripts/install-headless.sh b/scripts/install-headless.sh
index 95606ed7..9e452f73 100755
--- a/scripts/install-headless.sh
+++ b/scripts/install-headless.sh
@@ -7,6 +7,12 @@
# HARNESS_SERVER_VERSION pinned version tag (default: latest)
# HARNESS_SERVER_BASE_URL base URL serving the tarball + .sha256
# (default: GitHub releases for frenchie4111/harness)
+# HARNESS_SERVER_TARBALL absolute path to a tarball ALREADY staged on this
+# machine (e.g. uploaded over SSH by the Harness app).
+# When set, the GitHub download + version resolution
+# are skipped entirely and this file is installed
+# directly. If a sibling ".sha256" exists (or
+# HARNESS_SERVER_SHA256 is set) it is verified.
#
# POSIX-only — runs under dash, ash, busybox sh in addition to bash/zsh.
@@ -44,32 +50,7 @@ if [ "$PLATFORM" = "darwin-x64" ]; then
err "darwin-x64 (Intel Mac) tarballs are not currently shipped. Run on Apple Silicon, or build from source."
fi
-# --- version resolution ---
-VERSION="${HARNESS_SERVER_VERSION:-latest}"
-if [ "$VERSION" = "latest" ]; then
- log "resolving latest harness-server release..."
- if command -v curl >/dev/null 2>&1; then
- LATEST_JSON=$(curl -fsSL "https://api.github.com/repos/$OWNER/$REPO/releases/latest")
- else
- err "curl is required but not installed"
- fi
- # Parse "tag_name": "v1.2.3" without jq.
- VERSION=$(printf '%s\n' "$LATEST_JSON" | sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -n1)
- if [ -z "$VERSION" ]; then
- err "could not parse latest version from GitHub API response"
- fi
-fi
-# Strip a leading 'v' if the user passed one.
-VERSION="${VERSION#v}"
-
-# --- download URL ---
-TARBALL="harness-server-$VERSION-$PLATFORM.tar.gz"
-DEFAULT_BASE="https://github.com/$OWNER/$REPO/releases/download/v$VERSION"
-BASE_URL="${HARNESS_SERVER_BASE_URL:-$DEFAULT_BASE}"
-URL="$BASE_URL/$TARBALL"
-SHA_URL="$URL.sha256"
-
-# --- pick a sha256 tool ---
+# --- pick a sha256 tool (needed in both download + local-tarball modes) ---
if command -v shasum >/dev/null 2>&1; then
sha256_cmd="shasum -a 256"
elif command -v sha256sum >/dev/null 2>&1; then
@@ -78,39 +59,95 @@ else
err "neither shasum nor sha256sum is available"
fi
-# --- download ---
+LOCAL_TARBALL="${HARNESS_SERVER_TARBALL:-}"
DL_DIR=$(mktemp -d)
# Best effort cleanup; if the script blows up the OS reaps /tmp eventually.
trap 'rm -rf "$DL_DIR"' EXIT
-log "downloading $URL"
-if ! curl -fsSL --output "$DL_DIR/$TARBALL" "$URL"; then
- err "download failed: $URL"
-fi
-log "downloading $SHA_URL"
-if ! curl -fsSL --output "$DL_DIR/$TARBALL.sha256" "$SHA_URL"; then
- err "checksum download failed: $SHA_URL"
-fi
+if [ -n "$LOCAL_TARBALL" ]; then
+ # --- local-tarball mode: the Harness app already staged the bytes here ---
+ [ -f "$LOCAL_TARBALL" ] || err "HARNESS_SERVER_TARBALL not found: $LOCAL_TARBALL"
+ TARBALL_FILE="$LOCAL_TARBALL"
+ log "installing from staged tarball $LOCAL_TARBALL"
+ # Verify if we were handed (or can find) a checksum; otherwise the bytes
+ # came straight off the local machine over an authenticated channel, so a
+ # missing checksum is a warning, not a hard error.
+ EXPECTED="${HARNESS_SERVER_SHA256:-}"
+ if [ -z "$EXPECTED" ] && [ -f "$LOCAL_TARBALL.sha256" ]; then
+ EXPECTED=$(awk '{print $1}' "$LOCAL_TARBALL.sha256")
+ fi
+ if [ -n "$EXPECTED" ]; then
+ log "verifying checksum..."
+ ACTUAL=$($sha256_cmd "$TARBALL_FILE" | awk '{print $1}')
+ if [ "$EXPECTED" != "$ACTUAL" ]; then
+ err "sha256 mismatch: expected $EXPECTED, got $ACTUAL"
+ fi
+ else
+ log "no checksum provided for staged tarball — skipping verification"
+ fi
+else
+ # --- download mode: pull the tarball from a GitHub release ---
+ VERSION="${HARNESS_SERVER_VERSION:-latest}"
+ if [ "$VERSION" = "latest" ]; then
+ log "resolving latest harness-server release..."
+ if command -v curl >/dev/null 2>&1; then
+ LATEST_JSON=$(curl -fsSL "https://api.github.com/repos/$OWNER/$REPO/releases/latest")
+ else
+ err "curl is required but not installed"
+ fi
+ # Parse "tag_name": "v1.2.3" without jq.
+ VERSION=$(printf '%s\n' "$LATEST_JSON" | sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -n1)
+ if [ -z "$VERSION" ]; then
+ err "could not parse latest version from GitHub API response"
+ fi
+ fi
+ # Strip a leading 'v' if the user passed one.
+ VERSION="${VERSION#v}"
+
+ TARBALL="harness-server-$VERSION-$PLATFORM.tar.gz"
+ DEFAULT_BASE="https://github.com/$OWNER/$REPO/releases/download/v$VERSION"
+ BASE_URL="${HARNESS_SERVER_BASE_URL:-$DEFAULT_BASE}"
+ URL="$BASE_URL/$TARBALL"
+ SHA_URL="$URL.sha256"
+
+ log "downloading $URL"
+ if ! curl -fsSL --output "$DL_DIR/$TARBALL" "$URL"; then
+ err "download failed: $URL"
+ fi
+ log "downloading $SHA_URL"
+ if ! curl -fsSL --output "$DL_DIR/$TARBALL.sha256" "$SHA_URL"; then
+ err "checksum download failed: $SHA_URL"
+ fi
-# --- verify ---
-log "verifying checksum..."
-EXPECTED=$(awk '{print $1}' "$DL_DIR/$TARBALL.sha256")
-ACTUAL=$($sha256_cmd "$DL_DIR/$TARBALL" | awk '{print $1}')
-if [ "$EXPECTED" != "$ACTUAL" ]; then
- err "sha256 mismatch: expected $EXPECTED, got $ACTUAL"
+ log "verifying checksum..."
+ EXPECTED=$(awk '{print $1}' "$DL_DIR/$TARBALL.sha256")
+ ACTUAL=$($sha256_cmd "$DL_DIR/$TARBALL" | awk '{print $1}')
+ if [ "$EXPECTED" != "$ACTUAL" ]; then
+ err "sha256 mismatch: expected $EXPECTED, got $ACTUAL"
+ fi
+ TARBALL_FILE="$DL_DIR/$TARBALL"
fi
# --- extract atomically ---
log "extracting to $INSTALL_DIR"
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR"
-tar -xzf "$DL_DIR/$TARBALL" -C "$TMP_DIR"
-# Tarball's top-level dir is harness-server--/; flatten
-# it so $INSTALL_DIR/bin/harness-server is the canonical path regardless
-# of version.
-EXTRACTED="$TMP_DIR/harness-server-$VERSION-$PLATFORM"
-if [ ! -d "$EXTRACTED" ]; then
- err "tarball did not contain expected directory: harness-server-$VERSION-$PLATFORM"
+tar -xzf "$TARBALL_FILE" -C "$TMP_DIR"
+# The tarball's sole top-level dir is harness-server--/;
+# flatten it so $INSTALL_DIR/bin/harness-server is the canonical path
+# regardless of version. We locate it generically (the single child dir)
+# rather than reconstructing the name, so local-tarball mode doesn't need to
+# know the version baked into the archive.
+EXTRACTED=""
+for d in "$TMP_DIR"/*/; do
+ [ -d "$d" ] || continue
+ if [ -n "$EXTRACTED" ]; then
+ err "tarball contained more than one top-level directory"
+ fi
+ EXTRACTED="${d%/}"
+done
+if [ -z "$EXTRACTED" ] || [ ! -d "$EXTRACTED" ]; then
+ err "tarball did not contain a top-level harness-server directory"
fi
rm -rf "$INSTALL_DIR"
mv "$EXTRACTED" "$INSTALL_DIR"
diff --git a/scripts/pack-headless-linux.sh b/scripts/pack-headless-linux.sh
new file mode 100755
index 00000000..8e1fedbc
--- /dev/null
+++ b/scripts/pack-headless-linux.sh
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+#
+# Build Linux harness-server tarballs from a macOS (or any) host.
+#
+# Usage: ./scripts/pack-headless-linux.sh [platform ...]
+# Example:
+# ./scripts/pack-headless-linux.sh # both arm64 + amd64
+# ./scripts/pack-headless-linux.sh linux/arm64 # just arm64
+#
+# Why this isn't a single cross-compile: harness-server bundles two
+# arch-specific artifacts — node-pty (a native C++ addon) and the
+# platform-gated @anthropic-ai/claude-code- prebuilt. Everything ELSE in
+# the tarball (the vite-bundled main + web-client JS, the `ws` dep, node-pty's
+# own JS) is byte-identical across arches, and the pinned Node binary is just a
+# per-arch download from nodejs.org.
+#
+# So we don't pay the full `npm ci` + `build:headless` cost once per arch.
+# Instead:
+# Phase 1 (native arch, full speed): one `npm ci` + `build:headless` into a
+# shared Linux node_modules volume. On Apple Silicon the linux/arm64
+# container is VM-native (no Rosetta), so this is the fast path even
+# when the only tarball you want is amd64.
+# Phase 2 (per target arch): just `npm run pack:headless` against that shared
+# volume. For a non-native arch the only emulated work is compiling
+# the one node-pty addon and downloading that arch's claude prebuilt
+# + Node binary — minutes, not the ~30 min an emulated `npm ci` costs.
+#
+# The shared node_modules lives in an ephemeral named volume (removed on exit),
+# never the host's macOS node_modules, so the Linux and Electron ABIs never
+# mix.
+#
+# CI (.github/workflows/headless-release.yml) builds each platform on a native
+# runner via `npm run pack:headless` directly and does NOT use this script —
+# this is a local-dev convenience only.
+#
+# Output (on the host):
+# release/headless/harness-server--linux-arm64.tar.gz (+ .sha256)
+# release/headless/harness-server--linux-x64.tar.gz (+ .sha256)
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+
+err() { printf 'error: %s\n' "$*" >&2; exit 1; }
+log() { printf '\n=== %s ===\n' "$*"; }
+
+command -v docker >/dev/null 2>&1 || err "docker is required but not installed"
+
+# Default to both arches; allow overriding via args (e.g. linux/arm64).
+if [ "$#" -gt 0 ]; then
+ PLATFORMS=("$@")
+else
+ PLATFORMS=("linux/arm64" "linux/amd64")
+fi
+
+# The docker platform matching the host arch runs at full speed; the other is
+# emulated (QEMU/Rosetta). Build the shared deps on the native one so the
+# expensive npm ci + vite build never runs under emulation.
+case "$(uname -m)" in
+ arm64|aarch64) NATIVE_PLATFORM="linux/arm64" ;;
+ x86_64|amd64) NATIVE_PLATFORM="linux/amd64" ;;
+ *) NATIVE_PLATFORM="${PLATFORMS[0]}" ;;
+esac
+
+# node:22 is the full (buildpack-deps) variant and already ships g++/make/
+# python3 for node-gyp; only apt-install if a future image drops them.
+IMAGE="node:22"
+ENSURE_TOOLCHAIN='command -v g++ >/dev/null && command -v make >/dev/null && command -v python3 >/dev/null || {
+ apt-get update && apt-get install -y --no-install-recommends python3 make g++; }'
+
+claude_pkg_arch() {
+ case "$1" in
+ linux/arm64) echo "linux-arm64" ;;
+ linux/amd64) echo "linux-x64" ;;
+ *) err "unsupported platform '$1' (expected linux/arm64 or linux/amd64)" ;;
+ esac
+}
+
+# --- shared Linux node_modules: ephemeral named volume, never the host tree ---
+NM_VOL="harness_headless_nm_$$"
+docker volume create "$NM_VOL" >/dev/null
+cleanup() { docker volume rm -f "$NM_VOL" >/dev/null 2>&1 || true; }
+trap cleanup EXIT
+
+run_in() { # run_in
+ docker run --rm \
+ --platform "$1" \
+ -v "$REPO_ROOT":/src \
+ -v "$NM_VOL":/src/node_modules \
+ -w /src \
+ "$IMAGE" \
+ bash -lc "$2"
+}
+
+# --- phase 1: install + build once on the native arch (output is arch-free) ---
+log "phase 1: npm ci + build:headless on $NATIVE_PLATFORM (shared deps)"
+run_in "$NATIVE_PLATFORM" "set -e
+ $ENSURE_TOOLCHAIN
+ npm ci --legacy-peer-deps
+ npm run build:headless"
+
+# --- phase 2: assemble one tarball per target arch from the shared deps ---
+for platform in "${PLATFORMS[@]}"; do
+ arch="$(claude_pkg_arch "$platform")"
+ log "phase 2: pack $platform"
+ run_in "$platform" "set -e
+ $ENSURE_TOOLCHAIN
+ # Stage this arch's claude prebuilt if the shared tree lacks it (the native
+ # arch's came in via npm ci). Download + unpack only — the binary never
+ # runs here — so it's cheap even under emulation. No --os/--cpu override is
+ # needed because the container's own arch already matches the package.
+ if [ ! -d node_modules/@anthropic-ai/claude-code-$arch ]; then
+ ver=\$(node -p \"require('@anthropic-ai/claude-code/package.json').version\")
+ npm install --no-save --ignore-scripts --legacy-peer-deps \
+ @anthropic-ai/claude-code-$arch@\$ver
+ fi
+ # pack:headless rebuilds node-pty for THIS arch + bundles this arch's
+ # claude + Node binary; everything else is copied from the shared tree.
+ npm run pack:headless"
+done
+
+log "done — tarballs in release/headless/"
+ls -1 "$REPO_ROOT"/release/headless/harness-server-*-linux-*.tar.gz 2>/dev/null || true
diff --git a/scripts/pack-headless.mjs b/scripts/pack-headless.mjs
index 40092c9a..788a9a43 100644
--- a/scripts/pack-headless.mjs
+++ b/scripts/pack-headless.mjs
@@ -213,7 +213,17 @@ async function main() {
)
const shim = `#!/bin/sh
-DIR="$(cd "$(dirname "$0")/.." && pwd)"
+# Resolve symlinks so the launcher still finds its bundled Node + app when
+# invoked via a symlink (e.g. /usr/local/bin/harness-server -> .../bin/...).
+SELF="$0"
+while [ -L "$SELF" ]; do
+ LINK="$(readlink "$SELF")"
+ case "$LINK" in
+ /*) SELF="$LINK" ;;
+ *) SELF="$(dirname "$SELF")/$LINK" ;;
+ esac
+done
+DIR="$(cd "$(dirname "$SELF")/.." && pwd)"
# --version short-circuits without booting Node so it stays instant.
case "$1" in
diff --git a/scripts/reset-headless-container.sh b/scripts/reset-headless-container.sh
new file mode 100755
index 00000000..47449fab
--- /dev/null
+++ b/scripts/reset-headless-container.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+#
+# Reset a headless container back to a bare host: SSH in, stop any running
+# harness-server, uninstall it (~/.harness-server + the /usr/local/bin
+# symlink), and wipe the server's data dir (~/.harness) plus agent state
+# (~/.claude, ~/.codex) so nothing carries over. The container's plumbing —
+# SSH, the Node runtime, the installed claude/codex binaries, the repo clone —
+# is left intact, so you can immediately re-provision it from the Harness app
+# (SSH bootstrap) without the full teardown + rebuild that
+# `docker rm -f` + `run-headless-container.sh` costs. Note that wiping
+# ~/.claude / ~/.codex clears their auth, so you'll re-authenticate the agents
+# after a reset.
+#
+# Usage: ./scripts/reset-headless-container.sh
+# Example:
+# ./scripts/reset-headless-container.sh linux/arm64 # ssh root@localhost:2222
+# ./scripts/reset-headless-container.sh linux/amd64 # ssh root@localhost:2223
+#
+# Connects over SSH (not docker exec) so it exercises the same path the real
+# remote-reset flow would — key-based as root, using the same key
+# run-headless-container.sh injected. Idempotent: safe if nothing is
+# installed or running.
+
+set -euo pipefail
+
+err() { printf 'error: %s\n' "$*" >&2; exit 1; }
+log() { printf '\n=== %s ===\n' "$*"; }
+
+# --- parameterize on platform (mirror run-headless-container.sh) ---
+PLATFORM="${1:-}"
+case "$PLATFORM" in
+ linux/arm64) SSH_PORT=2222 ;;
+ linux/amd64) SSH_PORT=2223 ;;
+ *) err "usage: $0 " ;;
+esac
+NAME="harness_${PLATFORM//\//-}"
+
+# --- resolve the private key whose .pub run-headless-container.sh injected ---
+# Same discovery order, so we present the key the container authorized.
+KEY_FILE=""
+for f in id_ed25519 id_rsa id_ecdsa; do
+ if [ -f "$HOME/.ssh/$f" ]; then KEY_FILE="$HOME/.ssh/$f"; break; fi
+done
+[ -n "$KEY_FILE" ] || err "no SSH private key in ~/.ssh (looked for id_ed25519/id_rsa/id_ecdsa)"
+
+# Container host keys change on every rebuild, so don't pin them.
+SSH_OPTS=(-p "$SSH_PORT" -i "$KEY_FILE"
+ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR)
+
+log "resetting $NAME via ssh root@localhost:$SSH_PORT"
+
+# Remote reset. Variable refs (e.g. "$INSTALL_DIR/...") stay literal in this
+# heredoc until the REMOTE shell expands them — so pkill's expanded pattern
+# can't match this script's own command line (no self-kill foot-gun).
+ssh "${SSH_OPTS[@]}" root@localhost 'sh -s' <<'REMOTE'
+set -u
+INSTALL_DIR="${HARNESS_SERVER_INSTALL_DIR:-$HOME/.harness-server}"
+stopped=0
+
+# 1. Stop the server we recorded in state.json (the detached node process).
+if [ -f "$INSTALL_DIR/state.json" ]; then
+ pid=$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$INSTALL_DIR/state.json")
+ if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then
+ kill "$pid" 2>/dev/null && stopped=1
+ echo "stopped harness-server pid $pid"
+ fi
+fi
+
+# 2. Backstop: kill any straggler running the bundled server entrypoint. The
+# pattern is the expanded install path, which this heredoc never contains
+# literally (it references $INSTALL_DIR), so pkill won't match itself.
+if pkill -f "$INSTALL_DIR/lib/main/index.js" 2>/dev/null; then
+ stopped=1
+ echo "stopped straggler harness-server process(es)"
+fi
+
+# 3. Uninstall: the install tree + the best-effort /usr/local/bin symlink.
+if [ -e "$INSTALL_DIR" ]; then
+ rm -rf "$INSTALL_DIR"
+ echo "removed $INSTALL_DIR"
+else
+ echo "no install at $INSTALL_DIR"
+fi
+if [ -L /usr/local/bin/harness-server ] || [ -e /usr/local/bin/harness-server ]; then
+ rm -f /usr/local/bin/harness-server 2>/dev/null && echo "removed /usr/local/bin/harness-server symlink" || true
+fi
+
+# 4. Wipe the server data dir + agent state so nothing carries into the next
+# provision. ~/.harness is the headless server's HARNESS_DATA_DIR (config,
+# secrets, worktree/pane state); ~/.claude and ~/.codex hold the agents'
+# auth + config. (HARNESS_DATA_DIR can be overridden, but the bootstrap
+# starts the server with the default ~/.harness.)
+for d in "$HOME/.harness" "$HOME/.claude" "$HOME/.codex"; do
+ if [ -e "$d" ]; then
+ rm -rf "$d"
+ echo "removed $d"
+ fi
+done
+
+echo "reset complete (stopped=$stopped)"
+REMOTE
+
+log "done — $NAME is a bare host again; re-provision from the Harness app"
diff --git a/scripts/run-headless-container.sh b/scripts/run-headless-container.sh
new file mode 100755
index 00000000..44124c01
--- /dev/null
+++ b/scripts/run-headless-container.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+#
+# Start a clean Linux container that acts as a REMOTE HOST for Harness's SSH
+# bootstrap. It installs prerequisites + claude + codex, enables key-based
+# SSH, and clones the harness repo in so the server has something to manage —
+# but it deliberately does NOT install harness-server and copies no tarball
+# in. Provisioning is Harness's job: add this container as an SSH backend
+# ('+' in the backend chip strip) and Harness ships its own install-headless.sh
+# over the wire, then either uploads a locally-built tarball or pulls a
+# published release inside the container.
+#
+# Usage: ./scripts/run-headless-container.sh
+# Example:
+# ./scripts/run-headless-container.sh linux/arm64
+# ./scripts/run-headless-container.sh linux/amd64
+#
+# Each platform gets its own ports + container name, so you can run both at
+# once:
+# linux/arm64 -> server 37291, ssh 2222, container harness_linux-arm64
+# linux/amd64 -> server 37292, ssh 2223, container harness_linux-amd64
+#
+# SSH is always enabled (key-based, as root): your public key from ~/.ssh is
+# injected so you can `ssh -p root@localhost`, and Harness's SSH
+# bootstrap drives the rest exactly like a real remote host.
+#
+# To exercise Harness's upload mode, build the matching tarball first with
+# `npm run pack:headless:linux `; with no local tarball Harness
+# falls back to downloading a published release inside the container.
+#
+# Auth for claude + codex is left to you — exec/ssh into the container and
+# authenticate however you normally do.
+
+set -euo pipefail
+
+err() { printf 'error: %s\n' "$*" >&2; exit 1; }
+log() { printf '\n=== %s ===\n' "$*"; }
+
+# --- parameterize on platform ---
+PLATFORM="${1:-}"
+case "$PLATFORM" in
+ linux/arm64) PORT=37291; SSH_PORT=2222 ;;
+ linux/amd64) PORT=37292; SSH_PORT=2223 ;;
+ *) err "usage: $0 " ;;
+esac
+
+# ubuntu:24.04 is multi-arch; --platform selects the matching arch variant.
+IMAGE="ubuntu:24.04"
+NAME="harness_${PLATFORM//\//-}" # harness_linux-arm64 / harness_linux-amd64
+
+command -v docker >/dev/null 2>&1 || err "docker is required but not installed"
+
+# --- resolve an SSH public key on the host (fail fast) ---
+PUBKEY_FILE=""
+for f in id_ed25519 id_rsa id_ecdsa; do
+ if [ -f "$HOME/.ssh/$f.pub" ]; then PUBKEY_FILE="$HOME/.ssh/$f.pub"; break; fi
+done
+[ -n "$PUBKEY_FILE" ] || err "no SSH public key in ~/.ssh (looked for id_ed25519/id_rsa/id_ecdsa .pub) — generate one with: ssh-keygen -t ed25519"
+
+# --- repo to clone into the container so the server has something to manage ---
+# Defaults to the upstream repo; override with HARNESS_CLONE_URL.
+CLONE_URL="${HARNESS_CLONE_URL:-https://github.com/frenchie4111/harness.git}"
+CLONE_DEST="$(basename "$CLONE_URL" .git)"
+
+# --- guard against an existing container of the same name ---
+if docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
+ err "container '$NAME' already exists — remove it first: docker rm -f $NAME"
+fi
+
+# --- start the container (detached, keepalive) ---
+log "starting container $NAME ($PLATFORM, server $PORT, ssh $SSH_PORT)"
+docker run -dit --name "$NAME" \
+ --platform "$PLATFORM" \
+ -p "$PORT:$PORT" \
+ -p "$SSH_PORT:22" \
+ "$IMAGE" sleep infinity >/dev/null
+
+# --- prerequisites + Node 22 + sshd ---
+log "installing prerequisites (curl, git, openssh-server, Node 22)"
+docker exec "$NAME" bash -lc '
+ set -e
+ apt-get update
+ apt-get install -y --no-install-recommends curl ca-certificates git openssh-server
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
+ apt-get install -y nodejs'
+
+# --- enable SSH: inject the host public key, start sshd (no systemd here) ---
+# Pipe the key in over stdin and write it as root. `docker cp` would preserve
+# the host file's numeric UID, leaving authorized_keys owned by a non-root uid
+# — sshd's StrictModes then ignores it and silently falls back to a password
+# prompt.
+log "enabling SSH (port $SSH_PORT, key from $PUBKEY_FILE)"
+docker exec -i "$NAME" bash -lc '
+ set -e
+ mkdir -p /root/.ssh /run/sshd
+ cat > /root/.ssh/authorized_keys
+ chmod 700 /root/.ssh
+ chmod 600 /root/.ssh/authorized_keys
+ chown -R root:root /root/.ssh
+ /usr/sbin/sshd' < "$PUBKEY_FILE"
+
+# --- claude + codex ---
+log "installing claude + codex"
+docker exec "$NAME" bash -lc '
+ set -e
+ npm install -g @anthropic-ai/claude-code @openai/codex
+ claude --version && codex --version'
+
+# harness-server is intentionally NOT installed here — Harness's SSH bootstrap
+# ships its own install-headless.sh and the server tarball when you add this
+# container as a backend. That's the whole point: this container is a faithful
+# bare remote host, provisioned end-to-end by Harness.
+
+# --- clone the harness repo so the server has a repo to manage worktrees from ---
+log "cloning $CLONE_URL into the container (~/$CLONE_DEST)"
+if docker exec "$NAME" bash -lc "git clone '$CLONE_URL' ~/$CLONE_DEST"; then
+ REPO_NOTE="A clone of $CLONE_URL is at ~/$CLONE_DEST in the container — point Harness at that path to create worktrees."
+else
+ REPO_NOTE="(repo clone failed — for a private fork, set HARNESS_CLONE_URL or clone one manually over SSH.)"
+ printf 'warning: repo clone failed; container is otherwise ready\n' >&2
+fi
+
+# --- instructions (not executed) ---
+cat < Add Backend...) -> SSH host tab
+ Pick / type: $NAME (or: root@localhost:$SSH_PORT)
+
+Harness SSHes in, provisions harness-server, starts it bound to 127.0.0.1,
+and tunnels it back over SSH — no need to publish the server port or copy a
+token by hand. (The -p $PORT:$PORT mapping is only here if you'd rather run
+'docker exec -it $NAME harness-server --host 0.0.0.0 --port $PORT' manually
+and connect a browser to http://localhost:$PORT after provisioning.)
+
+$REPO_NOTE
+
+Auth — authenticate claude + codex inside the container as you normally do:
+
+ docker exec -it $NAME bash # or: ssh $NAME
+
+Note: sshd is started directly (no systemd here), so after a container
+restart re-run it with: docker exec $NAME /usr/sbin/sshd
+
+Tear down when finished:
+
+ docker rm -f $NAME
+EOF
diff --git a/scripts/run-ui-container.sh b/scripts/run-ui-container.sh
new file mode 100755
index 00000000..28f97c8b
--- /dev/null
+++ b/scripts/run-ui-container.sh
@@ -0,0 +1,186 @@
+#!/usr/bin/env bash
+#
+# Start + provision a linux/arm64 container that runs the full Harness Electron
+# UI on a virtual display (Xvfb), exposed to the host over VNC.
+#
+# It runs THIS checkout's build — your local Harness, not a fresh clone — so
+# whatever you've committed here is what shows up in the container. The app is
+# built on the host (electron-vite produces plain JS in out/, which runs fine
+# on linux) and copied in; the container only npm-installs to get the
+# linux-native deps (electron, node-pty). Separately it clones a repo as a test
+# workspace for Harness to open and create worktrees from. Unlike
+# run-headless-container.sh (standalone harness-server + web client), this is
+# the desktop GUI under Xvfb + fluxbox + x11vnc, driven from a VNC viewer.
+#
+# Usage: ./scripts/run-ui-container.sh
+#
+# Connect from the host once it's up (macOS ships a VNC client):
+# open vnc://localhost:5901 # then enter the VNC password
+#
+# Env overrides:
+# HARNESS_CLONE_URL test-workspace repo to clone (default: upstream
+# frenchie4111/harness) — NOT the app that runs
+# HARNESS_VNC_PORT host port to map to the container's :5900 (default 5901)
+# HARNESS_VNC_PASSWORD VNC password (default: harness)
+# HARNESS_UI_GEOMETRY Xvfb screen geometry (default: 1600x1000)
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+
+err() { printf 'error: %s\n' "$*" >&2; exit 1; }
+log() { printf '\n=== %s ===\n' "$*"; }
+
+command -v docker >/dev/null 2>&1 || err "docker is required but not installed"
+
+# linux/arm64 only — VM-native on Apple Silicon, so the Electron build is quick.
+PLATFORM="linux/arm64"
+IMAGE="ubuntu:24.04"
+NAME="harness_ui"
+
+VNC_HOST_PORT="${HARNESS_VNC_PORT:-5901}" # host side; x11vnc listens on 5900 inside
+NOVNC_HOST_PORT="${HARNESS_NOVNC_PORT:-6080}" # browser noVNC endpoint (websockify -> 5900)
+VNC_PW="${HARNESS_VNC_PASSWORD:-harness}"
+GEOMETRY="${HARNESS_UI_GEOMETRY:-1600x1000}"
+CLONE_URL="${HARNESS_CLONE_URL:-https://github.com/frenchie4111/harness.git}"
+CLONE_DEST="$(basename "$CLONE_URL" .git)"
+APP_DIR="/opt/harness-app" # the host's build runs from here; the clone is separate
+
+# --- guard against an existing container of the same name ---
+if docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
+ err "container '$NAME' already exists — remove it first: docker rm -f $NAME"
+fi
+
+# --- build the local UI on the host; this is the version the container runs.
+# out/ is plain bundled JS (no native code), so it runs in the linux
+# container against the linux-native node_modules installed below. ---
+log "building the local Harness UI on the host (electron-vite build)"
+( cd "$REPO_ROOT" && npx electron-vite build ) \
+ || err "host build failed — run 'npm install --legacy-peer-deps' in the repo first"
+[ -f "$REPO_ROOT/out/main/index.js" ] || err "build produced no out/main/index.js"
+
+# --- start the container (detached, keepalive). --shm-size avoids Chromium's
+# /dev/shm exhaustion crashes. ---
+log "starting container $NAME ($PLATFORM, VNC $VNC_HOST_PORT, noVNC $NOVNC_HOST_PORT)"
+docker run -dit --name "$NAME" \
+ --platform "$PLATFORM" \
+ --shm-size=1g \
+ -p "$VNC_HOST_PORT:5900" \
+ -p "$NOVNC_HOST_PORT:6080" \
+ "$IMAGE" sleep infinity >/dev/null
+
+# --- prerequisites: virtual display + VNC + Electron's runtime libs + Node 22.
+# The *t64 package names are the Ubuntu 24.04 (time_t transition) variants. ---
+log "installing prerequisites (Xvfb, x11vnc, Electron libs, Node 22)"
+docker exec "$NAME" bash -lc '
+ set -e
+ export DEBIAN_FRONTEND=noninteractive
+ apt-get update
+ apt-get install -y --no-install-recommends \
+ xvfb x11vnc fluxbox feh x11-utils autocutsel novnc websockify dbus dbus-x11 \
+ zsh curl ca-certificates git python3 make g++ \
+ libgtk-3-0t64 libnotify4 libnss3 libxss1 libxtst6 libatspi2.0-0t64 \
+ libdrm2 libgbm1 libasound2t64 libatk1.0-0t64 libatk-bridge2.0-0t64 \
+ libcups2t64 libxkbcommon0 libpango-1.0-0 libcairo2 libxcomposite1 \
+ libxdamage1 libxrandr2 libxfixes3
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
+ apt-get install -y nodejs'
+
+# --- claude + codex: the UI's terminal/chat tabs spawn `claude` from PATH ---
+log "installing claude + codex"
+docker exec "$NAME" bash -lc '
+ set -e
+ npm install -g @anthropic-ai/claude-code @openai/codex
+ claude --version && codex --version'
+
+# --- install the host's build as the app + its linux-native deps ---
+# Snapshot the committed source (gitignored node_modules/out excluded), overlay
+# the out/ just built on the host, then npm install so node-pty + electron are
+# the linux/arm64 builds. This runs YOUR local Harness, not a fresh clone.
+log "installing the local build into the container ($APP_DIR)"
+git -C "$REPO_ROOT" archive --format=tar HEAD \
+ | docker exec -i "$NAME" bash -lc "mkdir -p $APP_DIR && tar -x -C $APP_DIR"
+docker cp "$REPO_ROOT/out" "$NAME":"$APP_DIR/out"
+docker exec "$NAME" bash -lc "cd $APP_DIR && npm install --legacy-peer-deps"
+
+# --- clone a repo as a test workspace for Harness to open (not the app) ---
+log "cloning $CLONE_URL as a test workspace (~/$CLONE_DEST)"
+if docker exec "$NAME" bash -lc "git clone '$CLONE_URL' ~/$CLONE_DEST"; then
+ REPO_NOTE="A clone of $CLONE_URL is at ~/$CLONE_DEST in the container — point Harness at that path to create worktrees."
+else
+ REPO_NOTE="(test-workspace clone failed — for a private fork set HARNESS_CLONE_URL, or clone one over SSH.)"
+ printf 'warning: test-workspace clone failed; the UI is otherwise ready\n' >&2
+fi
+
+# --- store the VNC password ---
+log "configuring VNC (password auth)"
+docker exec "$NAME" bash -lc "mkdir -p ~/.vnc && x11vnc -storepasswd '$VNC_PW' ~/.vnc/passwd"
+
+# --- install the display+UI launcher ---
+# Brings up Xvfb, a window manager, x11vnc, then the Electron app. The app runs
+# as root with the sandbox disabled (same ELECTRON_DISABLE_SANDBOX the repo's
+# dev script uses) and software GL, since there's no GPU under Xvfb.
+docker exec -i "$NAME" bash -lc 'cat > /usr/local/bin/start-ui.sh && chmod +x /usr/local/bin/start-ui.sh' </var/log/xvfb.log 2>&1 &
+for _ in \$(seq 1 30); do xdpyinfo -display :99 >/dev/null 2>&1 && break; sleep 0.5; done
+# Paint the root window with fluxbox's own fbsetroot (bundled, no deps) so it
+# doesn't fall back to fbsetbg — which warns when no image-setter is installed.
+mkdir -p /root/.fluxbox
+printf 'session.screen0.rootCommand: fbsetroot -solid #1e1e1e\n' > /root/.fluxbox/init
+fluxbox >/var/log/fluxbox.log 2>&1 &
+# Keep the X CLIPBOARD (what Electron uses) and PRIMARY selections in sync with
+# the cut buffer x11vnc bridges to VNC, so copy+paste works to/from the host.
+autocutsel -fork
+autocutsel -selection PRIMARY -fork
+x11vnc -display :99 -forever -shared -rfbport 5900 -rfbauth /root/.vnc/passwd \
+ -bg -o /var/log/x11vnc.log
+# noVNC: serve the browser VNC client and proxy its WebSocket to x11vnc:5900.
+websockify --web=/usr/share/novnc 6080 localhost:5900 >/var/log/websockify.log 2>&1 &
+cd ${APP_DIR}
+dbus-run-session -- node_modules/.bin/electron . \
+ --no-sandbox --disable-gpu --disable-dev-shm-usage \
+ >/var/log/harness-ui.log 2>&1
+LAUNCH
+
+# --- launch the UI stack (detached; container PID 1 stays sleep infinity) ---
+log "launching the Harness UI"
+docker exec -d "$NAME" /usr/local/bin/start-ui.sh
+
+cat < name.endsWith(`-${p}.tar.gz`))
+}
+
+if (!existsSync(srcDir)) {
+ console.error(`error: ${srcDir} does not exist — build tarballs first (npm run pack:headless:all)`)
+ process.exit(1)
+}
+
+mkdirSync(destDir, { recursive: true })
+
+// Clear any previously-staged tarballs so a re-run doesn't leave stale
+// platforms behind (the .gitkeep and anything non-tarball is left alone).
+for (const name of readdirSync(destDir)) {
+ if (name.endsWith('.tar.gz') || name.endsWith('.tar.gz.sha256')) {
+ rmSync(join(destDir, name))
+ }
+}
+
+const tarballs = readdirSync(srcDir).filter(
+ (n) => n.startsWith('harness-server-') && n.endsWith('.tar.gz') && matchesWanted(n)
+)
+
+if (tarballs.length === 0) {
+ const hint = wanted.length ? ` matching ${wanted.join(', ')}` : ''
+ console.error(`error: no harness-server tarballs${hint} in ${srcDir} — build them with npm run pack:headless:all`)
+ process.exit(1)
+}
+
+let staged = 0
+for (const name of tarballs) {
+ copyFileSync(join(srcDir, name), join(destDir, name))
+ const sizeMb = (statSync(join(destDir, name)).size / (1024 * 1024)).toFixed(0)
+ const sha = `${name}.sha256`
+ if (existsSync(join(srcDir, sha))) {
+ copyFileSync(join(srcDir, sha), join(destDir, sha))
+ }
+ console.log(`staged ${name} (${sizeMb} MB)`)
+ staged++
+}
+console.log(`\n${staged} tarball(s) staged in resources/headless/ — will be bundled into the next packaged build.`)
diff --git a/src/main/index.ts b/src/main/index.ts
index 21ed0f9d..452bbd3e 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -16,7 +16,7 @@ import { WebSocketServerTransport } from './transport-websocket'
import { CompoundServerTransport } from './transport-compound'
import { createWebClientServer } from './web-client-server'
import { getOrCreateWsToken, rotateWsToken } from './ws-token'
-import { networkInterfaces } from 'os'
+import { networkInterfaces, hostname as osHostname, type as osType, release as osRelease, machine as osMachine } from 'os'
import type { Server as HttpServer } from 'http'
import type { ServerTransport } from '../shared/transport/transport'
import { detectRuntime } from './paths'
@@ -213,8 +213,12 @@ function getHarnessVersion(): string {
return typeof v === 'string' ? v : null
}
},
+ // Headless tarball: the VERSION sidecar sits at the install root, next
+ // to bin/ and lib/ — i.e. two levels up from lib/main/index.js. (The
+ // old '../VERSION' was off by one: it pointed at lib/VERSION, which
+ // never exists, so packed tarball servers reported version "unknown".)
{
- path: join(__dirname, '..', 'VERSION'),
+ path: join(__dirname, '..', '..', 'VERSION'),
parse: (text) => text.trim() || null
}
]
@@ -2529,6 +2533,19 @@ function registerIpcHandlers(): void {
// app.getVersion() so the headless server can answer without an `app`.
transport.onRequest('updater:getVersion', (_ctx) => getHarnessVersion())
+ // system:getBackendInfo identifies the machine actually running this
+ // backend — for a web client / multi-backend remote that's the SERVER's
+ // host, not the viewer's. Routed through the transport like getVersion so
+ // each connected backend answers for itself. os.machine() mirrors
+ // `uname -m` (aarch64/x86_64); os.type()+release() mirror `uname -s -r`.
+ transport.onRequest('system:getBackendInfo', (_ctx) => ({
+ hostname: osHostname(),
+ platform: osType(),
+ release: osRelease(),
+ arch: osMachine(),
+ version: getHarnessVersion()
+ }))
+
// updater:checkForUpdates / updater:quitAndInstall: the real
// implementations live in desktop-shell.ts (they drive electron-updater
// and tear down PTYs before handing off to Squirrel). Register no-op
diff --git a/src/main/paths.ts b/src/main/paths.ts
index 49af5522..ae2be567 100644
--- a/src/main/paths.ts
+++ b/src/main/paths.ts
@@ -45,7 +45,11 @@ function ensureDir(dir: string, mode = 0o700): string {
return dir
}
-function loadElectronApp(): { getPath: (name: string) => string; isPackaged: boolean } {
+function loadElectronApp(): {
+ getPath: (name: string) => string
+ getAppPath: () => string
+ isPackaged: boolean
+} {
const dynamicRequire = createRequire(__filename)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (dynamicRequire('electron') as any).app
@@ -92,3 +96,34 @@ export function resolveBundledMcpScript(name: string): string {
if (detectRuntime() === 'node') return join(__dirname, '..', 'mcp', name)
return join(__dirname, '..', '..', 'resources', name)
}
+
+/** Resolve a script shipped from the repo's `scripts/` dir (Electron-only
+ * callers — currently the SSH bootstrap). Two layouts:
+ * - Packaged: electron-builder copies it to `process.resourcesPath`
+ * (see the `extraResources` entry in package.json).
+ * - Dev (unpackaged Electron): it lives in the live source tree under
+ * `/scripts/`.
+ * Anchored on `app.getAppPath()` rather than `__dirname` because callers
+ * may be lazy-`import()`ed into `out/main/chunks/`, where relative
+ * `__dirname` math is one (or more) levels off. */
+export function resolveBundledScript(name: string): string {
+ if (isPackaged()) return join(process.resourcesPath, name)
+ return join(loadElectronApp().getAppPath(), 'scripts', name)
+}
+
+/** Directory to search for locally-staged `harness-server` tarballs that
+ * upload-mode provisioning can push to a remote (instead of the remote
+ * pulling from GitHub). Symmetric with `resolveBundledScript`:
+ * - Packaged: a `headless/` subdir under `process.resourcesPath`,
+ * populated opt-in by `scripts/stage-server-tarballs.mjs` (the
+ * `pack:servers` npm script). A default build ships this dir empty, so
+ * the lookup finds nothing and the bootstrap falls back to download
+ * mode — no ~130MB-per-platform bloat unless you ask for it.
+ * - Dev (unpackaged Electron): the repo's `release/headless/`, where
+ * `pack:headless*` writes tarballs.
+ * Returns null in headless/node mode (SSH bootstrap is Electron-only). */
+export function resolveBundledServerDir(): string | null {
+ if (detectRuntime() !== 'electron') return null
+ if (isPackaged()) return join(process.resourcesPath, 'headless')
+ return join(loadElectronApp().getAppPath(), 'release', 'headless')
+}
diff --git a/src/main/pty-manager.ts b/src/main/pty-manager.ts
index 1527b87f..e4fb430b 100644
--- a/src/main/pty-manager.ts
+++ b/src/main/pty-manager.ts
@@ -2,6 +2,7 @@ import * as pty from 'node-pty'
import { execFile } from 'child_process'
import { log } from './debug'
import { cleanupTerminalLog } from './hooks'
+import { resolveUserShell } from './user-shell'
import {
saveTerminalHistory,
loadTerminalHistory,
@@ -148,7 +149,10 @@ export class PtyManager {
CLAUDE_HARNESS_ID: id,
HARNESS_TERMINAL_ID: id
} as Record
- const shell = command || env.SHELL || '/bin/zsh'
+ // An explicit command wins; otherwise resolve the user's shell the same
+ // way the rest of the app does (resolveUserShell: $SHELL → zsh → bash →
+ // sh, existence-checked), so a box without /bin/zsh still gets a shell.
+ const shell = command || resolveUserShell()
let ptyProcess: pty.IPty
try {
ptyProcess = pty.spawn(shell, args, {
diff --git a/src/main/ssh-bootstrap.ts b/src/main/ssh-bootstrap.ts
index 9233a2e8..26e1b9f2 100644
--- a/src/main/ssh-bootstrap.ts
+++ b/src/main/ssh-bootstrap.ts
@@ -15,8 +15,12 @@ import type { NodeSSH } from 'node-ssh'
import { createRequire } from 'module'
import { createServer, type Server as NetServer, type Socket } from 'net'
import { randomBytes, randomUUID } from 'crypto'
+import { existsSync, readdirSync, statSync } from 'fs'
+import { readFile } from 'fs/promises'
import { homedir, userInfo } from 'os'
+import { basename, join } from 'path'
import { computeForHost } from './ssh-config'
+import { resolveBundledScript, resolveBundledServerDir } from './paths'
import type {
BootstrapError,
BootstrapPhase
@@ -181,14 +185,119 @@ const REMOTE_BIN = `${REMOTE_INSTALL_DIR}/bin/harness-server`
const REMOTE_STATE_FILE = `${REMOTE_INSTALL_DIR}/state.json`
const REMOTE_LOG_FILE = `${REMOTE_INSTALL_DIR}/log`
-/** Read from env so dev / CI can point the install script at a fork or
- * staging release. Mirrors the script's HARNESS_SERVER_BASE_URL hook. */
-function installScriptUrl(): string {
- const fork = process.env.HARNESS_INSTALL_SCRIPT_URL
- if (fork) return fork
- return 'https://raw.githubusercontent.com/frenchie4111/harness/main/scripts/install-headless.sh'
+/** Locate the `install-headless.sh` that THIS Harness ships, so the remote
+ * runs the installer that matches the version doing the bootstrap (rather
+ * than `curl`-ing whatever is on `main` at GitHub). Resolution is handled by
+ * `resolveBundledScript` (packaged → resourcesPath; dev → repo `scripts/`),
+ * anchored on `app.getAppPath()` so lazy-import chunking doesn't skew it. */
+function resolveBundledInstallScript(): string {
+ return resolveBundledScript('install-headless.sh')
}
+/** Server-tarball platform tag matching `scripts/pack-headless.mjs` naming
+ * (`harness-server--.tar.gz`). */
+type ServerPlatform = 'darwin-arm64' | 'linux-x64' | 'linux-arm64'
+
+/** Probe the remote's OS + arch via `uname` and map to the tarball platform
+ * tag. Returns null for anything we don't publish a tarball for (e.g.
+ * darwin-x64), which pushes the caller onto download mode. */
+async function detectRemotePlatform(ssh: NodeSSH): Promise {
+ const r = await ssh.execCommand('uname -s; uname -m')
+ const [osRaw = '', archRaw = ''] = r.stdout.trim().split(/\r?\n/)
+ const os = osRaw.trim()
+ const arch = archRaw.trim()
+ let osTag: 'darwin' | 'linux' | null = null
+ if (os === 'Darwin') osTag = 'darwin'
+ else if (os === 'Linux') osTag = 'linux'
+ let archTag: 'arm64' | 'x64' | null = null
+ if (arch === 'arm64' || arch === 'aarch64') archTag = 'arm64'
+ else if (arch === 'x86_64' || arch === 'amd64') archTag = 'x64'
+ if (!osTag || !archTag) return null
+ const tag = `${osTag}-${archTag}`
+ // darwin-x64 isn't published (see pack-headless.mjs); treat as unknown.
+ if (tag === 'darwin-x64') return null
+ return tag as ServerPlatform
+}
+
+interface LocalTarball {
+ path: string
+ sha256Path?: string
+}
+
+/** Find a locally-built server tarball matching the remote platform so we can
+ * upload it instead of having the remote pull from GitHub. Resolution:
+ * 1. `HARNESS_SERVER_LOCAL_TARBALL` — explicit path, wins outright.
+ * 2. `HARNESS_SERVER_TARBALL_DIR`, else `resolveBundledServerDir()` (dev
+ * `release/headless/`; packaged `resourcesPath/headless/`) — newest
+ * `harness-server-*-.tar.gz` in that dir.
+ * Returns null when none is found (→ download mode). A default packaged
+ * build ships no tarballs, so upload mode only kicks in for a build made
+ * with `pack:servers` (or when an explicit path/dir env is set). */
+function findLocalServerTarball(platform: ServerPlatform): LocalTarball | null {
+ const explicit = process.env.HARNESS_SERVER_LOCAL_TARBALL
+ if (explicit) {
+ if (!existsSync(explicit)) return null
+ const sha = `${explicit}.sha256`
+ return { path: explicit, ...(existsSync(sha) ? { sha256Path: sha } : {}) }
+ }
+ const dir = process.env.HARNESS_SERVER_TARBALL_DIR ?? resolveBundledServerDir()
+ if (!dir || !existsSync(dir)) return null
+ const suffix = `-${platform}.tar.gz`
+ let best: { path: string; mtime: number } | null = null
+ for (const name of readdirSync(dir)) {
+ if (!name.startsWith('harness-server-') || !name.endsWith(suffix)) continue
+ const full = join(dir, name)
+ let mtime = 0
+ try {
+ mtime = statSync(full).mtimeMs
+ } catch {
+ continue
+ }
+ if (!best || mtime > best.mtime) best = { path: full, mtime }
+ }
+ if (!best) return null
+ const sha = `${best.path}.sha256`
+ return { path: best.path, ...(existsSync(sha) ? { sha256Path: sha } : {}) }
+}
+
+/** Upload a local tarball (+ its `.sha256` sidecar if present) to an
+ * ephemeral `/tmp` staging dir on the remote and return the remote paths.
+ * Streams coarse percentage progress into `onLine`. Caller is responsible
+ * for `rm -rf`-ing the staging dir once the installer has run. */
+async function stageTarballOnRemote(
+ ssh: NodeSSH,
+ local: LocalTarball,
+ onLine: (line: string) => void
+): Promise<{ remoteTarball: string; stagingDir: string }> {
+ const stagingDir = `/tmp/harness-provision-${randomUUID()}`
+ await ssh.execCommand(`mkdir -p ${shellEscape(stagingDir)}`)
+ const remoteTarball = `${stagingDir}/${basename(local.path)}`
+ const sizeMb = (statSync(local.path).size / (1024 * 1024)).toFixed(1)
+ onLine(`uploading ${basename(local.path)} (${sizeMb} MB) to ${stagingDir}…`)
+ let lastPct = -1
+ await ssh.putFile(local.path, remoteTarball, null, {
+ // ssh2's TransferOptions.step — (transferred, _chunk, total). Log every
+ // ~10% so the modal's progress log shows the upload moving without
+ // flooding the slice with a line per packet.
+ step: (transferred: number, _chunk: number, total: number) => {
+ if (!total) return
+ const pct = Math.floor((transferred / total) * 100)
+ if (pct >= lastPct + 10 || pct === 100) {
+ lastPct = pct
+ onLine(` upload ${pct}%`)
+ }
+ }
+ })
+ if (local.sha256Path) {
+ await ssh.putFile(local.sha256Path, `${remoteTarball}.sha256`)
+ }
+ onLine('upload complete')
+ return { remoteTarball, stagingDir }
+}
+
+/** Env-var prefix for download mode — points the installer at a fork /
+ * staging release. Mirrors the script's HARNESS_SERVER_BASE_URL/VERSION
+ * hooks. */
function installerEnvPrefix(): string {
const base = process.env.HARNESS_SERVER_BASE_URL
const version = process.env.HARNESS_SERVER_VERSION
@@ -252,13 +361,69 @@ async function probeServer(ssh: NodeSSH): Promise {
return out
}
-/** Run the install script over SSH. Streams output into `onLine`. */
+/** Provision `harness-server` on the remote. The installer script is the
+ * copy THIS Harness ships (piped over the wire, not curled from GitHub), so
+ * it always matches the version doing the bootstrap. The server bytes come
+ * from one of two sources, auto-selected:
+ *
+ * - **upload**: a locally-built tarball matching the remote platform is
+ * `putFile`d to the remote and the installer runs in
+ * `HARNESS_SERVER_TARBALL` mode (no GitHub round-trip).
+ * - **download**: no local tarball → the installer pulls a published
+ * release (honoring HARNESS_SERVER_BASE_URL/VERSION).
+ *
+ * Set `HARNESS_PROVISION_RELEASE_ONLY=1` to force download mode. */
async function runInstall(ssh: NodeSSH, onLine: (line: string) => void): Promise {
- const cmd = `${installerEnvPrefix()}curl -fsSL ${shellEscape(installScriptUrl())} | sh`
+ const scriptPath = resolveBundledInstallScript()
+ let script: string
+ try {
+ script = await readFile(scriptPath, 'utf8')
+ } catch (err) {
+ throw Object.assign(new Error('could not read bundled install-headless.sh'), {
+ bootstrapError: {
+ code: 'install_failed',
+ message: 'bundled install-headless.sh is missing',
+ detail: `${scriptPath}: ${err instanceof Error ? err.message : String(err)}`
+ } satisfies BootstrapError
+ })
+ }
+
+ // Decide the server-bytes source.
+ let envPrefix = installerEnvPrefix()
+ let stagingDir: string | null = null
+ const releaseOnly = process.env.HARNESS_PROVISION_RELEASE_ONLY === '1'
+ if (!releaseOnly) {
+ const platform = await detectRemotePlatform(ssh)
+ const local = platform ? findLocalServerTarball(platform) : null
+ if (local) {
+ onLine(`remote platform ${platform}; staging local tarball ${basename(local.path)}`)
+ const staged = await stageTarballOnRemote(ssh, local, onLine)
+ stagingDir = staged.stagingDir
+ // Upload mode wins: only the tarball path matters to the installer.
+ envPrefix = `HARNESS_SERVER_TARBALL=${shellEscape(staged.remoteTarball)} `
+ } else {
+ onLine(
+ platform
+ ? `no local ${platform} tarball found; installing from GitHub release`
+ : 'remote platform not published as a tarball; installing from GitHub release'
+ )
+ }
+ }
+
+ // Pipe OUR install-headless.sh to the remote: `env sh -s` reads
+ // the script from stdin with the chosen env applied. Newline-separated
+ // statements are unaffected — `sh -s` runs the whole stdin as a script.
+ const cmd = `env ${envPrefix}sh -s`
const result = await ssh.execCommand(cmd, {
+ stdin: script,
onStdout: (chunk: Buffer) => emitLines(chunk.toString('utf8'), onLine),
onStderr: (chunk: Buffer) => emitLines(chunk.toString('utf8'), onLine)
})
+ // Clean up the staging dir regardless of outcome — it's an ephemeral
+ // /tmp upload, not something we want to leave behind.
+ if (stagingDir) {
+ await ssh.execCommand(`rm -rf ${shellEscape(stagingDir)}`).catch(() => {})
+ }
if (result.code !== 0) {
// The installer already prints "error: ..." on failure; bubble it up
// verbatim so the UI's progress log matches what the user would see
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 43a4dc0a..4eeda0a7 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1,11 +1,11 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
-import { useSettings, usePrs, useOnboarding, useHooks, useWorktrees, useTerminals, usePanes, useLastActive, useUpdater, useRepoConfigs, useSnooze, useAnnouncements } from './store'
+import { useSettings, usePrs, useOnboarding, useHooks, useWorktrees, useTerminals, usePanes, useLastActive, useUpdater, useRepoConfigs, useSnooze, useAnnouncements, useActiveBackend } from './store'
import { useBackend } from './backend'
import { useTailLineBuffer } from './hooks/useTailLineBuffer'
import { useTabHandlers } from './hooks/useTabHandlers'
import { useHotkeyHandlers } from './hooks/useHotkeyHandlers'
import { useWorktreeHandlers } from './hooks/useWorktreeHandlers'
-import type { Worktree, TerminalTab, PtyStatus, PendingTool, QuestStep, PendingWorktree, UpdaterStatus, RepoConfig, PaneNode } from './types'
+import type { Worktree, TerminalTab, PtyStatus, PendingTool, QuestStep, PendingWorktree, UpdaterStatus, RepoConfig, PaneNode, BackendInfo } from './types'
import { getLeaves, findLeaf } from '../shared/state/terminals'
import { CheckCircle2, FolderOpen } from 'lucide-react'
import { BUILT_IN_THEMES_BY_MODE } from './themes'
@@ -307,6 +307,25 @@ function DesktopApp(): JSX.Element {
const nameAgentSessions = nameClaudeSessions
const hasGithubToken = hasGithubPat || !!githubAuthSource
const hotkeyOverrides = settings.hotkeys ?? undefined
+ // Backend identity for the welcome screen. Fetched per active backend so
+ // the web client / multi-backend remotes show the SERVER's host, not the
+ // viewer's. `backend` is a stable singleton that routes to whatever the
+ // active transport is, so it can't be the effect's trigger — we key on the
+ // active backend id (which DOES change on chip switch / SSH connect) so the
+ // identity re-fetches against the newly-active backend instead of being
+ // frozen to whatever was active at mount (the local host).
+ const activeBackendId = useActiveBackend().id
+ const [backendInfo, setBackendInfo] = useState(null)
+ useEffect(() => {
+ let cancelled = false
+ setBackendInfo(null)
+ backend.getBackendInfo().then(
+ (info) => { if (!cancelled) setBackendInfo(info) },
+ () => {}
+ )
+ return () => { cancelled = true }
+ }, [backend, activeBackendId])
+
// Onboarding parallelism quest — see QuestCard.tsx for the steps.
// Quest state lives in the main-process store; its value is seeded from
// config on boot so it's already correct on first render.
@@ -895,6 +914,11 @@ const setQuestStep = useCallback((next: QuestStep) => {
Run many coding agents in parallel — one window, isolated git worktrees,
clear status on who needs you.
+ {backendInfo && (
+
+ {backendInfo.hostname} · {backendInfo.platform} {backendInfo.release} {backendInfo.arch} · v{backendInfo.version}
+
+ )}
diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts
index 18f079b9..e9b17a0d 100644
--- a/src/renderer/build-backend.ts
+++ b/src/renderer/build-backend.ts
@@ -382,6 +382,7 @@ export function buildBackend(
setHarnessStarred: (starred: boolean) => req('settings:setHarnessStarred', starred),
getVersion: () => req('updater:getVersion'),
+ getBackendInfo: () => req('system:getBackendInfo'),
readRecentLog: (maxLines?: number) => req('debug:readRecentLog', maxLines),
checkForUpdates: () => req('updater:checkForUpdates'),
quitAndInstall: () => req('updater:quitAndInstall'),
diff --git a/src/renderer/types.ts b/src/renderer/types.ts
index 5d7170ec..079294d6 100644
--- a/src/renderer/types.ts
+++ b/src/renderer/types.ts
@@ -25,6 +25,16 @@ export interface WorktreeDirtyStatus {
scratchpad: boolean
}
+/** Identifies the machine running a backend (the server for a remote/web
+ * client). `platform`/`release`/`arch` mirror `uname -s`/`-r`/`-m`. */
+export interface BackendInfo {
+ hostname: string
+ platform: string
+ release: string
+ arch: string
+ version: string
+}
+
export interface FsEntry {
name: string
isDir: boolean
@@ -442,6 +452,9 @@ export interface ElectronAPI {
setHarnessStarred(starred: boolean): Promise<{ ok: boolean; error?: string }>
getVersion(): Promise
+ /** Identifies the machine running the active backend (the server, for a
+ * remote/web client — not the viewer). See `system:getBackendInfo`. */
+ getBackendInfo(): Promise
readRecentLog(maxLines?: number): Promise
checkForUpdates(): Promise<{ ok: boolean; available?: boolean; version?: string; releaseDate?: string; error?: string }>
quitAndInstall(): Promise