Files
tty7/crates/tty7-core/Cargo.toml
T
l0ng-aiandl0ng-ai bed22d899e Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts

Reopening a remote workspace — or coming back to one whose `tty7-server`
had been replaced — landed on a screen of `tty7 — disconnected` panes with
their coding-agent conversations gone. Several independent holes added up
to that; this closes them together, and picks up the surrounding work the
same session produced.

**Telling a restarted server from a blinked link.** `ControlHelloOk` now
carries an `instance` minted once per server *process*. Nothing else in
the handshake changes across a restart — `build` and both dialect numbers
survive it — so a reconnect had no way to know its `pane_id`s were dead.
It does now: a different instance rebuilds the window from its layout
(same tabs and splits, fresh shells in the saved cwds) instead of
re-attaching to a process that is gone. An absent instance means *unknown*
and is never read as a restart.

**An attach can now fail.** `Attach` has no synchronous reply, so the
client returned `Ok` unconditionally and the daemon's `Error` frame was
read much later by the reader thread, which has no arm for it — the pane
then landed in the *link is down* state instead of falling back to a fresh
shell. The client now reads far enough into the reply to classify it on
the kind byte (the snapshot behind it can be megabytes) and hands those
bytes to the reader thread, so a successful attach loses none of its
replay. Local and remote attaches get different waits: the local one is on
the UI thread.

**The agent session survives to be resumed.** `TerminalView` raises
`AgentSessionChanged` when the pane's agent reports a new native session
id, so the layout on file catches up instead of waiting for the user to
happen to open a tab. A pane that is still connecting now carries its
agent through `PendingSpawn` — a save landing in that window used to write
`agent: null` over the record — and `land_pane` sends `--resume` when the
attach turned out to need a fresh shell.

**Ending sessions says so on file.** "End Sessions" kills the panes and
then drops their ids from the record, pushing the cleared layout to the
machine that owns it (design §10: the remote's copy wins, so a local-only
clear would be undone by the next open — the open this exists for).

**The new-tab dropdown lists the window's machine.** `Host::shells` and a
`Shells` control request (dialect v2) make the "+" menu a property of the
machine the window is bound to. A remote window filled from this
computer's `/etc/shells` offered `/bin/zsh` on a box whose zsh is
elsewhere, and every pick failed to spawn.

**An install reports its bytes.** The download and the SFTP upload each
report progress, relayed to the client over the routed connection as a
`RoutePrompt::InstallProgress`, and painted as a bar under the machine's
row in the switcher. ~8 MB across two hops behind the word "connecting…"
was indistinguishable from a hang.

**The installer compares dialects, not version strings.** `tty7-server
--protocol` prints what a binary speaks without starting it, so a connect
adopts an already-running server it can talk to rather than prompting
about a build difference and uploading 8 MB the machine did not need.

**Switcher.** A machine's `⋯` menu holds "New Workspace" (it was a row
under every machine, pushing the list a quarter of a card down) and a new
"Disconnect", which drops the connection and leaves the windows open and
read-only. The suspension lasts exactly as long as that machine has a
window on it.

Also drops three design/contract docs for the now-shipped remote-workspace
work.

* fix(session): stop one workspace's panes from being restored into another

A restart put a copy of one workspace's seven tabs — cwds, layout and
recorded agent sessions — in front of another workspace's own tabs, and
auto-resumed every one of those agents a second time: six `claude
--resume <id>` pairs running in parallel against the same conversations,
one set per window. The record-level corruption that seeded it is still
unattributed, but every mechanism that let it propagate, amplify, or go
unnoticed is closable, and this closes them.

**Panes now know their owner.** `Spawn` can carry the workspace the pane
is created for; the daemon stores it immutably and reports it in
`List`'s `PaneInfo.owner`. Restore refuses to re-attach a pane another
workspace owns (`pane_attachable`) — before this, a saved id landing on
somebody else's live pane attached silently, which is how one window
could pick up another's shells. The field rides a new `SPAWN_OWNED`
frame with a struct payload (the legacy spawn payloads are positional
tuples an old daemon cannot grow), gated on a new `pane-owner` feature
string: a client only sends it to a daemon that advertises it, so the
legacy kinds stay byte-for-byte what old daemons expect. A pane with no
recorded owner stays attachable by anyone — that is the pre-field
behavior, not a new risk.

**Saved pane ids are bound to the daemon process that issued them.**
`DaemonVersion` now carries an `instance` minted once per process (the
local twin of the control hello's), the GUI caches it at the
`ensure_running` handshake, and each local workspace records it as
`daemon_instance` beside its layout. Claiming a workspace whose ids came
from a different instance blanks them first: daemon pane ids restart
from 1, so after a reboot every saved id points at whatever unrelated
shell holds the number now, and the aliveness check cannot tell a
survivor from a squatter. A blank on either side means "cannot tell" and
never trips it. Unlike the duplicate-claim case below, this path keeps
the agent resume — the pane is genuinely gone with its daemon, and the
fresh shell resuming the conversation is the feature.

**A duplicate claim loses its agent resume along with its pane id.**
`dedupe_pane_ids` kept the loser's layout *and* its
`agent_session_id`, so the blanked leaves took restore's spawn-fresh
path and auto-typed `claude --resume` for conversations the winning
workspace's panes were still running — the doubling above. The winner
keeps the panes and the resume; the loser keeps only cwds.

**Cross-workspace saves are caught at the write.** Every terminal view
remembers the workspace whose window created it, and `save_session`
logs an error naming both ids if a window ever records a pane created
for a different workspace — the tripwire for the still-unattributed
seed corruption, so a recurrence is caught in the act instead of
reconstructed from `session.json` archaeology days later.

Wire compatibility both ways: `PaneInfo.owner`, `DaemonVersion.instance`
and `Workspace.daemon_instance` are `#[serde(default)]` struct fields
(old peers' JSON decodes, new fields are ignored by old readers), and
`SPAWN_OWNED` is feature-gated as above. `daemon_instance` is
client-owned in the design-§10 storage split — it names the local
daemon, and the field-census test pins the classification.

* fix(session): resume the agent when a local pane dies mid-restore

`session_to_pane` decided whether to send a coding agent's `--resume`
from `restore.is_none()` — i.e. from whether the pane looked alive when
the restore started. But `alive_panes_on` runs one `List` at the top of
the restore, while the attaches happen per leaf afterwards. A pane that
exited in between failed its attach, fell back to a fresh shell inside
`spawn_shell_terminal_in`, and then landed in the `restore.is_some()`
arm: an empty shell with its conversation dropped.

`ShellParts.restored` already answers this exactly, and the remote path
already reads it in `land_pane`. Carry it onto `TerminalView` so the
synchronous local path can read it too, and branch on that instead of
re-deriving the answer from a set that may be stale by the time it is
used.

No behaviour change on the paths that were already correct: a view that
was never restoring anything reports `restored: false`, which is the
same answer `restore.is_none()` gave them.

* fix(remote): check the server instance against the record, not just memory

A remote workspace's pane ids were only guarded against server restarts
by `RemoteLinks::instances`, an in-memory map. On the first connect after
the client starts, every machine is a first sighting, so `server_restarted`
answers false — and a `tty7-server` that was replaced while the client was
closed sails straight through. Its pane ids restart from 1, so the saved
ones now name unrelated shells, and the reconnect attaches to them: the
exact id-reuse failure the local side already guards against.

`Workspace::daemon_instance` was local-only for the stated reason that a
remote server's identity is tracked live per connection. That tracking is
correct but not sufficient — it cannot survive the client restart that
makes the question worth asking.

So the field now means the same thing on both sides: which process minted
the pane ids in this record. `WorkspaceStore::serving_instance` picks the
local daemon or the far machine's server depending on the workspace, and
`finish_attempt` compares it per workspace before deciding to re-attach or
rebuild. It stays client-owned: it records what *this* client last saw, so
two clients on one remote workspace each keep their own and neither may
overwrite the other's.

An unreachable machine still records nothing, which is what keeps a good
stamp from being erased with `None` — that would disarm the next check.

Also in these three files: the §N references to the deleted design docs,
cleaned up as part of the sweep in the following commit.

* docs: drop the references to the deleted design documents

The three documents this branch removed were cited ~280 times: `design
§10`, `contract §8`, `§17` and friends in comments, five references by
file path in code and manifests, five in CI workflows and one in the
release skill. Every one of them now points at nothing.

Rewritten rather than merely stripped, because most were not decoration:
"design §10 makes the remote's `workspaces.json` the authority" becomes a
statement in its own right, and the several that carried a Chinese phrase
from the document as their justification say the same thing in English
instead. Where the reference was purely parenthetical it is simply gone.

Not touched: `PRD §7.1`, `brief §8` and the like, which name documents
this branch did not remove and were already external before it, and the
`RFC 4648 §10` test-vector citation, which is a real specification.

The `host boundary` CI job loses `(§10.6)` from its name. It is not one of
the required checks, so branch protection is unaffected.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 19:15:19 +08:00

182 lines
8.3 KiB
TOML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[package]
name = "tty7-core"
version.workspace = true
edition.workspace = true
description = "tty7's framework-free core: wire protocol, session daemon, PTY, SSH engine, and the domain model both the GUI and the headless server build on"
repository = "https://github.com/l0ng-ai/tty7"
license = "Apache-2.0"
publish = false
# The whole point of this crate is that it does *not* depend on gpui. Everything
# here has to compile and run on a headless Linux box (that is what
# `tty7-server` is), so nothing windowing-, rendering- or GUI-shaped belongs in
# these dependencies.
[dependencies]
anyhow.workspace = true
log.workspace = true
serde = { workspace = true }
serde_json.workspace = true
# SSH connection-manager data layer (`core::ssh_profile` / `core::keychain`) and
# workspace identity (`core::session`). `uuid` mints stable ids (v4) and
# serde-serializes them as strings.
#
# These live here rather than in the GUI because `Config` embeds
# `Vec<SshProfile>` and `config.json` has to parse identically on the server.
#
# Deliberately *absent*: `keyring`. Nothing in this crate reads or writes a
# secret — the daemon gets them pre-resolved on the wire (`NativeSshSpec`) — and
# a headless `tty7-server` has no OS keychain to read from, so the vault's
# storage half lives in the GUI crate instead (`tty7::core::keychain`). Keeping
# it out here is what stops a static server binary from linking
# `zbus`/`secret-service` and 30-odd crates behind them for code it can never
# call. What stays is the *naming* half: `CredentialRef` and the account scheme,
# which `config.json` parsing needs.
uuid = { version = "1", features = ["v4", "serde"] }
# Two unrelated hashes, both server-side: `daemon::install::checksums` verifies a
# downloaded `tty7-server` asset against the release's sha256 manifest, and
# `core::keychain::key_account_from_contents` derives the sha512-hex account key
# a private key's passphrase is stored under (PRD §7.2) — the account *name* is
# part of the persisted config contract, so it belongs next to `CredentialRef`
# even though only the GUI computes one today.
sha2 = "0.11"
# The gitignore matcher chain (`core::gitignore`) the file tree dims entries
# with — the same crate ripgrep uses. Lives here rather than in the GUI because
# the remote server has to answer "is this path ignored?" with the identical
# implementation.
ignore = "0.4"
# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows,
# behind one blocking `Read`/`Write`/`resize` API. This is what lets
# `daemon::pane` share a single code path across platforms instead of
# hand-rolling fd/ioctl/signal code.
portable-pty = "0.8"
# Native (pure-Rust) SSH client for the daemon's russh session engine
# (`daemon::ssh`) — see the root manifest's note for why we own this stack
# instead of shelling out to `ssh`.
# HTTPS client for the remote-server installer (`daemon::install::download`).
# The GUI's own update check rides `reqwest_client`, which wraps Zed's reqwest
# fork behind `gpui::http_client` — unavailable here, since this crate must not
# depend on gpui. `ureq` is blocking (matching the installer, which runs on a
# daemon std thread), rustls-based (no OpenSSL to find at build time), and
# reuses the `rustls`/`http` versions already in the tree. `gzip` is off: the
# assets are already-compressed binaries.
#
# Optional, and off by default, so the static musl `tty7-server` never links a
# TLS stack for a code path it cannot take — it *is* the binary being
# downloaded. The GUI package turns the feature on (see the root `Cargo.toml`).
ureq = { version = "3", default-features = false, features = [
"rustls",
], optional = true }
russh = "0.62"
russh-sftp = "2"
# tokio powers only the russh session engine — a single runtime `daemon::ssh`
# owns. The daemon's PTY/reader/writer threads remain std threads and never
# touch it; they cross into async through bounded/unbounded channels.
tokio = { version = "1", features = [
"rt-multi-thread",
"net",
"io-util",
"sync",
"time",
"macros",
"process",
"fs",
] }
# Filesystem watching for `host::local` (`Host::watch`): one non-recursive
# watcher per expanded directory, coalesced into 100ms batches. Same crate and
# version the GUI already used for the file tree, so the server watches a remote
# tree exactly the way the client watched a local one.
notify = "8"
# `smol::channel` carries watch batches out of the coalescing thread. Only the
# channel is used — the executor stays in the GUI — but taking it from `smol`
# rather than `async-channel` directly keeps the `Receiver` type identical to the
# one gpui code already awaits.
smol.workspace = true
# SIMD byte search for the OSC tokenizer's Ground/Ignore fast paths — the
# sniffers sit on the full-throughput output stream (100+ MB/s at full drain),
# where a per-byte state machine costs a measurable slice of the reader loop.
memchr = "2"
# Base64 for the byte fields that cross the control dialect's JSON wire
# (`host::Output`'s stdout/stderr). JSON has no byte type — `serde_json` renders
# a `Vec<u8>` as an array of decimal numbers, inflating a 1 MB `git diff` to
# roughly 4 MB. Base64 costs 1.33× instead. Already in the tree via russh.
base64 = "0.22"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# GSSAPI/Kerberos SSH auth — see the `gssapi` feature below for why it is
# optional rather than an unconditional Unix dependency.
libgssapi = { version = "0.11", optional = true }
# CFStringTokenizer-adjacent CoreFoundation FFI: `daemon::pane` reads a pane's
# foreground process name through it on macOS.
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
# The Windows GUI⇄daemon transport is loopback TCP, which (unlike a Unix socket)
# any local process can connect to — so the daemon authenticates each connection
# against a random token it writes into the user-private port file. `getrandom`
# is the OS CSPRNG that mints that token.
[target.'cfg(windows)'.dependencies]
getrandom = "0.3"
# Toolhelp process enumeration + `TerminateProcess`, used by `daemon::winproc` to
# title a pane by its foreground command and to tear down a shell's descendant
# tree on hangup (ConPTY's `kill` only reaches the shell itself).
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Console",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Threading",
] }
[dev-dependencies]
# Sandboxes for the `host::conformance` suite: a fresh empty directory per case,
# removed on drop.
tempfile = "3"
# `test-util` unlocks tokio's paused clock (`start_paused`) so the ssh prompt
# broker's timeout/retry tests run instantly instead of in real time.
tokio = { version = "1", features = ["test-util", "macros", "rt"] }
# `gssapi` — GSSAPI/Kerberos `gssapi-with-mic` SSH auth (`daemon::ssh::auth`).
#
# Off by default, and the `tty7` GUI turns it on, so the GUI's behavior is
# unchanged. It has to be optional because `libgssapi` binds the *system* MIT /
# Heimdal krb5 through bindgen: a machine without krb5 headers cannot build it
# at all, and a static musl `tty7-server` — the binary remote workspaces push
# onto arbitrary hosts (decision D10) — cannot link it under any
# circumstances. Without the feature, `SshAuthMode::Gssapi` reports that the
# method is unavailable in this build and the other auth families are untouched.
#
# **Test coverage.** Everything in `daemon::ssh::auth` that can be tested without
# krb5 — the service-host list — is gated on `unix` alone, so a plain
# `cargo test -p tty7-core` runs it. Only the libgssapi FFI half needs
# `--features gssapi`, and it has no unit tests (it is all foreign calls). Do not
# gate a testable helper on this feature: `cargo test --workspace` unifies it on
# from the GUI package, so a feature-gated test looks green there and silently
# vanishes the moment anyone narrows to `-p tty7-core`.
[features]
default = []
gssapi = ["dep:libgssapi"]
# Lets this build download a `tty7-server` release asset over HTTPS and push it
# onto a remote machine. Off by default so `tty7-server` — which is
# the thing being downloaded, and never the thing doing the downloading — links
# no HTTP client. Without it, `daemon::install` still installs from bytes it is
# handed and still launches/probes a remote daemon; only the fetch fails, with a
# message saying so.
remote-install = ["dep:ureq"]
[lints]
workspace = true