feat(remote): remote workspaces — a window that is one machine

Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.

- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
  the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
  static against musl and pushed onto the remote box. One dependency, on
  purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
  makes goes through the machine it belongs to. `LocalHost` answers on this
  box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
  of them run on the background executor with the result landed on the UI
  thread; de-duplication, staleness and error reporting live here rather than
  at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
  workspace list → a window bound to one workspace on it. Workspace switcher
  groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
  cargo-zigbuild, a host-boundary grep, and version stamping factored out of
  the nightly workflow. Both new jobs are non-required so branch protection
  does not wedge open PRs.

Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
This commit is contained in:
l0ng-ai
2026-07-28 10:59:46 +08:00
parent 0030b98faa
commit 208454e202
116 changed files with 41232 additions and 4782 deletions
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Usage: assert-static.sh <path-to-elf>
# Fail unless the binary is a fully static ELF — no dynamic loader, no shared
# library dependencies.
#
# This is the mechanical guard behind D10 (docs/2026-07-27-remote-workspace-design.md):
# one `tty7-server` binary is pushed to arbitrary remote machines and must run
# there regardless of what libc, and what *version* of it, that machine has. A
# build that silently picked up a dynamic dependency would still pass a
# compile-only CI job and then fail on the first old box a user connects to —
# far from the change that caused it. Cheap to assert, expensive to discover.
set -euo pipefail
BIN="$1"
if [ ! -f "$BIN" ]; then
echo "::error::assert-static.sh: $BIN does not exist"
exit 1
fi
echo "--- file ---"
file "$BIN"
echo "--- readelf -d ---"
readelf -d "$BIN" || true
fail=0
# `file` says "statically linked" for a classic static binary and "static-pie
# linked" for a position-independent one. Rust's musl targets have shipped both
# shapes depending on toolchain version, and both are equally self-contained, so
# accept either — but nothing else.
if ! file "$BIN" | grep -Eq 'statically linked|static-pie linked'; then
echo "::error::$BIN is not statically linked (D10 requires a self-contained binary)"
fail=1
fi
# The decisive check: a static binary has no PT_INTERP segment, i.e. no
# request for /lib/ld-musl-*.so or ld-linux-*.so. This catches the case `file`
# alone would not, where a dynamic loader is still required.
if readelf -l "$BIN" | grep -q 'Requesting program interpreter'; then
echo "::error::$BIN requires a dynamic loader (PT_INTERP present) — not a static build"
fail=1
fi
# Belt and braces: no DT_NEEDED entries, i.e. no shared libraries to resolve.
if readelf -d "$BIN" 2>/dev/null | grep -q 'NEEDED'; then
echo "::error::$BIN has shared-library dependencies (DT_NEEDED) — not a static build"
fail=1
fi
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "$BIN is a static binary ($(du -h "$BIN" | cut -f1))"
+8 -1
View File
@@ -13,7 +13,14 @@ set -euo pipefail
TARGET="$1"
ARCH="$2"
VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
# Anchored on `= "` — see the note in bundle-macos.sh: the root manifest leads
# with `version.workspace = true`, which a bare `^version` match would return
# verbatim as the "version" and bake into every asset filename.
VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "bundle-linux: could not read a version from Cargo.toml (got '$VERSION')" >&2
exit 1
fi
NAME="tty7-${VERSION}-linux-${ARCH}"
STAGE="dist/${NAME}"
+9 -1
View File
@@ -12,7 +12,15 @@ set -euo pipefail
TARGET="$1"
ARCH="$2"
VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
# Anchored on `= "` because the root manifest's `[package]` section leads with
# `version.workspace = true` — a bare `^version` match grabs that line, finds no
# quotes to substitute, and passes it through as the "version", which then lands
# in CFBundleVersion and the .dmg filename. Guard against a silent recurrence.
VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "bundle-macos: could not read a version from Cargo.toml (got '$VERSION')" >&2
exit 1
fi
APP="dist/tty7.app"
rm -rf dist
+17
View File
@@ -26,6 +26,23 @@ Copy-Item "assets/completions/*.json" "$Stage/completions/"
Copy-Item LICENSE "$Stage/LICENSE.txt"
Copy-Item README.md "$Stage/README.md"
# The Linux musl `tty7-server`, staged at server/ so a WSL distro can be handed
# the binary this client shipped with (design §12: WSL downloads nothing). The
# lookup path is a contract with `daemon::install::wsl` — it searches
# <dir of tty7.exe>/server/<asset> first — so this directory name is not free to
# change on its own. Missing is a warning, not an error, matching `server-musl`'s
# own skip-don't-fail probe; WSL then fails at connect time with a message
# naming the directories it searched.
$ServerAsset = "tty7-server-x86_64-unknown-linux-musl"
$ServerSrc = "bundled-server/$ServerAsset"
if (Test-Path $ServerSrc) {
New-Item -ItemType Directory -Force -Path "$Stage/server" | Out-Null
Copy-Item $ServerSrc "$Stage/server/$ServerAsset"
Write-Host "OK bundled $ServerAsset"
} else {
Write-Warning "no $ServerAsset to bundle - this build cannot serve WSL distros"
}
Compress-Archive -Path "$Stage/*" -DestinationPath "dist/$Name.zip" -Force
# Installer, built from the same staged payload. ISCC is on PATH on GitHub's
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
#
# Contract §10.6 — the GUI must not touch the filesystem or git directly.
#
# Once a workspace can live on a remote machine, a path held by `ui::` or
# `terminal::` is not necessarily a path on *this* box, and `std::path`'s
# fs-backed APIs quietly answer for the wrong machine (contract §4.3):
# `canonicalize` walks the local filesystem, `is_absolute` says `false` for
# `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may
# be looking at a workspace path has to go through `ui::host_ops` / the `Host`
# trait, which routes to the local disk or the far side as appropriate.
#
# This script enforces that. It is deliberately *not* the raw grep from the
# contract: run bare, that grep reports 42 hits on a clean tree — not one of them
# git, all of them modules whose paths are local by construction — and a guard
# that always fails is a guard everyone learns to ignore. Two things fix it:
#
# 1. Test bodies are cut off properly. The contract's `grep -v '#\[cfg(test)\]'`
# only removes the attribute line itself, leaving the whole test module
# behind it in scope; here the scan stops at the `#[cfg(test)] mod …` that
# opens the trailing test region.
# 2. An explicit allowlist, keyed on (file, pattern) rather than whole files, so
# a module exempted for its `.is_absolute()` still trips on a new
# `std::fs::`. Every entry carries the reason its paths cannot be remote.
#
# Adding an entry is a deliberate act: if the path could ever be a workspace
# path, the answer is `Host`, not an allowlist line.
#
# Usage: bash .github/scripts/check-host-boundary.sh
# Exit 0 = clean, 1 = violations (printed to stderr).
set -euo pipefail
cd "$(dirname "$0")/../.."
ROOTS=(src/ui src/terminal)
# The forbidden constructs, as extended-regex alternatives.
PATTERN='std::fs::|Command::new\("git"\)|\.canonicalize\(\)|\.is_absolute\(\)'
# Allowlist entries are `<path>|<literal pattern>`, one per line. `<path>|*`
# exempts a file wholesale (used only for the boundary module itself). The
# pattern side is matched as a plain substring of the offending line.
ALLOW=$(
cat <<'EOF'
# The host boundary itself: every routed filesystem call lands here by design.
src/ui/host_ops.rs|*
# Themes and presets are app-owned files under the local config dir
# (`themes_dir()`), never a workspace path — a remote workspace does not carry
# the user's color schemes with it.
src/ui/presets.rs|std::fs::
src/ui/presets.rs|.is_absolute()
src/ui/app.rs|std::fs::create_dir_all
# Reading a private key off *this* machine to hash it into a keychain account
# (`core::keychain`). The key is the client's credential; the far side never
# sees the file, only the resulting auth.
src/ui/ssh_prompt.rs|std::fs::read
src/ui/ssh_connect.rs|std::fs::read
# Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs
# this app's own history search. A remote pane's history is the remote shell's
# business, read over the wire, not through here.
src/terminal/history.rs|std::fs::
# Completion is already remote-aware: a remote pane is signalled by `cwd: None`,
# which disables exactly these local-filesystem candidate sources in favour of
# `remote_path_request` / `remote_path_candidates`. The `$PATH` scan is likewise
# this machine's `$PATH`, deliberately withheld from remote panes.
src/terminal/completion.rs|std::fs::read_dir
src/terminal/completion.rs|.is_absolute()
# Bundled completion specs shipped in `assets/completions`, resolved from the
# app bundle / dev manifest dir. Program data, not user or workspace data.
src/terminal/signature.rs|std::fs::read_to_string
# Opening a path scraped out of terminal output resolves it against the local
# cwd — a local-pane affordance; `resolve_existing_path` declines when there is
# no local cwd.
src/terminal/search.rs|.is_absolute()
# Clipboard-image paste stages the bytes into the OS temp dir so an agent TUI
# can be handed a path. Always `std::env::temp_dir()` on this machine.
src/terminal/view.rs|std::fs::create_dir_all
src/terminal/view.rs|std::fs::write
EOF
)
# Where the trailing `#[cfg(test)] mod …` starts, if any. Line numbers are
# preserved because only the tail is dropped.
body_of() {
local file=$1 cut
cut=$(awk '
/^#\[cfg\(test\)\]$/ { attr = NR }
/^mod [A-Za-z_]/ { if (attr == NR - 1) { print NR - 1; exit } }
' "$file")
if [ -n "$cut" ]; then
head -n "$((cut - 1))" "$file"
else
cat "$file"
fi
}
allowed() {
local file=$1 line=$2 entry path pat
while IFS= read -r entry; do
case "$entry" in '' | '#'*) continue ;; esac
path=${entry%%|*}
pat=${entry#*|}
[ "$path" = "$file" ] || continue
if [ "$pat" = '*' ] || [ "${line#*"$pat"}" != "$line" ]; then
return 0
fi
done <<<"$ALLOW"
return 1
}
# A guard that scans nothing reports success, which is the one failure mode worse
# than a noisy guard. Fail loudly if a root has moved out from under us.
for root in "${ROOTS[@]}"; do
if [ ! -d "$root" ]; then
echo "check-host-boundary: scan root '$root' does not exist (moved? renamed?)" >&2
exit 2
fi
done
violations=0
scanned=0
while IFS= read -r file; do
scanned=$((scanned + 1))
while IFS= read -r hit; do
lineno=${hit%%:*}
text=${hit#*:}
# Prose, not code.
case "$(printf '%s' "$text" | sed 's/^[[:space:]]*//')" in '//'*) continue ;; esac
if allowed "$file" "$text"; then
continue
fi
printf '%s:%s:%s\n' "$file" "$lineno" "$text" >&2
violations=$((violations + 1))
done < <(body_of "$file" | grep -nE "$PATTERN" || true)
done < <(find "${ROOTS[@]}" -name '*.rs' | sort)
if [ "$scanned" -lt 10 ]; then
echo "check-host-boundary: only $scanned files scanned — the roots look wrong" >&2
exit 2
fi
if [ "$violations" -ne 0 ]; then
cat >&2 <<'EOF'
--------------------------------------------------------------------------------
Contract §10.6: the GUI reached the filesystem/git directly.
A path in `ui::` or `terminal::` may belong to a remote workspace, where these
calls answer for the wrong machine. Route it through `ui::host_ops` / `Host`
instead (`Host::read_dir`, `join`, `is_absolute`, `canonicalize`, …).
If the path genuinely cannot be remote — app config, bundled assets, the local
temp dir — add it to the allowlist in this script with the reason why.
--------------------------------------------------------------------------------
EOF
exit 1
fi
echo "host boundary clean: $scanned files in ${ROOTS[*]}, no direct fs/git calls"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Usage: stamp-version.sh <version>
# Rewrite the package version in Cargo.toml. Used by the nightly workflow, where
# every versioned artifact — the binary's CARGO_PKG_VERSION, asset names, the DMG
# plist, the Inno installer — reads Cargo.toml, so this one edit covers them all.
# Cargo.lock is left alone: cargo refreshes the root package's own lock entry
# automatically, without network access.
#
# Extracted from the inline step so nightly's several build jobs stamp
# identically, and so the guard below has exactly one home.
set -euo pipefail
VERSION="$1"
awk -v ver="$VERSION" '!done && /^version = /{ $0 = "version = \"" ver "\""; done = 1 } { print }' \
Cargo.toml > Cargo.toml.tmp
mv Cargo.toml.tmp Cargo.toml
# Fail loudly if the substitution missed. Without this the awk is a silent no-op
# whenever Cargo.toml's shape changes — e.g. if the root manifest ever becomes a
# virtual workspace whose members carry `version.workspace = true`, in which case
# the version to stamp moves to `[workspace.package]`. A nightly that quietly
# publishes assets named after the *last stable* version is far worse than one
# that fails here.
if ! grep -qx "version = \"$VERSION\"" Cargo.toml; then
echo "::error::stamp-version.sh did not stamp $VERSION into Cargo.toml — the manifest's version line is not where this script expects it"
exit 1
fi
# Report the line actually stamped, not the first thing that looks like a
# version: post-crate-split the root package carries `version.workspace = true`
# and the real value lives further down under [workspace.package], so a naive
# `grep -m1 '^version'` prints the inherit marker and tells you nothing.
grep -n "^version = \"$VERSION\"" Cargo.toml
+4
View File
@@ -57,6 +57,10 @@ Source: "{#StageDir}\tty7.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs
Source: "{#StageDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion
; The Linux musl tty7-server, for serving WSL distros without a download.
; `skipifsourcedoesntexist` because a build whose server-musl leg was skipped
; still has to produce an installer. See bundle-windows.ps1.
Source: "{#StageDir}\server\*"; DestDir: "{app}\server"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist
[Icons]
Name: "{autoprograms}\tty7"; Filename: "{app}\tty7.exe"