From 6c26b35acc6438ca8fdfc5e62bc91943b435db67 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Thu, 13 Aug 2026 11:47:27 +0800 Subject: [PATCH] fix(control): bump the dialect to v6, and publish a tty7-server for macOS (#605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(control): bump the dialect to v6 so an out-of-date server says so The control dialect has been renamed, extended and cut since it was last numbered, all of it against CONTROL_VERSION 5: the machine tree replaced WorkspaceList/Get/Put/Delete with WorkspaceTree, MachineGet and the tab/pane verbs, GitStream arrived with its chunk and end events, and ReplyOk::Attached and FileMeta went away. A peer left behind by any of that still answers the hello, because the number it answers with still matches. It is also still sitting at the path the installer looks for, tty7-server-c5p5, so a client decides it already has the server it needs. Then the first call reaches a variant the peer has never heard of, the frame fails to decode, and the read loop takes the whole link down with it. What the user sees is a remote workspace that opens with no tabs and a git detail pane that never fills, with nothing anywhere saying why. Moving the number puts all three guards back: the hello is refused with the message that names the old build, the remote binary is looked for at c6p5 and installed rather than trusted, and a stale local daemon gets the restart prompt it should have been getting all along. Document the rule next to the constant while it is fresh: move it when a variant is added or removed. The feature strings only cover what a peer can safely ignore, and a request it cannot decode is not that. * feat(remote): publish a tty7-server for macOS hosts A remote workspace has been Linux-only for no reason anyone chose: the installer derives the asset name from `uname -sm`, and the only names it knew were the two musl builds. A Mac on the other end of an SSH profile got "a remote tty7 workspace needs a Linux host" and stopped there. Publish the two Apple slices alongside them and teach the installer to ask for them. `Darwin arm64` and `Darwin x86_64` now map to tty7-server-macos-aarch64 and tty7-server-macos-x86_64; everything past that point already worked, because nothing under it was ever Linux- specific — the install path is POSIX, the upload is SFTP, and the dialect probe runs the binary before trusting it. The machine names are matched per system rather than by architecture alone. Linux says aarch64 on one distribution and arm64 on the next, while a Mac only ever says arm64, so honouring Linux's spellings under Darwin would be guessing at output no Mac produces. Static linking is not the instrument on macOS — Apple ships no static libSystem — so assert-macho.sh stands in for assert-static.sh with the guarantee that actually matters: every dependency resolves under /usr/lib or /System/Library, so nothing the destination Mac lacks can be picked up from a build runner, and the binary carries the signature arm64 refuses to run without. Not signed or notarized beyond that, deliberately. The binary is never downloaded by the Mac that runs it: the client fetches it, verifies it against checksums.txt and writes it over SFTP, which sets no quarantine attribute, so Gatekeeper is not in the path. ASSET_X86_64 and ASSET_AARCH64 become ASSET_LINUX_*, which is what they always meant and could not keep meaning next to a macOS pair. * fix(ci): sign the x86_64 macOS server, and stop the guard flaking on it Two faults the first green run hid from each other. The linker ad-hoc signs the arm64 slice because Apple Silicon will not execute anything unsigned, and leaves x86_64 bare. That is fine on an Intel Mac, but the x86_64 server is also what an Apple Silicon box gets when it asks through a Rosetta shell, and handing that machine an unsigned binary is a guess about Rosetta nobody needs to make. Sign both slices ad-hoc in the workflow — no identity, no secrets, nothing to do with the notarized signing the GUI bundles get. The guard that caught it was itself unreliable: `codesign -dv | grep -q` under `pipefail` reports failure whenever grep wins the race, because -q exits on the first match and the writer takes SIGPIPE. Small output means the writer usually finishes first, which is why the arm64 job passed and x86_64 failed on the same signed-or-not question. Capture into a variable and match afterwards, the way the release workflow already does it. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- .github/scripts/assert-macho.sh | 80 +++++++++++ .github/workflows/ci.yml | 75 ++++++++++ .github/workflows/nightly.yml | 95 ++++++++++++- .github/workflows/release.yml | 97 ++++++++++++- crates/tty7-core/src/daemon/control.rs | 21 ++- crates/tty7-core/src/daemon/install/asset.rs | 133 +++++++++++++----- .../tty7-core/src/daemon/install/checksums.rs | 43 +++--- crates/tty7-core/src/daemon/install/tests.rs | 52 ++++--- crates/tty7-core/src/daemon/install/wsl.rs | 18 ++- crates/tty7-core/src/daemon/router.rs | 7 +- crates/tty7-core/src/daemon/spawn.rs | 9 +- docs/remote/workspaces.mdx | 8 +- 12 files changed, 547 insertions(+), 91 deletions(-) create mode 100755 .github/scripts/assert-macho.sh diff --git a/.github/scripts/assert-macho.sh b/.github/scripts/assert-macho.sh new file mode 100755 index 00000000..9254fd20 --- /dev/null +++ b/.github/scripts/assert-macho.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Usage: assert-macho.sh +# Fail unless the binary is a Mach-O executable for that depends +# on nothing but the libraries every macOS already has, and carries a code +# signature. +# +# The macOS counterpart of assert-static.sh, and the same decision (D10) behind +# it: one `tty7-server` binary is pushed to an arbitrary remote Mac and has to +# run there with nothing installed alongside it. Static linking is not the +# instrument on macOS — Apple does not ship a static libSystem and linking one +# is unsupported — so the equivalent guarantee is "links only what the OS +# guarantees is present". A stray Homebrew dependency picked up from the runner +# would still compile, still pass a build-only job, and then fail on the first +# Mac that does not have /opt/homebrew — far from the change that caused it. +set -euo pipefail + +BIN="$1" +WANT_ARCH="$2" + +if [ ! -f "$BIN" ]; then + echo "::error::assert-macho.sh: $BIN does not exist" + exit 1 +fi + +echo "--- file ---" +file "$BIN" +echo "--- otool -L ---" +otool -L "$BIN" +echo "--- otool -l (build version) ---" +otool -l "$BIN" | grep -A 4 -E 'LC_BUILD_VERSION|LC_VERSION_MIN_MACOSX' || true + +fail=0 + +# Each probe is captured into a variable and matched afterwards, never piped +# into `grep -q`. Under `pipefail` that pipeline is a coin toss: -q exits on the +# first match, the writer takes SIGPIPE, and the pipeline reports failure — so a +# binary that passes would be reported as failing, on the runs where grep +# happened to win the race. +FILE_SAYS=$(file "$BIN") +if [[ "$FILE_SAYS" != *"Mach-O 64-bit executable ${WANT_ARCH}"* ]]; then + echo "::error::$BIN is not a 64-bit Mach-O executable for ${WANT_ARCH}" + fail=1 +fi + +# Every dependency must live somewhere the OS owns. /usr/lib and +# /System/Library are the two prefixes shipped with macOS itself; anything else +# — /opt/homebrew, /usr/local, @rpath into a bundle we are not shipping — is a +# library the destination Mac has no reason to have. +# +# `tail -n +2` drops otool's first line, which is the binary's own path and +# would otherwise be judged as if it were a dependency. +STRAY=$(otool -L "$BIN" | tail -n +2 | awk '{print $1}' \ + | grep -Ev '^(/usr/lib/|/System/Library/)' || true) +if [ -n "$STRAY" ]; then + echo "::error::$BIN links libraries that are not part of macOS:" + echo "$STRAY" + fail=1 +fi + +# arm64 refuses to execute an unsigned binary outright, so an unsigned build +# would not fail here but on the user's Mac, as "killed: 9" with no explanation. +# +# Asserted for both slices, not just arm64. The linker ad-hoc signs arm64 on its +# own and leaves x86_64 bare — which would be fine on an Intel Mac, but the +# x86_64 server is also what an Apple Silicon box gets when it asks through a +# Rosetta shell (`uname -sm` = "Darwin x86_64"), and that is not a machine to +# hand an unsigned binary to on a guess. The workflow signs it; this catches the +# day it stops. +SIGNING=$(codesign -dv "$BIN" 2>&1 || true) +if [[ "$SIGNING" != *"Signature="* ]]; then + echo "::error::$BIN carries no code signature — arm64 macOS will refuse to run it" + echo "$SIGNING" + fail=1 +fi + +if [ "$fail" -ne 0 ]; then + exit 1 +fi + +echo "✅ $BIN is a self-contained ${WANT_ARCH} Mach-O ($(du -h "$BIN" | cut -f1))" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29491501..3fab3929 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,3 +239,78 @@ jobs: - name: Assert the binary is static if: steps.probe.outputs.present == 'true' run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server" + + # The same headless server for remote Macs. A second job rather than more rows + # in `server-musl`: that one exists to cross-compile from Linux with zig, and + # neither half of it applies here — an Apple target needs an Apple runner for + # the SDK, and there is no musl to link. + # + # Both slices come off one arm64 runner. Cross-compiling to x86_64 needs + # nothing but the rustup target: the Xcode SDK carries both architectures, and + # the linker is the same one. + # + # Compile-only, like `server-musl` — release.yml and nightly.yml carry the + # same job with an upload step. Non-required until it has a few weeks of + # green, for the branch-protection reason spelled out above. + server-macos: + name: tty7-server macOS (${{ matrix.target }}) + runs-on: macos-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + arch: arm64 + - target: x86_64-apple-darwin + arch: x86_64 + env: + RUSTFLAGS: -C strip=symbols + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Look for the tty7-server package + id: probe + run: | + set -euo pipefail + if cargo metadata --no-deps --format-version 1 \ + | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::tty7-server is not a workspace member yet (crate split, M1) — nothing to build" + fi + + # `-p tty7-server` for the same two reasons as the musl job: it addresses + # the package by name whatever the layout, and it keeps the GUI's `gssapi` + # feature out of the unified feature set. That feature does resolve on + # macOS, unlike under musl, but linking Heimdal into a binary that gets + # pushed to someone else's Mac is exactly the kind of dependency + # assert-macho.sh is there to refuse. + - name: Build tty7-server + if: steps.probe.outputs.present == 'true' + run: cargo build --release --locked -p tty7-server --target ${{ matrix.target }} + + # Ad-hoc, so it needs no identity and no secrets — this is not the code + # signing the GUI bundles get, and it buys nothing with Gatekeeper. It is + # the signature the arm64 kernel requires before it will execute anything + # at all. The linker already applies one to the arm64 slice; x86_64 comes + # out bare, and that is the slice a Rosetta shell on an Apple Silicon Mac + # asks for. `--force` makes this idempotent across both. + - name: Ad-hoc sign the binary + if: steps.probe.outputs.present == 'true' + run: codesign --force --sign - "target/${{ matrix.target }}/release/tty7-server" + + - name: Assert the binary is self-contained + if: steps.probe.outputs.present == 'true' + run: | + bash .github/scripts/assert-macho.sh \ + "target/${{ matrix.target }}/release/tty7-server" "${{ matrix.arch }}" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 43443c28..9bad230b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -309,11 +309,104 @@ jobs: path: tty7/dist/${{ matrix.asset }} if-no-files-found: error + # Mirrors release.yml's server-macos job; keep the two in sync when editing. + # Nightly carries the macOS servers for the same reason it carries the Linux + # ones: so the remote-install path onto a Mac can be exercised against the + # rolling channel instead of waiting for a tag. + server-macos: + needs: plan + if: needs.plan.outputs.build == 'true' + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + arch: arm64 + asset: tty7-server-macos-aarch64 + - target: x86_64-apple-darwin + arch: x86_64 + asset: tty7-server-macos-x86_64 + runs-on: macos-latest + env: + RUSTFLAGS: -C strip=symbols + steps: + - name: Checkout tty7 + uses: actions/checkout@v4 + with: + path: tty7 + + # Stamped for the same reason the musl servers are: the version the server + # reports during the handshake has to be tonight's, not the last stable. + - name: Stamp nightly version + working-directory: tty7 + run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}" + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tty7 + key: ${{ matrix.target }} + + - name: Look for the tty7-server package + id: probe + working-directory: tty7 + run: | + set -euo pipefail + if cargo metadata --no-deps --format-version 1 \ + | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::warning::tty7-server is not a workspace member yet — tonight's nightly carries no macOS remote-server assets" + fi + + # No `--locked`, matching the rest of nightly: the version stamp above + # rewrites Cargo.toml and cargo has to be free to refresh its lock entry. + - name: Build tty7-server + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: cargo build --release -p tty7-server --target ${{ matrix.target }} + + # Ad-hoc, for the reason release.yml spells out: it is what arm64 requires + # before it will execute anything, the linker only applies it to the arm64 + # slice, and x86_64 is what a Rosetta shell asks for. + - name: Ad-hoc sign the binary + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: codesign --force --sign - "target/${{ matrix.target }}/release/tty7-server" + + - name: Assert the binary is self-contained + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: | + bash .github/scripts/assert-macho.sh \ + "target/${{ matrix.target }}/release/tty7-server" "${{ matrix.arch }}" + + - name: Stage the asset + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: | + set -euo pipefail + mkdir -p dist + cp "target/${{ matrix.target }}/release/tty7-server" \ + "dist/${{ matrix.asset }}" + chmod +x "dist/${{ matrix.asset }}" + + - uses: actions/upload-artifact@v7 + if: steps.probe.outputs.present == 'true' + with: + name: nightly-${{ matrix.asset }} + path: tty7/dist/${{ matrix.asset }} + if-no-files-found: error + # Single publish step after all platforms succeed, so the rolling release is # always complete — a failed platform means tonight's nightly is skipped # entirely and users keep yesterday's, never a partial asset set. publish: - needs: [plan, build, server-musl] + needs: [plan, build, server-musl, server-macos] runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b72f06b..350faf24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -265,14 +265,107 @@ jobs: path: tty7/dist/${{ matrix.asset }} if-no-files-found: error - # Single assembly step, after all four platforms succeed. The release object is + # The same server for remote Macs, published under the same contract: flat, + # version-free names that `install::asset` derives from `uname -sm`. Separate + # from `server-musl` because an Apple target needs an Apple runner, and both + # slices come off one arm64 runner — the Xcode SDK carries both. See ci.yml's + # `server-macos` for the rest of the reasoning; keep the three in sync. + # + # Not code-signed or notarized, and deliberately: this binary is never + # downloaded by the destination Mac. The client fetches it, verifies it against + # checksums.txt and writes it over SFTP, which sets no quarantine attribute, so + # Gatekeeper is not in the path at all. The ad-hoc signature the linker applies + # is all arm64 asks for, and `assert-macho.sh` checks it is there. + server-macos: + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + arch: arm64 + asset: tty7-server-macos-aarch64 + - target: x86_64-apple-darwin + arch: x86_64 + asset: tty7-server-macos-x86_64 + runs-on: macos-latest + env: + RUSTFLAGS: -C strip=symbols + steps: + - name: Checkout tty7 + uses: actions/checkout@v4 + with: + path: tty7 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tty7 + key: ${{ matrix.target }} + + - name: Look for the tty7-server package + id: probe + working-directory: tty7 + run: | + set -euo pipefail + if cargo metadata --no-deps --format-version 1 \ + | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::warning::tty7-server is not a workspace member yet — this release will carry no macOS remote-server assets" + fi + + - name: Build tty7-server + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: cargo build --release --locked -p tty7-server --target ${{ matrix.target }} + + # Ad-hoc — no identity, no secrets, and nothing to do with the notarized + # signing the GUI bundles get. It is what the arm64 kernel requires before + # it will execute a binary at all: the linker signs the arm64 slice + # itself, x86_64 comes out bare, and x86_64 is what a Rosetta shell on an + # Apple Silicon Mac asks for. `--force` makes it idempotent across both. + - name: Ad-hoc sign the binary + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: codesign --force --sign - "target/${{ matrix.target }}/release/tty7-server" + + - name: Assert the binary is self-contained + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: | + bash .github/scripts/assert-macho.sh \ + "target/${{ matrix.target }}/release/tty7-server" "${{ matrix.arch }}" + + - name: Stage the asset + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: | + set -euo pipefail + mkdir -p dist + cp "target/${{ matrix.target }}/release/tty7-server" \ + "dist/${{ matrix.asset }}" + chmod +x "dist/${{ matrix.asset }}" + + - uses: actions/upload-artifact@v7 + if: steps.probe.outputs.present == 'true' + with: + name: release-${{ matrix.asset }} + path: tty7/dist/${{ matrix.asset }} + if-no-files-found: error + + # Single assembly step, after every GUI platform and every server slice + # succeeds. The release object is # created as a **draft** and left that way: a draft is invisible to both # /releases/latest and the releases page, so nothing can prompt a user to # download a version whose asset set is incomplete or whose notes are still # empty. Publishing is the release skill's job — it verifies the platform assets and # writes the body first, then flips the draft. See .claude/skills/release/SKILL.md. draft-release: - needs: [build, server-musl] + needs: [build, server-musl, server-macos] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest env: diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index ac758330..639fce0e 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -9,7 +9,26 @@ use serde::{Deserialize, Serialize}; use super::protocol::{MAX_FRAME, read_frame, write_frame}; -pub const CONTROL_VERSION: u32 = 5; +/// The control dialect this build speaks. A peer that answers the hello with a +/// different number is refused outright, and the number is half of the remote +/// server's filename (`tty7-server-c{control}p{protocol}`), so moving it also +/// makes a client install the server that matches instead of trusting the one +/// already sitting at that path. +/// +/// Move it whenever a variant is added to or removed from [`ControlRequest`], +/// [`ReplyOk`] or [`ControlEvent`]. The [`feature`] strings cover only what a +/// peer can safely ignore — a field added to a message it already decodes. A +/// request it has never heard of is not ignorable: the frame fails to decode +/// and the read loop that failed on it takes the whole link down, which is how +/// a remote workspace opens with no tabs and a git detail pane never fills. +/// +/// v6 pays off the drift since v5, which was most of the dialect: the machine +/// tree replaced `WorkspaceList`/`Get`/`Put`/`Delete` with `WorkspaceTree`, +/// `MachineGet` and the tab/pane verbs, `GitStream` arrived with its chunk and +/// end events, and `ReplyOk::Attached` and `FileMeta` went away. All of it +/// shipped against a number that never moved, so every one of those servers +/// still answers the hello and then drops the link on the first call. +pub const CONTROL_VERSION: u32 = 6; const DIALECT_MARKER: &str = "speaks control v"; diff --git a/crates/tty7-core/src/daemon/install/asset.rs b/crates/tty7-core/src/daemon/install/asset.rs index 28ac01fd..cbc37e51 100644 --- a/crates/tty7-core/src/daemon/install/asset.rs +++ b/crates/tty7-core/src/daemon/install/asset.rs @@ -1,7 +1,15 @@ use std::fmt; -pub const ASSET_X86_64: &str = "tty7-server-linux-x86_64-musl"; -pub const ASSET_AARCH64: &str = "tty7-server-linux-aarch64-musl"; +pub const ASSET_LINUX_X86_64: &str = "tty7-server-linux-x86_64-musl"; +pub const ASSET_LINUX_AARCH64: &str = "tty7-server-linux-aarch64-musl"; + +/// The macOS servers carry no libc suffix because there is nothing to choose: +/// they link the system libSystem every macOS has, which is as portable there +/// as static musl is on Linux. Same flat, version-free shape as the others — +/// the tag in the download URL carries the version. +pub const ASSET_MACOS_X86_64: &str = "tty7-server-macos-x86_64"; +pub const ASSET_MACOS_AARCH64: &str = "tty7-server-macos-aarch64"; + pub const CHECKSUMS_ASSET: &str = "checksums.txt"; pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download"; @@ -10,7 +18,7 @@ pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"] #[derive(Debug, Clone, PartialEq, Eq)] pub enum UnsupportedTarget { - NotLinux { raw: String }, + UnsupportedSystem { raw: String }, UnknownMachine { raw: String }, Unparseable { raw: String }, } @@ -18,9 +26,9 @@ pub enum UnsupportedTarget { impl UnsupportedTarget { pub fn raw(&self) -> &str { match self { - Self::NotLinux { raw } | Self::UnknownMachine { raw } | Self::Unparseable { raw } => { - raw - } + Self::UnsupportedSystem { raw } + | Self::UnknownMachine { raw } + | Self::Unparseable { raw } => raw, } } } @@ -28,14 +36,15 @@ impl UnsupportedTarget { impl fmt::Display for UnsupportedTarget { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::NotLinux { raw } => write!( + Self::UnsupportedSystem { raw } => write!( f, - "a remote tty7 workspace needs a Linux host; this machine reports `uname -sm` = {raw:?}" + "a remote tty7 workspace needs a Linux or macOS host; this machine reports \ + `uname -sm` = {raw:?}" ), Self::UnknownMachine { raw } => write!( f, "no tty7-server is published for this architecture (`uname -sm` = {raw:?}); \ - supported: x86_64/amd64 and aarch64/arm64" + supported: Linux on x86_64/amd64 and aarch64/arm64, macOS on x86_64 and arm64" ), Self::Unparseable { raw } => write!( f, @@ -53,23 +62,31 @@ pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget let (Some(system), Some(machine), None) = (words.next(), words.next(), words.next()) else { return Err(UnsupportedTarget::Unparseable { raw }); }; - if system != "Linux" { - return Err(UnsupportedTarget::NotLinux { raw }); - } - match machine { - "x86_64" | "amd64" => Ok(ASSET_X86_64), - "aarch64" | "arm64" | "armv8l" | "armv8b" => Ok(ASSET_AARCH64), - _ => Err(UnsupportedTarget::UnknownMachine { raw }), + // Matched per system rather than by machine alone: the two do not share a + // vocabulary. Linux answers `arm64` on some distributions and `aarch64` on + // others, while macOS only ever says `arm64` — accepting Linux's spellings + // under Darwin would be guessing at output no Mac produces, and the machine + // names that would reach it are the ones worth refusing loudly. + match (system, machine) { + ("Linux", "x86_64" | "amd64") => Ok(ASSET_LINUX_X86_64), + ("Linux", "aarch64" | "arm64" | "armv8l" | "armv8b") => Ok(ASSET_LINUX_AARCH64), + // A Rosetta shell reports `x86_64` on Apple Silicon, and taking it at + // its word is right: the x86_64 server runs under the same translation + // the shell asking for it is already running under. + ("Darwin", "x86_64") => Ok(ASSET_MACOS_X86_64), + ("Darwin", "arm64") => Ok(ASSET_MACOS_AARCH64), + ("Linux" | "Darwin", _) => Err(UnsupportedTarget::UnknownMachine { raw }), + _ => Err(UnsupportedTarget::UnsupportedSystem { raw }), } } pub fn interned(name: &str) -> &'static str { - if name == ASSET_X86_64 { - ASSET_X86_64 - } else if name == ASSET_AARCH64 { - ASSET_AARCH64 - } else { - Box::leak(name.to_string().into_boxed_str()) + match name { + _ if name == ASSET_LINUX_X86_64 => ASSET_LINUX_X86_64, + _ if name == ASSET_LINUX_AARCH64 => ASSET_LINUX_AARCH64, + _ if name == ASSET_MACOS_X86_64 => ASSET_MACOS_X86_64, + _ if name == ASSET_MACOS_AARCH64 => ASSET_MACOS_AARCH64, + _ => Box::leak(name.to_string().into_boxed_str()), } } @@ -139,7 +156,7 @@ mod tests { #[test] fn uname_maps_to_the_published_assets() { for raw in ["Linux x86_64", "Linux amd64"] { - assert_eq!(asset_for_uname(raw).unwrap(), ASSET_X86_64, "{raw}"); + assert_eq!(asset_for_uname(raw).unwrap(), ASSET_LINUX_X86_64, "{raw}"); } for raw in [ "Linux aarch64", @@ -147,16 +164,43 @@ mod tests { "Linux armv8l", "Linux armv8b", ] { - assert_eq!(asset_for_uname(raw).unwrap(), ASSET_AARCH64, "{raw}"); + assert_eq!(asset_for_uname(raw).unwrap(), ASSET_LINUX_AARCH64, "{raw}"); + } + assert_eq!( + asset_for_uname("Darwin arm64").unwrap(), + ASSET_MACOS_AARCH64 + ); + assert_eq!( + asset_for_uname("Darwin x86_64").unwrap(), + ASSET_MACOS_X86_64 + ); + } + + /// The two systems are matched as pairs, so a machine name that means one + /// thing on Linux must not be honoured under Darwin just because it appears + /// in the same function. + #[test] + fn a_machine_name_does_not_carry_across_systems() { + for raw in ["Darwin aarch64", "Darwin amd64", "Darwin armv8l"] { + assert!( + matches!( + asset_for_uname(raw).unwrap_err(), + UnsupportedTarget::UnknownMachine { .. } + ), + "{raw} is not something a Mac reports" + ); } } #[test] fn uname_output_is_trimmed_before_matching() { - assert_eq!(asset_for_uname("Linux x86_64\n").unwrap(), ASSET_X86_64); + assert_eq!( + asset_for_uname("Linux x86_64\n").unwrap(), + ASSET_LINUX_X86_64 + ); assert_eq!( asset_for_uname(" Linux x86_64 \r\n").unwrap(), - ASSET_X86_64 + ASSET_LINUX_X86_64 ); } @@ -185,17 +229,18 @@ mod tests { } #[test] - fn non_linux_systems_are_refused() { + fn systems_we_publish_nothing_for_are_refused() { for raw in [ - "Darwin arm64", "FreeBSD amd64", + "OpenBSD amd64", "SunOS i86pc", "linux x86_64", + "darwin arm64", ] { assert!( matches!( asset_for_uname(raw).unwrap_err(), - UnsupportedTarget::NotLinux { .. } + UnsupportedTarget::UnsupportedSystem { .. } ), "{raw}" ); @@ -233,7 +278,7 @@ mod tests { #[test] fn download_urls_point_at_the_release_the_tag_names() { assert_eq!( - download_url(&release_tag("26.7.5"), ASSET_X86_64), + download_url(&release_tag("26.7.5"), ASSET_LINUX_X86_64), "https://github.com/l0ng-ai/tty7/releases/download/v26.7.5/tty7-server-linux-x86_64-musl" ); assert_eq!( @@ -244,16 +289,32 @@ mod tests { #[test] fn asset_names_are_the_ones_the_release_workflow_publishes() { - assert_eq!(ASSET_X86_64, "tty7-server-linux-x86_64-musl"); - assert_eq!(ASSET_AARCH64, "tty7-server-linux-aarch64-musl"); - for asset in [ASSET_X86_64, ASSET_AARCH64] { + assert_eq!(ASSET_LINUX_X86_64, "tty7-server-linux-x86_64-musl"); + assert_eq!(ASSET_LINUX_AARCH64, "tty7-server-linux-aarch64-musl"); + assert_eq!(ASSET_MACOS_X86_64, "tty7-server-macos-x86_64"); + assert_eq!(ASSET_MACOS_AARCH64, "tty7-server-macos-aarch64"); + + let all = [ + ASSET_LINUX_X86_64, + ASSET_LINUX_AARCH64, + ASSET_MACOS_X86_64, + ASSET_MACOS_AARCH64, + ]; + for asset in all { assert!( - !asset.contains("unknown"), + !asset.contains("unknown") && !asset.contains("apple"), "{asset} carries the triple's vendor field" ); + assert_eq!(asset, interned(asset), "{asset} must intern to itself"); + } + // No name may contain another: `checksums` looks a line up by filename, + // and a name that is a suffix of its neighbour would let one asset's + // digest answer for the other's. + for a in all { + for b in all { + assert!(a == b || !a.contains(b), "{a} contains {b}"); + } } - assert!(!ASSET_X86_64.contains(ASSET_AARCH64)); - assert!(!ASSET_AARCH64.contains(ASSET_X86_64)); } #[test] diff --git a/crates/tty7-core/src/daemon/install/checksums.rs b/crates/tty7-core/src/daemon/install/checksums.rs index 91f702f3..d49436d4 100644 --- a/crates/tty7-core/src/daemon/install/checksums.rs +++ b/crates/tty7-core/src/daemon/install/checksums.rs @@ -111,7 +111,7 @@ pub fn verify(manifest: &str, asset: &str, bytes: &[u8]) -> Result<(), ChecksumE #[cfg(test)] mod tests { use super::*; - use crate::daemon::install::asset::{ASSET_AARCH64, ASSET_X86_64}; + use crate::daemon::install::asset::{ASSET_LINUX_AARCH64, ASSET_LINUX_X86_64}; fn manifest_for(payloads: &[(&str, &[u8])]) -> String { payloads @@ -123,31 +123,35 @@ mod tests { #[test] fn matching_bytes_verify() { let bytes = b"\x7fELF pretend this is a server".as_slice(); - let manifest = manifest_for(&[(ASSET_X86_64, bytes), (ASSET_AARCH64, b"other")]); - verify(&manifest, ASSET_X86_64, bytes).expect("the published bytes must verify"); + let manifest = + manifest_for(&[(ASSET_LINUX_X86_64, bytes), (ASSET_LINUX_AARCH64, b"other")]); + verify(&manifest, ASSET_LINUX_X86_64, bytes).expect("the published bytes must verify"); } #[test] fn digest_comparison_is_case_insensitive() { let bytes = b"payload".as_slice(); - let manifest = format!("{} {ASSET_X86_64}\n", hex(&sha256(bytes)).to_uppercase()); - verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter"); + let manifest = format!( + "{} {ASSET_LINUX_X86_64}\n", + hex(&sha256(bytes)).to_uppercase() + ); + verify(&manifest, ASSET_LINUX_X86_64, bytes).expect("case must not matter"); } #[test] fn mismatched_bytes_abort_with_both_digests() { let published = b"the real server binary".as_slice(); let tampered = b"the real server binary!".as_slice(); - let manifest = manifest_for(&[(ASSET_X86_64, published)]); + let manifest = manifest_for(&[(ASSET_LINUX_X86_64, published)]); - let err = verify(&manifest, ASSET_X86_64, tampered).unwrap_err(); + let err = verify(&manifest, ASSET_LINUX_X86_64, tampered).unwrap_err(); match err { ChecksumError::Mismatch { ref asset, ref expected, ref actual, } => { - assert_eq!(asset, ASSET_X86_64); + assert_eq!(asset, ASSET_LINUX_X86_64); assert_eq!(*expected, hex(&sha256(published))); assert_eq!(*actual, hex(&sha256(tampered))); assert_ne!(expected, actual); @@ -163,24 +167,24 @@ mod tests { fn a_single_flipped_bit_fails() { let mut payload = vec![0u8; 4096]; payload[1234] = 0x5a; - let manifest = manifest_for(&[(ASSET_X86_64, &payload)]); + let manifest = manifest_for(&[(ASSET_LINUX_X86_64, &payload)]); let mut flipped = payload.clone(); flipped[1234] ^= 0x01; assert!(matches!( - verify(&manifest, ASSET_X86_64, &flipped), + verify(&manifest, ASSET_LINUX_X86_64, &flipped), Err(ChecksumError::Mismatch { .. }) )); } #[test] fn a_missing_entry_aborts() { - let manifest = manifest_for(&[(ASSET_AARCH64, b"arm bytes")]); + let manifest = manifest_for(&[(ASSET_LINUX_AARCH64, b"arm bytes")]); assert!(matches!( - verify(&manifest, ASSET_X86_64, b"anything"), + verify(&manifest, ASSET_LINUX_X86_64, b"anything"), Err(ChecksumError::Missing { .. }) )); assert!(matches!( - verify("", ASSET_X86_64, b"anything"), + verify("", ASSET_LINUX_X86_64, b"anything"), Err(ChecksumError::Missing { .. }) )); } @@ -192,7 +196,7 @@ mod tests { "zz786850e387550fdab836ed7e6dc881de23001b4b4d8ec3a1a0b9d5e0d5c0f1x tty7-server-linux-x86_64-musl", " tty7-server-linux-x86_64-musl", ] { - let err = expected_digest(bad, ASSET_X86_64).unwrap_err(); + let err = expected_digest(bad, ASSET_LINUX_X86_64).unwrap_err(); assert!( matches!( err, @@ -207,13 +211,13 @@ mod tests { fn filename_matching_is_exact_not_substring() { let payload = b"decoy".as_slice(); let manifest = format!( - "{} {ASSET_X86_64}.sig\n{} old-{ASSET_X86_64}\n", + "{} {ASSET_LINUX_X86_64}.sig\n{} old-{ASSET_LINUX_X86_64}\n", hex(&sha256(payload)), hex(&sha256(payload)), ); assert!( matches!( - expected_digest(&manifest, ASSET_X86_64), + expected_digest(&manifest, ASSET_LINUX_X86_64), Err(ChecksumError::Missing { .. }) ), "neither a suffixed nor a prefixed name may satisfy the lookup" @@ -224,9 +228,10 @@ mod tests { fn tolerates_binary_mode_crlf_and_comments() { let payload = b"payload".as_slice(); let digest = hex(&sha256(payload)); - let manifest = - format!("# generated by the release workflow\r\n\r\n{digest} *{ASSET_X86_64}\r\n"); - verify(&manifest, ASSET_X86_64, payload).expect("binary-mode CRLF lines must parse"); + let manifest = format!( + "# generated by the release workflow\r\n\r\n{digest} *{ASSET_LINUX_X86_64}\r\n" + ); + verify(&manifest, ASSET_LINUX_X86_64, payload).expect("binary-mode CRLF lines must parse"); } #[test] diff --git a/crates/tty7-core/src/daemon/install/tests.rs b/crates/tty7-core/src/daemon/install/tests.rs index 6b0109dd..38c20835 100644 --- a/crates/tty7-core/src/daemon/install/tests.rs +++ b/crates/tty7-core/src/daemon/install/tests.rs @@ -3,7 +3,7 @@ use std::sync::Mutex; use std::time::Duration; use super::*; -use crate::daemon::install::asset::{ASSET_X86_64, CHECKSUMS_ASSET}; +use crate::daemon::install::asset::{ASSET_LINUX_X86_64, CHECKSUMS_ASSET}; const VERSION: &str = "26.7.5"; const CONTROL: u32 = 3; @@ -339,7 +339,7 @@ impl FakeRelease { fn manifest(&self) -> String { format!( - "{} {ASSET_X86_64}\n{} checksums-are-not-self-describing\n", + "{} {ASSET_LINUX_X86_64}\n{} checksums-are-not-self-describing\n", checksums::hex(&checksums::sha256(&self.manifest_of)), checksums::hex(&checksums::sha256(b"noise")), ) @@ -359,7 +359,7 @@ impl AssetFetcher for FakeRelease { if url.ends_with(CHECKSUMS_ASSET) { return Ok(self.manifest().into_bytes()); } - if url.ends_with(ASSET_X86_64) { + if url.ends_with(ASSET_LINUX_X86_64) { return Ok(self.asset_bytes.clone()); } Err(format!("404: {url}")) @@ -418,7 +418,7 @@ fn first_install_runs_all_six_steps() { .run() .expect("a clean install must succeed"); - assert_eq!(report.asset, ASSET_X86_64); + assert_eq!(report.asset, ASSET_LINUX_X86_64); assert_eq!(report.paths.binary, BINARY); assert!(report.installed, "bytes were transferred"); assert!(report.confirmed, "a new machine is confirmed once"); @@ -442,7 +442,9 @@ fn first_install_runs_all_six_steps() { release.fetched(), vec![ format!("https://github.com/l0ng-ai/tty7/releases/download/v{VERSION}/checksums.txt"), - format!("https://github.com/l0ng-ai/tty7/releases/download/v{VERSION}/{ASSET_X86_64}"), + format!( + "https://github.com/l0ng-ai/tty7/releases/download/v{VERSION}/{ASSET_LINUX_X86_64}" + ), ] ); } @@ -587,14 +589,14 @@ fn the_confirmation_states_path_size_and_origin() { let request = &asked[0]; assert_eq!(request.host, "me@fresh-box:22"); assert_eq!(request.remote_path, BINARY); - assert_eq!(request.asset, ASSET_X86_64); + assert_eq!(request.asset, ASSET_LINUX_X86_64); assert_eq!( request.size_bytes, SERVER_BYTES.len() as u64, "the size quoted is the verified byte count, not a Content-Length promise" ); assert!(request.source_url.contains("github.com")); - assert!(request.source_url.contains(ASSET_X86_64)); + assert!(request.source_url.contains(ASSET_LINUX_X86_64)); assert_eq!( request.sha256, checksums::hex(&checksums::sha256(SERVER_BYTES)) @@ -625,7 +627,7 @@ fn the_default_confirmation_declines() { let request = InstallRequest { host: "me@somewhere:22".into(), version: VERSION.into(), - asset: ASSET_X86_64, + asset: ASSET_LINUX_X86_64, source_url: "https://example/x".into(), remote_path: BINARY.into(), size_bytes: 42, @@ -694,7 +696,14 @@ fn a_present_but_unexecutable_binary_is_reinstalled() { #[test] fn an_unsupported_machine_is_refused_before_any_work() { - for (uname, expect_linux) in [("Linux armv7l", true), ("Darwin arm64", false)] { + // A machine we publish nothing for on a system we do, on both systems we + // do, and a system we do not — the three ways this can end, none of which + // may touch the network or the remote box. + for (uname, expect_unknown_machine) in [ + ("Linux armv7l", true), + ("Darwin i386", true), + ("FreeBSD amd64", false), + ] { let mut remote = FakeRemote::new(); remote.uname = format!("{uname}\n"); let release = FakeRelease::new(); @@ -708,7 +717,8 @@ fn an_unsupported_machine_is_refused_before_any_work() { assert_eq!(target.raw(), uname); assert_eq!( matches!(target, UnsupportedTarget::UnknownMachine { .. }), - expect_linux + expect_unknown_machine, + "{uname} was refused as {target:?}" ); } other => panic!("{uname} must be refused, got {other}"), @@ -982,7 +992,9 @@ fn without_a_bundle_the_source_is_the_plain_download() { bundled: None, fallback_on_missing: false, }; - let loaded = source.load("26.7.5", ASSET_X86_64).expect("downloads"); + let loaded = source + .load("26.7.5", ASSET_LINUX_X86_64) + .expect("downloads"); assert_eq!(loaded.bytes, SERVER_BYTES); assert_eq!( release.fetched().len(), @@ -996,7 +1008,7 @@ fn a_bundle_is_used_instead_of_downloading() { let dir = std::env::temp_dir().join(format!("tty7-bundle-src-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(ASSET_X86_64), b"\x7fELF local build").unwrap(); + std::fs::write(dir.join(ASSET_LINUX_X86_64), b"\x7fELF local build").unwrap(); let release = FakeRelease::new(); let source = BundledOrRelease { @@ -1004,7 +1016,9 @@ fn a_bundle_is_used_instead_of_downloading() { bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), fallback_on_missing: false, }; - let loaded = source.load("26.7.5", ASSET_X86_64).expect("loads locally"); + let loaded = source + .load("26.7.5", ASSET_LINUX_X86_64) + .expect("loads locally"); assert_eq!(loaded.bytes, b"\x7fELF local build"); assert!( release.fetched().is_empty(), @@ -1030,7 +1044,9 @@ fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() { bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), fallback_on_missing: false, }; - let err = source.load("26.7.5", ASSET_X86_64).expect_err("no binary"); + let err = source + .load("26.7.5", ASSET_LINUX_X86_64) + .expect_err("no binary"); assert!(matches!(err, InstallError::MissingBundled { .. }), "{err}"); assert!( err.to_string().contains(&dir.display().to_string()), @@ -1056,7 +1072,7 @@ fn discover_falls_back_to_release_when_bundled_is_missing() { fallback_on_missing: true, }; let loaded = source - .load("26.7.5", ASSET_X86_64) + .load("26.7.5", ASSET_LINUX_X86_64) .expect("falls back to release"); assert_eq!(loaded.bytes, SERVER_BYTES); assert_eq!( @@ -1075,7 +1091,7 @@ fn discover_uses_bundled_when_it_is_present() { )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(ASSET_X86_64), b"\x7fELF discovered build").unwrap(); + std::fs::write(dir.join(ASSET_LINUX_X86_64), b"\x7fELF discovered build").unwrap(); let release = FakeRelease::new(); let source = BundledOrRelease { @@ -1083,7 +1099,9 @@ fn discover_uses_bundled_when_it_is_present() { bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), fallback_on_missing: true, }; - let loaded = source.load("26.7.5", ASSET_X86_64).expect("loads locally"); + let loaded = source + .load("26.7.5", ASSET_LINUX_X86_64) + .expect("loads locally"); assert_eq!(loaded.bytes, b"\x7fELF discovered build"); assert!( release.fetched().is_empty(), diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs index 32bf96f1..8c43e82a 100644 --- a/crates/tty7-core/src/daemon/install/wsl.rs +++ b/crates/tty7-core/src/daemon/install/wsl.rs @@ -1273,23 +1273,23 @@ mod tests { let source = BundledServerBinary::in_dirs(vec![tmp.join("nope"), tmp.clone()]); let err = source - .load("26.7.5", super::super::asset::ASSET_X86_64) + .load("26.7.5", super::super::asset::ASSET_LINUX_X86_64) .expect_err("nothing bundled yet"); let msg = err.to_string(); assert!(msg.contains("tty7-server-linux-x86_64-musl"), "{msg}"); assert!(msg.contains(&tmp.display().to_string()), "{msg}"); assert!(matches!(err, InstallError::MissingBundled { .. })); - let path = tmp.join(super::super::asset::ASSET_X86_64); + let path = tmp.join(super::super::asset::ASSET_LINUX_X86_64); std::fs::write(&path, b"").unwrap(); let err = source - .load("26.7.5", super::super::asset::ASSET_X86_64) + .load("26.7.5", super::super::asset::ASSET_LINUX_X86_64) .expect_err("empty is not a binary"); assert!(err.to_string().contains("empty file"), "{err}"); std::fs::write(&path, b"\x7fELF-not-really").unwrap(); let loaded = source - .load("26.7.5", super::super::asset::ASSET_X86_64) + .load("26.7.5", super::super::asset::ASSET_LINUX_X86_64) .unwrap(); assert_eq!(loaded.bytes, b"\x7fELF-not-really"); assert_eq!(loaded.origin, path.display().to_string()); @@ -1451,7 +1451,7 @@ mod tests { fn bundled(dir: &Path, bytes: &[u8]) -> BundledServerBinary { std::fs::create_dir_all(dir).unwrap(); - std::fs::write(dir.join(super::super::asset::ASSET_X86_64), bytes).unwrap(); + std::fs::write(dir.join(super::super::asset::ASSET_LINUX_X86_64), bytes).unwrap(); BundledServerBinary::in_dirs(vec![dir.to_path_buf()]) } @@ -1504,7 +1504,7 @@ mod tests { assert_eq!(asked.len(), 1); assert_eq!( asked[0].source_url, - dir.join(super::super::asset::ASSET_X86_64) + dir.join(super::super::asset::ASSET_LINUX_X86_64) .display() .to_string() ); @@ -1635,7 +1635,11 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("linux-aarch64-musl"), "{msg}"); - std::fs::write(dir.join(super::super::asset::ASSET_AARCH64), b"\x7fELF arm").unwrap(); + std::fs::write( + dir.join(super::super::asset::ASSET_LINUX_AARCH64), + b"\x7fELF arm", + ) + .unwrap(); let source = BundledServerBinary::in_dirs(vec![dir.clone()]); let report = Installer::with_source(&ops, &source, &confirm, host_label("Ubuntu")) .with_version("26.7.5") diff --git a/crates/tty7-core/src/daemon/router.rs b/crates/tty7-core/src/daemon/router.rs index 12d0bf13..4c759568 100644 --- a/crates/tty7-core/src/daemon/router.rs +++ b/crates/tty7-core/src/daemon/router.rs @@ -978,7 +978,7 @@ mod tests { InstallRequest { host: "me@build-box:22".into(), version: "26.7.5".into(), - asset: crate::daemon::install::asset::ASSET_AARCH64, + asset: crate::daemon::install::asset::ASSET_LINUX_AARCH64, source_url: "https://example.invalid/tty7-server".into(), remote_path: "/home/me/.local/share/tty7/bin/tty7-server-26.7.5".into(), size_bytes: 12_345_678, @@ -1055,7 +1055,10 @@ mod tests { impl InstallConfirm for Approve { fn confirm(&self, request: &InstallRequest) -> InstallDecision { assert_eq!(request.host, "me@build-box:22"); - assert_eq!(request.asset, crate::daemon::install::asset::ASSET_AARCH64); + assert_eq!( + request.asset, + crate::daemon::install::asset::ASSET_LINUX_AARCH64 + ); InstallDecision::Approve } } diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index e5295825..a57461ad 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -83,10 +83,11 @@ fn note_local_daemon(version: Option) { /// behind, since the GUI is replaced while the daemon keeps serving every pane /// from the old binary. /// -/// `PROTOCOL_VERSION` moves only when the wire format does (it has been 5 since -/// July), so this is the *common* case after an update, not a rare one: the -/// user sees a new version number while `pane.rs`, `shell_integration.rs` and -/// the whole ssh stack still run last release's code. +/// `PROTOCOL_VERSION` moves only when the wire format does, and it has not +/// moved since July, so this is the *common* case after an update, not a rare +/// one: the user sees a new version number while `pane.rs`, +/// `shell_integration.rs` and the whole ssh stack still run last release's +/// code. /// /// Deliberately not a prompt and not an automatic restart. Restarting the /// daemon ends every process it owns — the shells, the agents, the SSH diff --git a/docs/remote/workspaces.mdx b/docs/remote/workspaces.mdx index 67ed5e7d..f5ca6cf4 100644 --- a/docs/remote/workspaces.mdx +++ b/docs/remote/workspaces.mdx @@ -46,10 +46,14 @@ Nothing is synced or copied. The repository stays where it is. | | | |---|---| -| **What** | A single static `tty7-server` binary | +| **What** | A single self-contained `tty7-server` binary | | **Where** | `~/.local/share/tty7/bin/tty7-server-cp` | | **Privileges** | None. No sudo, nothing outside your home directory | -| **Hosts** | Linux, x86-64 or aarch64 | +| **Hosts** | Linux (x86-64 or aarch64) and macOS (Apple Silicon or Intel) | + +The Linux binary is linked fully static against musl, so it runs on any +distribution whatever its glibc vintage. The macOS one links only the libraries +every Mac already has. Either way nothing is installed beside it. The binary is named after the wire dialect it speaks, so a client and a server that disagree never quietly half-work — tty7 installs the matching one instead.