Skip to content
Merged
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
87 changes: 83 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ 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.
# Deleting ci.yml outright blocks a merge; silencing the gate would not. No-op on a push,
# 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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
34 changes: 29 additions & 5 deletions docs/designs/remote-workrooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1929,6 +1936,20 @@ 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. **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.
- **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
Expand Down Expand Up @@ -2394,7 +2415,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.
Expand Down
13 changes: 12 additions & 1 deletion macapp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
84 changes: 82 additions & 2 deletions macapp/Scripts/build-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -108,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
Expand All @@ -118,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
Expand All @@ -143,3 +152,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 "$AGENT_FEATURES" --target "$target" )
cp -f "$OUT_ROOT/$target/release/$HELPER_NAME" "$RES_DIR/${HELPER_NAME}-linux-$arch"
done
Loading
Loading