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.