From 7b46713d856b7958f92ab2173e8c377b24b4fd34 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Thu, 24 Sep 2026 11:35:43 +0100 Subject: [PATCH 1/3] feat(macapp): bundle static Linux wr-agents in Release and Nightly builds Phase 3 of #154 pushes the agent to a remote box on first connect, which depends on Workroom.app carrying a Linux agent. build-agent.sh now cross-builds wr-agent for aarch64 and x86_64 musl with cargo-zigbuild and the terminal-state feature, and puts them at Contents/Resources/wr-agent-linux-, unsigned, sealed by the app's own signature. Both arches ship regardless of ARCHS. Debug builds skip them and remove stale ones unless WR_AGENT_LINUX=1. release.sh fails a release whose ELFs are missing or not static, the new agent-linux CI job runs `protocol` on each under Linux, and the release workflows install the musl targets and cargo-zigbuild. Whether notarization accepts the ELFs is answered by the first Nightly built with them. Part of #227. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 87 ++++++++++++++++++++++++++++-- .github/workflows/nightly.yml | 7 +++ .github/workflows/release.yml | 7 +++ docs/designs/remote-workrooms.md | 31 +++++++++-- macapp/AGENTS.md | 13 ++++- macapp/Scripts/build-agent.sh | 77 +++++++++++++++++++++++++- macapp/Scripts/build-agent_test.sh | 69 +++++++++++++++++++++++- macapp/Scripts/release.sh | 15 ++++++ 8 files changed, 294 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912b74fe8..e187439b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: with: fetch-depth: 0 - # Run the BASE branch's selector, never the PR's own. This job decides whether the four + # Run the BASE branch's selector, never the PR's own. This job decides whether the # native jobs run at all, and GitHub counts a conditionally-SKIPPED required check as # passing — so a PR that edited changes.py to emit `run=false` (its `__main__` line alone, # leaving should_run() and every test untouched) would merge green with no native coverage. @@ -43,7 +43,7 @@ jobs: # where github.sha is already the trusted commit. # Absent on the base ref when that ref predates the selector — the PR introducing it (this # one) included. No trusted selector means NO gate: the two steps below are skipped, `run` - # stays empty, and the `!= 'false'` tests on the four native jobs all pass, so everything + # stays empty, and the `!= 'false'` tests on the native jobs all pass, so everything # runs. Never fall back to the PR's own copy; that is the bypass this whole step exists for. - name: Restore the trusted selector id: selector @@ -265,6 +265,78 @@ jobs: WR_AGENT_HAS_TERMINAL_STATE: "1" run: cargo test --manifest-path vcs/Cargo.toml -p wr-agent --features terminal-state + # The Linux agents Workroom.app ships for remote hosts (issue #227), built the way the app bundle + # builds them (cargo-zigbuild, static musl, terminal-state) and then RUN. The Mac-side check in + # build-agent_test.sh can only see that each ELF is static and the right arch, not that it starts. + # aarch64 runs under qemu-user, which needs no sysroot for a static binary. + agent-linux: + needs: changes + if: always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + env: + TARGET: ${{ matrix.arch }}-unknown-linux-musl + steps: + - uses: actions/checkout@v5 + + - name: Install Rust + run: | + rustup toolchain install 1.96.1 --profile minimal --target "$TARGET" + rustup default 1.96.1 + + # Pinned by version AND checksum, as in `agent-terminal-state`. cargo-zigbuild links with it + # and libghostty-vt is built with it. + - name: Install Zig + env: + ZIG_VERSION: "0.16.0" + ZIG_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00" + run: | + url="https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" + curl -fsSL "$url" -o zig.tar.xz + echo "${ZIG_SHA256} zig.tar.xz" | sha256sum -c - + mkdir -p "$HOME/.zig" && tar -xJf zig.tar.xz -C "$HOME/.zig" --strip-components=1 + echo "$HOME/.zig" >> "$GITHUB_PATH" + + # Its own key per arch. Sharing `agent-terminal-state`'s key would restore that job's glibc + # prefix, count as a hit, never save the musl one, and rebuild it on every run. + - name: Cache libghostty-vt + uses: actions/cache@v4 + with: + path: ~/.cache/workroom/ghostty-vt + key: ghostty-vt-${{ runner.os }}-${{ env.TARGET }}-${{ hashFiles('vcs/scripts/build-ghostty-vt.sh') }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: vcs + key: ${{ env.TARGET }} + + - name: Install cargo-zigbuild and qemu + run: | + cargo install cargo-zigbuild --locked --version 0.23.4 + if [ "${{ matrix.arch }}" = aarch64 ]; then + sudo apt-get update -qq && sudo apt-get install -y -qq qemu-user-static + fi + + - name: Build + run: | + cargo zigbuild --manifest-path vcs/Cargo.toml --release -p wr-agent \ + --features terminal-state --target "$TARGET" + + - name: Check + run: | + bin="vcs/target/$TARGET/release/wr-agent" + file "$bin" + file -b "$bin" | grep -q 'statically linked' + run="" + if [ "${{ matrix.arch }}" = aarch64 ]; then run=qemu-aarch64-static; fi + out="$($run "$bin" protocol)" + echo "$out" + echo "$out" | grep -q '^protocol ' + echo "$out" | grep -qx 'terminal-state yes' + app: needs: changes if: always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') @@ -475,8 +547,15 @@ jobs: # `rustup target list --installed --toolchain stable` query checks. A bare `rustup target add` # installs into the DEFAULT toolchain, which would leave the query — and so the skip — # unchanged if the default is ever not `stable`. - - name: Install darwin cross targets (for the universal-arch script tests) - run: rustup target add --toolchain stable x86_64-apple-darwin aarch64-apple-darwin + # + # The musl targets and cargo-zigbuild are the same gap for the Linux agents (#227): without + # them the Linux case skips, and build-agent.sh's Linux branch would first run on a nightly. + # `agent-linux` does not cover it, because it calls `cargo zigbuild` directly. + - name: Install cross targets (for the universal-arch and Linux-agent script tests) + run: | + rustup target add --toolchain stable x86_64-apple-darwin aarch64-apple-darwin \ + aarch64-unknown-linux-musl x86_64-unknown-linux-musl + cargo install cargo-zigbuild --locked --version 0.23.4 # Shell-script tests. `make app-test` builds Debug with a single native arch, so it can never # exercise build-helper.sh's / build-agent.sh's multi-arch path — the branch whose absence diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 819b98f1e..dcd3fcdd5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -182,6 +182,13 @@ jobs: - name: Add Rust darwin targets (universal VCS core) run: rustup target add x86_64-apple-darwin aarch64-apple-darwin + # The app bundle carries a static Linux wr-agent per arch for remote hosts (issue #227), + # cross-built by build-agent.sh with cargo-zigbuild and the Zig installed above. + - name: Add Linux agent toolchain + run: | + rustup target add aarch64-unknown-linux-musl x86_64-unknown-linux-musl + cargo install cargo-zigbuild --locked --version 0.23.4 + - name: Import Developer ID signing certificate env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9205e50c4..b3e223069 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,13 @@ jobs: - name: Add Rust darwin targets (universal VCS core) run: rustup target add x86_64-apple-darwin aarch64-apple-darwin + # The app bundle carries a static Linux wr-agent per arch for remote hosts (issue #227), + # cross-built by build-agent.sh with cargo-zigbuild and the Zig installed above. + - name: Add Linux agent toolchain + run: | + rustup target add aarch64-unknown-linux-musl x86_64-unknown-linux-musl + cargo install cargo-zigbuild --locked --version 0.23.4 + - name: Import Developer ID signing certificate env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 3bb16d87c..3d64892a6 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -6,7 +6,15 @@ Repo: joelmoss/workroom Status: APPROVED Mode: Builder -## Current Status — 2026-09-17 +## Current Status — 2026-09-24 + +**Phase 2 is merged to master** (2026-09-23, #201 through #218; review follow-ups #220–#226), and +**Phase 3 has started.** Its milestones are sub-issues of #154: the Linux agent artifact (#227, in +progress), the supervised far-side agent with its stdio relay and container fixture (#228), the +app-side transport with `HostDriver` and the container driver (#229), `execve` hand-off (#230), +push-on-first-connect bootstrap (#231) and stop-and-reboot screen restoration (#232). The rest of +this section is the 2026-09-17 status, kept for the Phase 2 detail it records and corrected where +it had gone stale. Phase 0's feasibility results are recorded and Phase 1 is complete (closed out by #192). New persistent local sessions use `wr-agent`; an attach-only Swift shim preserves access to @@ -25,10 +33,9 @@ environment, a different exit code for a missing tool, and transport failures re ran"). Parity is now three explicit properties rather than an architectural claim — see Next Steps item 3. The File service is implemented too (#211): directory listing, containment-checked raw reads and change notification on `Service::File`, with the app's watch sites routed through it. The GitHub -status and interchangeability services remain unbuilt. All five PRs stay draft, stacked, until the -next stable release cuts from master. +status and interchangeability services followed (#207, #208), and the whole stack is now merged. OQ1's gix measurements and OQ21's Swift driver decision are answered. The gix code remains a spike. -Phases 3 and 4 — persistent remote transport, distribution, provisioning and UI — remain planned. +Phase 4 (provisioning, credentials, the lifecycle shim and the UI) remains planned. The implementation sequence and remaining decisions are in **Next Steps**. ## Problem Statement @@ -1929,6 +1936,17 @@ disagreement passes every test on either side alone while presenting as an empty the exposure is the reverse. The ELF must **not** land in `Contents/MacOS` and must be sealed as a resource. Confirm codesign and notarization accept it before Phase 3 depends on push-the- binary bootstrap; if they do not, the "no provider images" claim collapses. + **In progress (#227, 2026-09-24).** Release and Nightly builds now put a static musl agent per + Linux arch at `Contents/Resources/wr-agent-linux-{aarch64,x86_64}`, cross-built by + `build-agent.sh` with cargo-zigbuild and the pinned Zig, with `terminal-state`, and not + codesigned themselves. Both arches ship regardless of `ARCHS`. A local cross-build takes 1m51s + cold per arch. `release.sh` fails the release if either ELF is missing or not static, and the + `agent-linux` CI job runs `protocol` on each under Linux. The notarization answer is still owed: + `release.sh` notarizes and fails on rejection, so the first Nightly built with them either + confirms it or fails. Record the result here when it runs. Size: 10.9 MB (aarch64) and + 12.3 MB (x86_64) raw, and the aarch64 one is 5.1 MB gzipped, so the pair adds about 23 MB to + the installed app and roughly 11 MB to the DMG. A Debug build with `WR_AGENT_LINUX=1` passes + `codesign --verify --strict` with both inside, and both run `protocol` in a Linux container. - **Resume policy versus version lockstep.** Phase 1 versions the envelope because an agent on a suspended VM can be older than the app reconnecting to it; Phase 3's bootstrap implies lockstep. These reconcile only with a stated policy: does reconnecting to a suspended box @@ -2394,7 +2412,10 @@ service milestones below so each layer can be reviewed and landed independently. same shells survive the upgrade with their pids and their exit codes. An agent that predates hand-off, or a new binary that fails its pre-check, leaves the old agent running. Deliberately crash the restore to measure the one case that is not recoverable. Treat stop-and-reboot screen - restoration separately from live-process survival. + restoration separately from live-process survival. **Filed 2026-09-24** as #227 (Linux artifact) + → #228 (supervised agent, relay, container fixture) → #229 (app-side transport, `HostDriver`, + container driver), with #230 (hand-off, independent), #231 (bootstrap, after #227, #229 and + #230) and #232 (stop-and-reboot restore, after #228). 5. **Phase 4: credentials, provisioning, lifecycle and the Nightly UI.** Resolve the non-admin repository credential path (OQ20) before claiming ordinary organization-repository support. diff --git a/macapp/AGENTS.md b/macapp/AGENTS.md index 8bf777595..31e82a3cf 100644 --- a/macapp/AGENTS.md +++ b/macapp/AGENTS.md @@ -107,7 +107,9 @@ before `app-build`/`app-test`/`app-generate`** (a Makefile prerequisite). Requir - **`protoc`** on PATH (`brew install protobuf`) — a build-time dep of jj-lib. - arm64 by default; **`make app-release` builds universal** (`VCS_APPLE_FLAGS=--universal`), which needs **rustup `stable` ≥ 1.93** + `rustup target add x86_64-apple-darwin aarch64-apple-darwin` - (Homebrew's rust can't cross-compile; the script preflights this and errors clearly). + (Homebrew's rust can't cross-compile; the script preflights this and errors clearly). It also + cross-builds the Linux agents, which need cargo-zigbuild and the two musl targets (see "Linux + agents" below). - **Unchanged inputs are a no-op.** The script hashes the Rust sources, manifests/lockfile, itself, `rustc --version` and the arch flavour into `vcs/swift/WrVcs/Frameworks/.build-stamp`, and exits early when that matches and the outputs exist. `WR_VCS_FORCE=1 make app-vcs` rebuilds regardless. @@ -162,6 +164,15 @@ which CI installs. `terminal-state` is **not optional for the app**: without it repaints blank, which only shows up after a quit-and-relaunch. `wr-agent protocol` reports `terminal-state yes|no`, and the build test asserts it against the shipped binary. +**Linux agents (issue #227).** Release and Nightly builds also put a static musl `wr-agent` per +Linux arch in `Contents/Resources/wr-agent-linux-{aarch64,x86_64}`, for pushing to remote hosts. +Both arches ship always, whatever `ARCHS` says, because a remote box's arch has nothing to do with +the Mac's. They are not codesigned; the app's signature seals them as resources. Debug skips them +(and removes stale ones) unless `WR_AGENT_LINUX=1`. Building them needs `cargo install +cargo-zigbuild --locked` and `rustup target add aarch64-unknown-linux-musl +x86_64-unknown-linux-musl`, which the release workflows install. `release.sh` asserts both ELFs are +present and static, and the `agent-linux` CI job runs `protocol` on each under Linux. + The Zig toolchain and the pinned Ghostty engine come from `vcs/scripts/build-ghostty-vt.sh` (cached per `(engine sha, target)` outside the repo). That pin must stay in step with the GhosttyKit the app links — see the comment in `project.yml`. diff --git a/macapp/Scripts/build-agent.sh b/macapp/Scripts/build-agent.sh index f6185c541..83ee14611 100755 --- a/macapp/Scripts/build-agent.sh +++ b/macapp/Scripts/build-agent.sh @@ -9,8 +9,12 @@ # not collide with the app executable "Workroom" on a case-insensitive filesystem. It goes there # because that is where `PersistentSessionPaths.binaryURL(for:)` looks, beside workroom-session. # +# Release and Nightly builds also put a static Linux agent per arch in Contents/Resources, for remote +# hosts. See the end of this file. +# # Env vars provided by Xcode: SRCROOT, TARGET_BUILD_DIR, EXECUTABLE_FOLDER_PATH, -# EXPANDED_CODE_SIGN_IDENTITY, ARCHS, DERIVED_FILE_DIR. +# UNLOCALIZED_RESOURCES_FOLDER_PATH, EXPANDED_CODE_SIGN_IDENTITY, ARCHS, DERIVED_FILE_DIR, +# CONFIGURATION. set -euo pipefail HELPER_NAME="wr-agent" @@ -143,3 +147,74 @@ else echo "Signing $HELPER_NAME with $IDENTITY (hardened runtime + timestamp)" codesign --force --options runtime --timestamp --sign "$IDENTITY" "$DEST" fi + +# Linux agents (issue #227). Workroom.app pushes the agent to a remote box on first connect +# (docs/designs/remote-workrooms.md, Distribution Plan), so the bundle carries a static musl build +# for each Linux arch, named by what `uname -m` prints there. +# +# Both ship ALWAYS. A remote box's arch has nothing to do with the Mac's, so these do not follow +# `ARCHS`, and the ARCH_LIST rule above (for universal MAC binaries) does not apply to them. +# +# Resources, not MacOS, and not codesigned. codesign sees an ELF as data, and the app's own +# signature seals it like any other resource. +# +# Release and Nightly only, unless WR_AGENT_LINUX=1. They are two more release builds of the agent, +# and a Debug app has no remote host to push them to yet. A build that skips them also removes any +# that an earlier opt-in build left, so a bundle never carries a stale agent. +RES_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" +if [ "${CONFIGURATION:-Debug}" = "Debug" ] && [ "${WR_AGENT_LINUX:-}" != "1" ]; then + rm -f "$RES_DIR/${HELPER_NAME}-linux-"* + exit 0 +fi + +if ! command -v cargo-zigbuild >/dev/null 2>&1; then + echo "error: the Linux agents need cargo-zigbuild. Run 'cargo install cargo-zigbuild --locked'." >&2 + exit 1 +fi +if ! command -v rustup >/dev/null 2>&1; then + echo "error: the Linux agents need rustup (Homebrew rust cannot cross-compile)." >&2 + exit 1 +fi +LINUX_ARCHES="aarch64 x86_64" +for arch in $LINUX_ARCHES; do + if ! rustup target list --installed --toolchain "$AGENT_TOOLCHAIN" 2>/dev/null | grep -qx "$arch-unknown-linux-musl"; then + echo "error: rustup target '$arch-unknown-linux-musl' is not installed. Run 'rustup target add --toolchain $AGENT_TOOLCHAIN $arch-unknown-linux-musl'." >&2 + exit 1 + fi +done + +# cargo-zigbuild re-invokes `cargo` from PATH, so a Homebrew cargo earlier on PATH (see the export at +# the top) would build against a std with no musl target: "can't find crate for `core`". Put the +# toolchain's own bin directory first. vcs/scripts/test-linux.sh documents the same trap. +TOOLCHAIN_BIN="$(dirname "$(rustup which --toolchain "$AGENT_TOOLCHAIN" cargo)")" + +# cargo-zigbuild links with `zig`, at the version libghostty-vt is pinned to. mise's copy when mise +# has one, otherwise whatever is on PATH, and checked either way. The directory goes on PATH rather +# than using `mise exec`, which would also put the user's other mise tools (a rust, say) ahead of +# TOOLCHAIN_BIN. +ZIG_VERSION="$(awk -F'"' '/^ZIG_VERSION=/{print $2}' "$CARGO_DIR/scripts/build-ghostty-vt.sh")" +ZIG_DIR="" +if command -v mise >/dev/null 2>&1; then + mise install "zig@${ZIG_VERSION}" >/dev/null 2>&1 || true + ZIG_HOME="$(mise where "zig@${ZIG_VERSION}" 2>/dev/null || true)" + for candidate in "$ZIG_HOME/bin" "$ZIG_HOME"; do + if [ -n "$ZIG_HOME" ] && [ -x "$candidate/zig" ]; then + ZIG_DIR="$candidate" + break + fi + done +fi +LINUX_PATH="$TOOLCHAIN_BIN:${ZIG_DIR:+$ZIG_DIR:}$PATH" +if [ "$(PATH="$LINUX_PATH" zig version 2>/dev/null)" != "$ZIG_VERSION" ]; then + echo "error: the Linux agents need zig $ZIG_VERSION. Install mise, or put zig $ZIG_VERSION on PATH." >&2 + exit 1 +fi + +mkdir -p "$RES_DIR" +for arch in $LINUX_ARCHES; do + target="$arch-unknown-linux-musl" + echo "Building $HELPER_NAME ($target) -> $RES_DIR/${HELPER_NAME}-linux-$arch" + ( cd "$CARGO_DIR" && PATH="$LINUX_PATH" cargo zigbuild --release -p wr-agent \ + --features terminal-state --target "$target" ) + cp -f "$OUT_ROOT/$target/release/$HELPER_NAME" "$RES_DIR/${HELPER_NAME}-linux-$arch" +done diff --git a/macapp/Scripts/build-agent_test.sh b/macapp/Scripts/build-agent_test.sh index f5e29153c..4b578c672 100755 --- a/macapp/Scripts/build-agent_test.sh +++ b/macapp/Scripts/build-agent_test.sh @@ -43,8 +43,12 @@ trap 'rm -rf "$WORK"' EXIT # the developer's build tree. The agent includes the VCS backends, so this is a full Cargo build. REPO="$(cd "$DIR/../.." && pwd)" export CARGO_TARGET_DIR="$WORK/cargo-target" +# Set only by the case that tests it. Inherited from the developer's shell, it would make every +# Debug case build the Linux agents and the stale-removal case fail. +unset WR_AGENT_LINUX -# run_agent -> sets $OUT to the built helper path, $RC to the exit code +# run_agent [CONFIGURATION] -> sets $OUT to the built helper path, $RC to +# the exit code. CONFIGURATION defaults to unset, which the script treats as Debug. run_agent() { case_name="$1" archs="$2" @@ -54,7 +58,9 @@ run_agent() { SRCROOT="$REPO/macapp" \ TARGET_BUILD_DIR="$dest" \ EXECUTABLE_FOLDER_PATH="MacOS" \ + UNLOCALIZED_RESOURCES_FOLDER_PATH="Resources" \ DERIVED_FILE_DIR="$dest/intermediates" \ + CONFIGURATION="${3:-}" \ ARCHS="$archs" \ sh "$HELPER" >"$dest/log" 2>&1 RC=$? @@ -135,6 +141,67 @@ expect_failure unknown_arch "arm64 ppc64" "unsupported arch" # iterations; that must be a readable error, not a bash "unbound variable" death. expect_failure blank_archs " " "no architectures" +# A Debug build ships no Linux agents, including ones an earlier WR_AGENT_LINUX=1 build left behind +# in the same bundle. +mkdir -p "$WORK/out-debug_stale/Resources" +: >"$WORK/out-debug_stale/Resources/wr-agent-linux-aarch64" +run_agent debug_stale "$HOST_ARCH" +if [ "$RC" -ne 0 ]; then + echo "FAIL: Debug build exited $RC, want 0. Log:" + sed 's/^/ /' "$WORK/out-debug_stale/log" + fails=$((fails + 1)) +elif ls "$WORK/out-debug_stale/Resources"/wr-agent-linux-* >/dev/null 2>&1; then + echo "FAIL: Debug build left a Linux agent in Resources" + fails=$((fails + 1)) +fi + +# A Release build ships a static Linux agent for EVERY Linux arch, whatever ARCHS says: the remote +# box's arch has nothing to do with the Mac's. Needs cargo-zigbuild and both musl targets, so it +# skips without them like the universal cases. The Linux CI job runs `protocol` on the ELFs, which +# this Mac cannot. +LINUX=1 +command -v cargo-zigbuild >/dev/null 2>&1 || LINUX=0 +for t in aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do + command -v rustup >/dev/null 2>&1 \ + && rustup target list --installed --toolchain "${WR_AGENT_RUST_TOOLCHAIN:-stable}" 2>/dev/null | grep -qx "$t" \ + || LINUX=0 +done + +# expect_linux_agents : both ELFs present, static, and the right arch. +expect_linux_agents() { + if [ "$RC" -ne 0 ]; then + echo "FAIL: $1 exited $RC, want 0. Log:" + sed 's/^/ /' "$WORK/out-$1/log" + fails=$((fails + 1)) + return + fi + for pair in "aarch64:ARM aarch64" "x86_64:x86-64"; do + arch="${pair%%:*}" + want="${pair#*:}" + desc="$(file -b "$WORK/out-$1/Resources/wr-agent-linux-$arch" 2>/dev/null)" + case "$desc" in + *"ELF 64-bit"*"$want"*"statically linked"*) ;; + *) + echo "FAIL: $1: wr-agent-linux-$arch is not a static $want ELF: '${desc:-missing}'" + fails=$((fails + 1)) + ;; + esac + done +} + +if [ "$LINUX" -eq 1 ]; then + run_agent release_linux "$HOST_ARCH" Release + expect_linux_agents release_linux + # The Debug opt-in. Reuses the Linux builds above, so it costs a copy, not a compile. + WR_AGENT_LINUX=1 + export WR_AGENT_LINUX + run_agent debug_optin "$HOST_ARCH" + unset WR_AGENT_LINUX + expect_linux_agents debug_optin +else + echo "build-agent_test: SKIP Linux agent cases (cargo-zigbuild or musl targets not installed)" +fi + if [ "$fails" -eq 0 ]; then echo "build-agent_test: OK" exit 0 diff --git a/macapp/Scripts/release.sh b/macapp/Scripts/release.sh index 335e5d519..5786ac79b 100755 --- a/macapp/Scripts/release.sh +++ b/macapp/Scripts/release.sh @@ -215,6 +215,21 @@ rm -rf "$GHOSTTY_PROBE_STATE" || { echo "error: shipped ghostty cannot dispatch '+ssh-cache' — the libghostty action layer is broken." >&2; exit 1; } echo " ghostty -> $GHOSTTY_TARGET (relative, dispatches +ssh-cache)" +# The Linux agents for remote hosts (issue #227). Asserted on the artifact for the same reason as the +# checks above: build-agent.sh skips them for Debug, and nothing downstream would notice a Release +# that shipped without them, or with a dynamically linked one that fails on a box with no glibc. +echo "==> Verifying Linux agents" +for pair in "aarch64:ARM aarch64" "x86_64:x86-64"; do + arch="${pair%%:*}" + want="${pair#*:}" + elf="$APP/Contents/Resources/wr-agent-linux-$arch" + desc="$(file -b "$elf" 2>/dev/null || true)" + case "$desc" in + *"ELF 64-bit"*"$want"*"statically linked"*) echo " wr-agent-linux-$arch: $desc" ;; + *) echo "error: $elf is not a static $want ELF: '${desc:-missing}'." >&2; exit 1 ;; + esac +done + echo "==> Verifying signatures" codesign --verify --strict --verbose=2 "$APP" echo "--- embedded helper ---" From 72479a8fc46a0a7d05f60558278ad7884243348f Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Thu, 24 Sep 2026 12:34:04 +0100 Subject: [PATCH 2/3] fix(macapp): share one feature list between the Mac and Linux agent builds Only the Mac agent can be run on the build host, and the build test asserts terminal-state on it. Building the Linux ELFs from the same feature list extends that check to them, so dropping terminal-state from the Linux build alone is no longer possible. Found by Greptile on #233. Co-Authored-By: Claude Opus 5.5 --- macapp/Scripts/build-agent.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/macapp/Scripts/build-agent.sh b/macapp/Scripts/build-agent.sh index 83ee14611..e7579490d 100755 --- a/macapp/Scripts/build-agent.sh +++ b/macapp/Scripts/build-agent.sh @@ -112,6 +112,11 @@ trap 'rm -f "$SLICE_DIR/${HELPER_NAME}-slice-"*' EXIT # its own assertions passing against a binary this script never produced. OUT_ROOT="${CARGO_TARGET_DIR:-$CARGO_DIR/target}" +# One feature list for the Mac and Linux builds alike. Only the Mac agent can be run here, and the +# build test asserts `terminal-state yes` on it; sharing the list is what extends that check to the +# Linux ELFs, which this Mac cannot run. +AGENT_FEATURES="terminal-state" + SLICES=() for target in "${TARGETS[@]}"; do # `terminal-state` is not optional for the app, whatever its name suggests. Without it the @@ -122,7 +127,7 @@ for target in "${TARGETS[@]}"; do # # The library is cached per (engine sha, target) outside the repo, so only the first build of a # given pin pays for it; see vcs/scripts/build-ghostty-vt.sh. - ( cd "$CARGO_DIR" && $CARGO build --release -p wr-agent --features terminal-state --target "$target" ) + ( cd "$CARGO_DIR" && $CARGO build --release -p wr-agent --features "$AGENT_FEATURES" --target "$target" ) cp -f "$OUT_ROOT/$target/release/$HELPER_NAME" "$SLICE_DIR/${HELPER_NAME}-slice-$target" SLICES+=("$SLICE_DIR/${HELPER_NAME}-slice-$target") done @@ -215,6 +220,6 @@ for arch in $LINUX_ARCHES; do target="$arch-unknown-linux-musl" echo "Building $HELPER_NAME ($target) -> $RES_DIR/${HELPER_NAME}-linux-$arch" ( cd "$CARGO_DIR" && PATH="$LINUX_PATH" cargo zigbuild --release -p wr-agent \ - --features terminal-state --target "$target" ) + --features "$AGENT_FEATURES" --target "$target" ) cp -f "$OUT_ROOT/$target/release/$HELPER_NAME" "$RES_DIR/${HELPER_NAME}-linux-$arch" done From 2eba8ddc2cfc8766b1c1c8fe4cf13684b38a1e71 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Thu, 24 Sep 2026 13:02:44 +0100 Subject: [PATCH 3/3] docs(remote-workrooms): record that notarization accepts the Linux agents Co-Authored-By: Claude Opus 5.5 --- docs/designs/remote-workrooms.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 3d64892a6..830c370e5 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -1941,9 +1941,12 @@ disagreement passes every test on either side alone while presenting as an empty `build-agent.sh` with cargo-zigbuild and the pinned Zig, with `terminal-state`, and not codesigned themselves. Both arches ship regardless of `ARCHS`. A local cross-build takes 1m51s cold per arch. `release.sh` fails the release if either ELF is missing or not static, and the - `agent-linux` CI job runs `protocol` on each under Linux. The notarization answer is still owed: - `release.sh` notarizes and fails on rejection, so the first Nightly built with them either - confirms it or fails. Record the result here when it runs. Size: 10.9 MB (aarch64) and + `agent-linux` CI job runs `protocol` on each under Linux. **Confirmed (2026-09-24): notarization + accepts them.** A local `make app-release` of #233 (Developer ID, the same `release.sh` path the + workflows run) notarized the app with both ELFs inside with status `Accepted`, stapled, and + passed `codesign --verify --deep --strict` and `spctl --assess` (`source=Notarized Developer + ID`). The DMG was not built in that run, because create-dmg's Finder AppleScript cannot run in + a non-interactive session, so the first Nightly is still the end-to-end check. Size: 10.9 MB (aarch64) and 12.3 MB (x86_64) raw, and the aarch64 one is 5.1 MB gzipped, so the pair adds about 23 MB to the installed app and roughly 11 MB to the DMG. A Debug build with `WR_AGENT_LINUX=1` passes `codesign --verify --strict` with both inside, and both run `protocol` in a Linux container.