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