Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 52 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<arch>` 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-<version>-linux-<arch>.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 <port> 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 <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)
Expand Down
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions resources/headless/.gitkeep
Original file line number Diff line number Diff line change
@@ -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-*-<platform>.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.
133 changes: 85 additions & 48 deletions scripts/install-headless.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<tarball>.sha256" exists (or
# HARNESS_SERVER_SHA256 is set) it is verified.
#
# POSIX-only — runs under dash, ash, busybox sh in addition to bash/zsh.

Expand Down Expand Up @@ -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
Expand All @@ -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-<version>-<platform>/; 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-<version>-<platform>/;
# 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"
Expand Down
Loading
Loading