diff --git a/CHANGELOG.md b/CHANGELOG.md index 13265c85..6ad4d1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,70 @@ All notable changes to the Toolpath workspace are documented here. +## Resume: remote session-persistence picker + transport seam — 2026-07-24 + +- **`path-cli`** (0.18.0): `path resume --remote` gains `--persist + `, generalizing the previous `--tmux`-only detach story into a + picker over six backends: `plain`, `tmux`, `abduco`, `dtach`, `zellij`, + `shpool`. Three launch mechanisms: direct-wrap (`plain`/`tmux`/`abduco`/ + `dtach` — the harness command is wrapped inline), layout-wrap (`zellij` + — a generated KDL layout drives the launch), and attach-only (`shpool` + — a persistent remote shell; resume prints the attach command instead + of launching it directly). The remote is probed for installed backends + (mirroring the harness picker); `--persist X` skips the picker; a + non-TTY invocation auto-picks the best available backend (tmux > + zellij > abduco > dtach > plain). `--tmux` is now a **deprecated + alias** for `--persist tmux` — combining both flags is an error. + - New `--via ssh|mosh|et` transport axis carries the persist-wrapped + launch command; `ssh` is implemented (the prior default behavior), + `mosh`/`et` are reserved seams that error "not yet supported". + - Reachability over Tailscale, Cloudflare Tunnel, or a bastion hop + needs no new flag — it rides the existing `~/.ssh/config` `Host` + alias resolution already documented for `--remote`. + +## Resume: remote resume over SSH — 2026-07-23 + +- **`path-cli`** (0.17.0): `path resume` gains `--remote + ssh://[user@]host[:port]` — resume a session on a remote host. The host + resolves the Toolpath document AND projects the session fully in memory, + ships the finished harness file to the remote, and launches the harness + over an interactive `ssh -t`. The remote needs only sshd + the harness: + no `path` installed, no Pathbase access, no temp files. + - Transport is typed libssh2 calls (SFTP, with an SCP-protocol + + minimal-exec fallback for servers whose SFTP channel won't open) — no + composed remote shell strings, same ethos as `git2` over shelling out. + - Natively honors the `HostName`/`User`/`Port`/`IdentityFile` subset of + `~/.ssh/config` (Host blocks, `*`/`?`/`!` globs, first-value-wins, `~` + expansion); configured identities are matched against the SSH agent by + public-key blob, so key-pinned hosts (e.g. exe.dev, which identifies + the account *by* the key) authenticate as the right account. + - `--tmux` wraps the remote launch in `tmux new-session -A -s + path- …` for detachable sessions: detach with `ctrl-b d`, re-run + the same resume to re-attach. + - `--harness` is required with `--remote` and currently must be + `claude`; `--cwd` keys the remote project dir and the launch `cd` + (both created if missing). Bounded connect/per-op timeouts; one cached + connection per target. + - New deps: `ssh2`, `base64`. + +## Project: drop empty streaming-seed assistant lines — 2026-07-23 + +- **`toolpath-claude`** (0.12.2): the Claude projector no longer emits + content-empty assistant messages. Claude Code streams a message as several + JSONL lines — the first an empty "seed" (`text: ""`) superseded by the + real-text line, both sharing one `message.id`. Projecting that seed as + `content:[{"type":"text","text":""}]` produced an API-invalid message: on the + next turn of a *resumed* session Claude replays the whole transcript and + Anthropic rejects the empty text block with `400 … text content blocks must + be non-empty`, so the resumed session couldn't take a second turn. The + projector now skips any assistant turn with no text, thinking, tool-uses, + delegations, or file-mutations and re-links the following turn's `parentUuid` + to the dropped seed's parent, keeping the uuid chain intact. The group's + token total is re-expanded onto the surviving line as before. +- **`toolpath-convo`** (0.12.0): new `Turn::is_content_empty()` — the + "no renderable content of its own" predicate the seed-drop check calls + instead of an inline conjunction. + ## One artifact-type layer and per-session imports — 2026-07-16 Groundwork for a cache that fills itself: one enum for artifact diff --git a/CLAUDE.md b/CLAUDE.md index d75497af..9ce38082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,6 +274,6 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. -- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. +- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. With `--remote ssh://[user@]host[:port]` the resume happens on a remote host instead: the host projects the session fully in memory and ships the finished JSONL over libssh2 (SFTP, SCP fallback) into the remote's Claude layout, then execs an interactive `ssh -t … claude -r ` — the remote needs only sshd + the harness (no `path`, no Pathbase). The transport honors the `HostName`/`User`/`Port`/`IdentityFile` subset of `~/.ssh/config` and matches identities against the agent by public-key blob. `--tmux` wraps the remote launch in `tmux new-session -A -s path-` for detachable sessions (re-run the same resume to re-attach). `--remote` requires `--harness` and currently supports only `claude`. `--persist ` generalizes `--tmux` (now a deprecated alias for `--persist tmux`) into a six-backend picker (`plain`/`tmux`/`abduco`/`dtach`/`zellij`/`shpool`) across three launch mechanisms — direct-wrap, zellij's layout-wrap, and shpool's attach-only (prints the attach command); the remote is probed for installed backends and a non-TTY invocation auto-picks the best available one. `--via ssh|mosh|et` selects the transport carrying the launch (`ssh` implemented; `mosh`/`et` reserved). Tailscale/Cloudflare Tunnel/bastion reachability needs no flag — point `--remote` at a `~/.ssh/config` `Host` alias. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources. It derives `clap::ValueEnum` and is used by `ArtifactRow.artifact_type` and `cmd_import`'s cache-id prefixes (`name()` is the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.lock b/Cargo.lock index 6cdb1e62..573206d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,10 +2441,11 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.16.0" +version = "0.18.0" dependencies = [ "anyhow", "assert_cmd", + "base64", "chrono", "clap", "git2", @@ -2465,6 +2466,7 @@ dependencies = [ "sha2", "similar", "skim", + "ssh2", "tempfile", "tokio", "toolpath", @@ -3727,6 +3729,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libssh2-sys", + "parking_lot", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4073,7 +4087,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.1" +version = "0.12.2" dependencies = [ "anyhow", "chrono", @@ -4104,7 +4118,7 @@ dependencies = [ [[package]] name = "toolpath-convo" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "jsonschema", diff --git a/Cargo.toml b/Cargo.toml index 23223581..288509f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,9 +25,9 @@ license = "Apache-2.0" [workspace.dependencies] toolpath = { version = "0.7.0", path = "crates/toolpath" } -toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } +toolpath-convo = { version = "0.12.0", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.12.1", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.16.0", path = "crates/path-cli" } +path-cli = { version = "0.18.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } @@ -54,6 +54,8 @@ similar = "2" tempfile = "3.15" insta = "1" rusqlite = { version = "0.32", features = ["bundled"] } +ssh2 = "0.9" +base64 = "0.22" uuid = { version = "1", features = ["serde"] } [profile.wasm] diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 575c972f..7d931b6f 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.16.0" +version = "0.18.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" @@ -56,6 +56,8 @@ git2 = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } rusqlite = { workspace = true } +ssh2 = { workspace = true } +base64 = { workspace = true } uuid = { workspace = true, features = ["v4"] } # Embedded fuzzy picker — used when external `fzf` isn't on PATH. # Disable default features to avoid pulling in skim's CLI deps diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index b1a4a5cf..cc1fa9f3 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -299,6 +299,47 @@ pub(crate) struct PathbaseUploadArgs { // projected session id. They are called by `path resume`; the existing // `run_` functions are untouched. +/// Project `path` into a Claude session entirely in memory, returning +/// `(session_id, jsonl)` without writing anything. The id is +/// deterministic — the projector takes it verbatim from the document's +/// conversation view — and the JSONL is byte-identical to what +/// `p export claude` would write. Used by `path resume --remote` to +/// ship a ready-to-resume session file to a host with no `path` +/// installed. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn claude_session_jsonl(path: &toolpath::v1::Path) -> Result<(String, String)> { + let conv = build_claude_conversation(path)?; + validate_session_id(&conv.session_id)?; + let jsonl = serialize_jsonl(&conv)?; + Ok((conv.session_id, jsonl)) +} + +/// Reject session ids that aren't safe as a single filesystem path +/// component. The id becomes `/.jsonl` both locally and (over +/// SFTP) on a remote host, and `path resume` accepts third-party +/// Pathbase documents — so an id like `../../../.ssh/authorized_keys` +/// from a malicious doc would otherwise let the write escape the +/// session directory. Every real Claude session id is a UUID; this +/// allows the UUID charset plus a conservative margin. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn validate_session_id(id: &str) -> Result<()> { + if id.is_empty() { + anyhow::bail!("document projects to an empty Claude session id"); + } + if id.len() > 128 + || id.starts_with('.') + || !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) + { + anyhow::bail!( + "refusing session id `{id}`: it isn't a safe filename component \ + (expected UUID-like `[A-Za-z0-9_-]`, ≤128 chars, no leading dot)" + ); + } + Ok(()) +} + /// Project `path` into a Claude session under `project_dir` and return /// the resulting session id. #[cfg(not(target_os = "emscripten"))] @@ -307,6 +348,7 @@ pub(crate) fn project_claude( project_dir: &std::path::Path, ) -> Result { let conv = build_claude_conversation(path)?; + validate_session_id(&conv.session_id)?; let jsonl = serialize_jsonl(&conv)?; write_into_claude_project(&conv, &jsonl, project_dir)?; Ok(conv.session_id) @@ -693,6 +735,9 @@ fn write_into_claude_project( jsonl: &str, project_dir: &std::path::Path, ) -> Result { + // Chokepoint for every local write — the id becomes a filename, so + // reject path-traversal ids here (covers `run_claude` too). + validate_session_id(&conv.session_id)?; let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; let project_path = project_dir.to_string_lossy(); diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 163634fe..2a50bdfb 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -35,6 +35,93 @@ //! integration tests use [`RecordingExec`] to capture //! `(binary, args, cwd)` without launching anything. //! +//! ## Remote (`--remote ssh://[user@]host[:port][/path]` | `[user@]host[:port]` | config alias) +//! +//! v3 (host projects, remote just receives files): the **host** resolves +//! the document AND projects the session fully in memory, then ships the +//! finished harness file to the remote over SFTP and launches the +//! harness — so the remote needs only SSH and the harness installed. +//! **No `path` on the remote**, no Pathbase access, no temp files, and +//! no composed remote shell strings: the file operations are typed SFTP +//! calls via libssh2 (matching the repo's `git2`-over-shelling-out +//! ethos). Steps (`run_remote`): +//! +//! 1. **Resolve + project on the host** — same `resolve_input` / +//! `ensure_path_with_agent` as a local resume, so a bad or non-agent +//! document fails fast on the host, not deep inside SSH. The session +//! id and JSONL come from the same in-memory projection +//! (`cmd_export::claude_session_jsonl`). +//! 2. **Preflight** — resolve the remote home over SFTP +//! ([`ExecStrategy::remote_home`]). First remote touch, so +//! reachability/auth failures abort here rather than dropping the +//! user into a doomed session; the home anchors the Claude layout +//! and the default launch dir. +//! 3. **Ship** — SFTP `mkdir -p` + write of +//! `/.claude/projects//.jsonl`, where `` is the +//! launch cwd run through Claude Code's own project-dir sanitization +//! so `claude -r` started there finds the session. A pinned `--cwd` +//! is also created over SFTP. +//! 4. **Launch** — `execvp` an interactive `ssh -t host '[cd && ] +//! claude -r '`. The `-t` gives the remote harness a real +//! terminal; this is the one step that stays on the real `ssh` +//! binary (it needs the user's TTY and full ssh config). The +//! transport honors the `HostName`/`User`/`Port`/`IdentityFile` +//! subset of `~/.ssh/config` (natively parsed — see +//! `parse_ssh_config`) with URL values winning; configured +//! identities are matched against the agent by public-key blob, so +//! key-pinned hosts (exe.dev-style, where the key IS the identity) +//! authenticate as the right account. `known_hosts` is still not +//! checked on the transport connection. +//! +//! `--harness` is required with `--remote` (and currently must be +//! `claude` — the projection and layout knowledge are Claude-specific). +//! The resolution-only flags (`--no-cache`/`--force`/`--url`) act on the +//! host and are never forwarded. `--cwd` does double duty as the +//! project-dir key for the shipped file and the launch's `cd` target; +//! absent, both default to the remote's ssh cwd (`$HOME`). +//! +//! ## Session persistence (`--persist `) +//! +//! `--remote` resumes can survive an SSH disconnect by wrapping the +//! remote launch in a session multiplexer, chosen from six backends +//! ([`PersistBackend`]): `plain` (no wrapping — dies on disconnect, +//! always offered), `tmux`, `abduco`, `dtach`, `zellij`, `shpool`. Each +//! backend maps to one of three launch mechanisms: +//! 1. **Direct-wrap** (`plain`, `tmux`, `abduco`, `dtach`) — the harness +//! command is wrapped inline (e.g. `tmux new-session -A -s path- +//! …`); detach/re-run to re-attach. +//! 2. **Layout-wrap** (`zellij`) — a generated KDL layout drives the +//! launch so the harness starts inside a named zellij session/pane. +//! 3. **Attach-only** (`shpool`) — shpool keeps a persistent remote shell +//! running the harness; resume prints the `shpool attach` command for +//! the user to run rather than launching it directly. +//! +//! Without `--persist`, a non-TTY invocation picks the best available +//! backend automatically ([`preferred_backend`]: tmux > zellij > abduco > +//! dtach > plain); an interactive TTY shows a picker built from +//! [`persist_candidates`] — the remote is probed for installed backends +//! ([`ExecStrategy::remote_which`]) and only those (plus `plain`) are +//! offered. `--persist X` skips the picker. `--tmux` is a **deprecated +//! alias** for `--persist tmux` ([`resolve_persist_flag`]); combining +//! both flags is an error. +//! +//! ## Transport (`--via ssh|mosh|et`) +//! +//! `--via` selects the transport that carries the persist-wrapped +//! remote command for the interactive launch ([`launch_invocation`]). +//! `ssh` (the default) is implemented; `mosh` and `et` are reserved +//! seams that currently error with "not yet supported" — the type is +//! wired through so a future transport only needs a new match arm. +//! +//! ## Reachability +//! +//! Tailscale, Cloudflare Tunnel, and bastion-hop targets need no +//! dedicated flag: point `--remote` at a `~/.ssh/config` `Host` alias +//! (e.g. `path resume … --remote my-tailnet-box`) and the existing +//! `HostName`/`User`/`Port`/`IdentityFile` resolution (see "Remote" +//! above) does the rest, since those tools all work by rewriting what +//! `ssh ` resolves to. +//! //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` //! for the full design. @@ -80,15 +167,62 @@ pub struct ResumeArgs { /// then `$PATHBASE_URL`, then `https://pathbase.dev`. #[arg(long)] pub url: Option, + + /// Resume on a remote host over SSH instead of locally. Takes a full + /// SSH URL (`ssh://[user@]host[:port][/path]`), a bare + /// `[user@]host[:port]`, or a `~/.ssh/config` Host alias (its + /// HostName/User/Port/IdentityFile are resolved from the config). When + /// set, the resume is dispatched to the remote host rather than + /// exec'ing a local harness. + #[arg(long)] + pub remote: Option, + + /// Wrap the remote launch in a named tmux session + /// (`tmux new-session -A -s path- …`) so it survives SSH + /// disconnects and can be detached/re-attached; re-running the same + /// resume re-attaches. Requires --remote and tmux on the remote. + #[arg(long, requires = "remote")] + pub tmux: bool, + + /// Remote session-persistence backend. Skips the picker. Requires --remote. + #[arg(long, value_enum, requires = "remote")] + pub persist: Option, + + /// Transport for the interactive launch. ssh (default); mosh/et reserved. + #[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")] + pub via: Transport, +} + +/// Resolve the effective persist backend from `--tmux`/`--persist`, +/// treating `--tmux` as a deprecated alias for `--persist tmux`. +/// Errors if both are set. +fn resolve_persist_flag(args: &ResumeArgs) -> Result> { + match (args.tmux, args.persist) { + (true, Some(_)) => anyhow::bail!( + "--tmux is a deprecated alias for --persist tmux; don't combine it with --persist" + ), + (true, None) => { + eprintln!("note: --tmux is deprecated; use --persist tmux"); + Ok(Some(PersistBackend::Tmux)) + } + (false, p) => Ok(p), + } } pub fn run(args: ResumeArgs) -> Result<()> { - run_with_strategy(args, &RealExec) + run_with_strategy(args, &RealExec::default()) } /// Internal entry point that the integration tests call with a /// `RecordingExec` strategy. Production callers use [`run`]. pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { + // Remote resume: resolve + project here, ship the finished session + // file to the remote (no `path` needed there), and launch the harness + // over an interactive SSH. See the module docs' "Remote" section. + if let Some(remote) = args.remote.as_deref() { + return run_remote(&args, remote, exec); + } + let (graph, source_harness) = resolve_input(&args)?; let path = ensure_path_with_agent(&graph)?; @@ -426,159 +560,1898 @@ pub(crate) fn invocation_for( if harness == Harness::Cursor { return cursor_invocation(cwd); } - (harness.name().to_string(), argv_for(harness, session_id)) -} + (harness.name().to_string(), argv_for(harness, session_id)) +} + +fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec) { + let workspace = cwd.to_string_lossy().into_owned(); + if binary_on_path("cursor", None) { + ("cursor".to_string(), vec![workspace]) + } else { + #[cfg(target_os = "macos")] + { + ( + "open".to_string(), + vec!["-a".into(), "Cursor".into(), workspace], + ) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + ("xdg-open".to_string(), vec![workspace]) + } + #[cfg(not(unix))] + { + ("cursor".to_string(), vec![workspace]) + } + } +} + +/// Project a Path into the chosen harness's on-disk layout under `cwd`, +/// returning the projected session id. +pub(crate) fn project_into_harness( + path: &TPath, + harness: Harness, + cwd: &std::path::Path, +) -> Result { + match harness { + Harness::Claude => crate::cmd_export::project_claude(path, cwd), + Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), + Harness::Codex => crate::cmd_export::project_codex(path, cwd), + Harness::Copilot => crate::cmd_export::project_copilot(path, cwd), + Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), + Harness::Cursor => crate::cmd_export::project_cursor(path, cwd), + Harness::Pi => crate::cmd_export::project_pi(path, cwd), + } +} + +/// What `exec_harness` saw (for tests). +#[derive(Debug, Clone, Default)] +pub struct CapturedExec { + pub binary: String, + pub args: Vec, + pub cwd: std::path::PathBuf, +} + +/// A parsed SSH remote: `ssh://[user@]host[:port][/path]`, a bare +/// `[user@]host[:port]`, or a `~/.ssh/config` Host alias in the `host` slot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshTarget { + /// Login user; falls back to `$USER` at connect time when absent. + pub user: Option, + pub host: String, + /// Explicit port from the URL; SSH's default 22 when absent. + pub port: Option, +} + +/// Pluggable exec + remote-transport backend. Production uses +/// `RealExec` (`execvp` on Unix, spawn-and-wait on Windows; SFTP via +/// libssh2 for the remote file operations — typed calls, not composed +/// shell strings). Tests use `RecordingExec`. +pub trait ExecStrategy { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>; + + /// Resolve the remote login home directory. Doubles as the + /// reachability/auth preflight: it's the first remote touch, so a + /// bad host, port, or key fails here with context. + fn remote_home(&self, target: &SshTarget) -> Result; + + /// Create `dir` (and any missing parents) on the remote — + /// `mkdir -p` semantics, existing directories are fine. + fn remote_mkdirs(&self, target: &SshTarget, dir: &str) -> Result<()>; + + /// Write `data` to the absolute remote `path`, truncating any + /// existing file. Parent directories must already exist. + fn remote_write(&self, target: &SshTarget, path: &str, data: &[u8]) -> Result<()>; + + /// Which of `bins` exist on the remote (`command -v`). One exec channel. + fn remote_which( + &self, + target: &SshTarget, + bins: &[&str], + ) -> Result>; +} + +/// Production implementation. On Unix this never returns on success +/// (the current process is replaced); on Windows it spawns the child, +/// waits, and propagates the exit code. +/// +/// The remote-transport methods share one authenticated SSH connection +/// (cached across calls) and prefer SFTP; when the server won't open an +/// SFTP channel (some custom sshds don't, e.g. exe.dev VMs), they fall +/// back to the SCP protocol for writes and a minimal exec channel for +/// `pwd`/`mkdir -p` — all still through libssh2, no external binaries. +#[derive(Default)] +pub struct RealExec { + conn: std::sync::Mutex>, +} + +/// A live authenticated session plus the SFTP channel if the server +/// offers one (`None` ⇒ SCP/exec fallback). +struct RemoteConn { + key: SshTarget, + sess: ssh2::Session, + sftp: Option, +} + +impl ExecStrategy for RealExec { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { + let mut cmd = std::process::Command::new(binary); + cmd.args(args); + cmd.current_dir(cwd); + + eprintln!( + "Resuming: {} {} (cwd: {})", + binary, + args.join(" "), + cwd.display() + ); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // exec only returns if it fails. + let err = cmd.exec(); + anyhow::bail!( + "couldn't exec `{}`: {}. Recipe: {} {} (run from {})", + binary, + err, + binary, + args.join(" "), + cwd.display() + ); + } + #[cfg(not(unix))] + { + let status = cmd + .spawn() + .with_context(|| format!("spawn {}", binary))? + .wait() + .with_context(|| format!("wait for {}", binary))?; + std::process::exit(status.code().unwrap_or(1)); + } + } + + fn remote_home(&self, target: &SshTarget) -> Result { + self.with_conn(target, |conn| match &conn.sftp { + Some(sftp) => { + let home = sftp + .realpath(std::path::Path::new(".")) + .context("resolve remote home directory")?; + validate_remote_home(&home.to_string_lossy()) + } + None => { + let out = exec_channel_capture(&conn.sess, "pwd")?; + validate_remote_home(&out) + } + }) + } + + fn remote_mkdirs(&self, target: &SshTarget, dir: &str) -> Result<()> { + self.with_conn(target, |conn| match &conn.sftp { + Some(sftp) => { + // Walk the components, creating as we go — `mkdir -p` + // semantics. + let mut cur = std::path::PathBuf::new(); + for comp in std::path::Path::new(dir).components() { + cur.push(comp); + if cur.parent().is_none() { + continue; // skip the root component + } + if sftp.stat(&cur).is_ok() { + continue; // already exists + } + sftp.mkdir(&cur, 0o755) + .with_context(|| format!("create remote dir {}", cur.display()))?; + } + Ok(()) + } + None => { + exec_channel_capture(&conn.sess, &format!("mkdir -p {}", shell_single_quote(dir))) + .with_context(|| format!("create remote dir {dir}"))?; + Ok(()) + } + }) + } + + fn remote_write(&self, target: &SshTarget, path: &str, data: &[u8]) -> Result<()> { + use std::io::Write; + self.with_conn(target, |conn| match &conn.sftp { + Some(sftp) => { + let mut f = sftp + .create(std::path::Path::new(path)) + .with_context(|| format!("create remote file {path}"))?; + f.write_all(data) + .with_context(|| format!("write remote file {path}"))?; + Ok(()) + } + None => { + // SCP protocol upload — libssh2's scp_send, no external + // binary. + let mut ch = conn + .sess + .scp_send(std::path::Path::new(path), 0o644, data.len() as u64, None) + .with_context(|| format!("open SCP upload to {path}"))?; + ch.write_all(data) + .with_context(|| format!("SCP write to {path}"))?; + ch.send_eof().context("SCP finish (eof)")?; + ch.wait_eof().context("SCP finish (wait eof)")?; + ch.close().context("SCP close")?; + ch.wait_close().context("SCP close (wait)")?; + Ok(()) + } + }) + } + + fn remote_which( + &self, + target: &SshTarget, + bins: &[&str], + ) -> Result> { + if bins.is_empty() { + return Ok(Default::default()); + } + let probe = bins + .iter() + .map(|b| { + format!( + "command -v {} >/dev/null 2>&1 && echo {}", + shell_single_quote(b), + shell_single_quote(b) + ) + }) + .collect::>() + .join("; "); + self.with_conn(target, |conn| { + let out = exec_channel_capture(&conn.sess, &probe)?; + Ok(out + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) + }) + } +} + +impl RealExec { + /// Run `f` against the cached connection for `target`, dialing (and + /// probing for SFTP support) on first use or when the target + /// changed. + fn with_conn( + &self, + target: &SshTarget, + f: impl FnOnce(&RemoteConn) -> Result, + ) -> Result { + let mut guard = self.conn.lock().unwrap(); + if guard.as_ref().is_none_or(|c| &c.key != target) { + *guard = Some(connect_remote(target)?); + } + f(guard.as_ref().expect("connection just established")) + } +} + +/// Run a one-shot command over a libssh2 exec channel and return its +/// stdout. Used only on servers without an SFTP subsystem, and only for +/// `pwd` / `mkdir -p`. +fn exec_channel_capture(sess: &ssh2::Session, cmd: &str) -> Result { + use std::io::Read; + let mut ch = sess.channel_session().context("open exec channel")?; + ch.exec(cmd).with_context(|| format!("run `{cmd}`"))?; + let mut out = String::new(); + ch.read_to_string(&mut out) + .with_context(|| format!("read `{cmd}` output"))?; + let mut err = String::new(); + ch.stderr().read_to_string(&mut err).ok(); + ch.wait_close().ok(); + let status = ch.exit_status().unwrap_or(-1); + // Some minimal sshds report a bogus nonzero exit even on success — + // trust actual output over the status code when there is any. + if status != 0 && out.trim().is_empty() { + anyhow::bail!( + "`{cmd}` exited {status}{}", + if err.trim().is_empty() { + String::new() + } else { + format!(": {}", err.trim()) + } + ); + } + Ok(out) +} + +/// Validate that resolving the remote home produced a real absolute path, +/// not an error banner. Some sshds authenticate a key at the transport +/// layer but then answer every command with a notice — e.g. exe.dev +/// replies `Please complete registration by running: ssh exe.dev` for a +/// key not yet bound to an account. Without this check that banner gets +/// spliced into the remote session path (`~/.claude/projects/Please +/// complete registration…/`), which fails deep inside the file ship with +/// an inscrutable error. Require a single-line absolute path so the +/// failure is caught early with the offending output shown verbatim. +fn validate_remote_home(raw: &str) -> Result { + let home = raw.trim(); + if home.is_empty() { + anyhow::bail!("remote home lookup returned nothing"); + } + if !home.starts_with('/') || home.contains(['\n', '\r']) { + anyhow::bail!( + "remote home lookup returned an unexpected value (not an absolute path): {home:?}. \ + This often means the SSH key authenticated but the host doesn't recognize it — \ + e.g. an unregistered key on a key-identified host. Register/load the right key and retry." + ); + } + Ok(home.to_string()) +} + +/// The subset of `~/.ssh/config` the transport honors for a host: +/// `HostName`, `User`, `Port`, `IdentityFile`. Parsed natively — +/// libssh2 reads no config at all, and without this, agent auth picks +/// whatever key happens to be first (which servers like exe.dev, that +/// identify accounts *by key*, then misroute). +#[derive(Debug, Default, PartialEq, Eq)] +struct SshHostConfig { + host_name: Option, + user: Option, + port: Option, + identity_files: Vec, + /// `IdentitiesOnly yes` — when set (or when any `IdentityFile` is + /// configured), auth must use only the configured keys and must not + /// fall back to trying every agent key. + identities_only: bool, +} + +/// Load [`SshHostConfig`] for `host` from `~/.ssh/config` (empty config +/// when the file or `$HOME` is missing). +fn ssh_host_config(host: &str) -> SshHostConfig { + let Some(home) = std::env::var_os("HOME").map(std::path::PathBuf::from) else { + return SshHostConfig::default(); + }; + match std::fs::read_to_string(home.join(".ssh/config")) { + Ok(content) => parse_ssh_config(&content, host, &home), + Err(_) => SshHostConfig::default(), + } +} + +/// Minimal ssh_config parser: `Host` blocks with `*`/`?`/`!` patterns, +/// first-obtained-value-wins for scalars (OpenSSH semantics), +/// accumulating `IdentityFile` with `~` expansion. Directives before +/// the first `Host` line apply to every host. +fn parse_ssh_config(content: &str, host: &str, home: &std::path::Path) -> SshHostConfig { + let mut cfg = SshHostConfig::default(); + let mut active = true; // pre-Host directives are global + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (keyword, value) = match line.split_once([' ', '\t', '=']) { + // OpenSSH allows `Key = value` (whitespace around `=`); after + // splitting on the first separator, strip a leading `=` and + // surrounding whitespace off the value so `User = alice` + // yields `alice`, not `= alice`. + Some((k, v)) => ( + k.to_ascii_lowercase(), + v.trim().trim_start_matches('=').trim().trim_matches('"'), + ), + None => continue, + }; + if keyword == "host" { + let patterns: Vec<&str> = value.split_whitespace().collect(); + let negated = patterns + .iter() + .any(|p| p.strip_prefix('!').is_some_and(|p| glob_match(p, host))); + let matched = patterns + .iter() + .any(|p| !p.starts_with('!') && glob_match(p, host)); + active = matched && !negated; + continue; + } + if !active { + continue; + } + match keyword.as_str() { + "hostname" if cfg.host_name.is_none() => cfg.host_name = Some(value.to_string()), + "user" if cfg.user.is_none() => cfg.user = Some(value.to_string()), + "port" if cfg.port.is_none() => cfg.port = value.parse().ok(), + "identityfile" => { + let path = match value.strip_prefix("~/") { + Some(rest) => home.join(rest), + None => std::path::PathBuf::from(value), + }; + if !cfg.identity_files.contains(&path) { + cfg.identity_files.push(path); + } + } + "identitiesonly" if value.eq_ignore_ascii_case("yes") => { + cfg.identities_only = true; + } + _ => {} + } + } + cfg +} + +/// ssh_config-style glob: `*` matches any run, `?` a single char. +fn glob_match(pattern: &str, s: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = s.chars().collect(); + // Iterative wildcard match with backtracking on the last `*`. + let (mut pi, mut ti) = (0usize, 0usize); + let (mut star, mut mark) = (None::, 0usize); + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(sp) = star { + pi = sp + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +/// Verify the connected server's host key against `~/.ssh/known_hosts` +/// (TOFU baseline, matching what the interactive `ssh` launch does). +/// Aborts on a mismatch (possible MITM). A not-yet-known host is +/// accepted with a warning — first-contact, like ssh's default +/// `StrictHostKeyChecking accept-new` — because the transport ships +/// bytes before the interactive step can prompt. A missing/unreadable +/// known_hosts file is non-fatal (nothing to check against). +fn check_known_host(sess: &ssh2::Session, host: &str, port: u16) -> Result<()> { + let Some((key, _)) = sess.host_key() else { + anyhow::bail!("remote {host}:{port} presented no host key"); + }; + let mut known = sess.known_hosts().context("open known_hosts")?; + if let Some(home) = std::env::var_os("HOME").map(std::path::PathBuf::from) { + // Best-effort read; absence is handled by the check below. + let _ = known.read_file( + &home.join(".ssh/known_hosts"), + ssh2::KnownHostFileKind::OpenSSH, + ); + } + use ssh2::CheckResult; + match known.check_port(host, port, key) { + CheckResult::Match => Ok(()), + CheckResult::Mismatch => anyhow::bail!( + "host key for {host}:{port} does NOT match ~/.ssh/known_hosts — \ + possible man-in-the-middle; refusing to ship the session" + ), + CheckResult::NotFound | CheckResult::Failure => { + eprintln!( + "note: {host}:{port} is not in ~/.ssh/known_hosts — accepting on first contact" + ); + Ok(()) + } + } +} + +/// Authenticate `sess` as `user`, honoring configured identities: for +/// each `IdentityFile`, first look for the matching key in the agent +/// (by public-key blob — works for passphrase-protected keys), then try +/// the key file directly. Falls back to plain agent auth (any loaded +/// key) ONLY when no identity was configured; when identities are +/// pinned, trying an arbitrary agent key could misroute on hosts that +/// identify the account by key (e.g. exe.dev), so a pinned-but-failed +/// auth errors instead. +fn authenticate(sess: &ssh2::Session, user: &str, cfg: &SshHostConfig, addr: &str) -> Result<()> { + for key_path in &cfg.identity_files { + if agent_auth_with_key(sess, user, key_path)? { + return Ok(()); + } + if sess + .userauth_pubkey_file(user, None, key_path, None) + .is_ok() + { + return Ok(()); + } + } + // Pinned identities that all failed must not silently degrade to + // "try every agent key" — that's the exact misroute the config + // parsing exists to prevent. + if !cfg.identity_files.is_empty() || cfg.identities_only { + anyhow::bail!( + "SSH auth as `{user}` on {addr} failed with the configured IdentityFile(s); \ + none matched a loaded agent key or were usable directly. Load the pinned key \ + (`ssh-add `) — refusing to fall back to an arbitrary agent key." + ); + } + sess.userauth_agent(user).with_context(|| { + format!("SSH agent auth as `{user}` on {addr} — is the key loaded (`ssh-add`)?") + }) +} + +/// Try agent auth with the specific key whose public half sits next to +/// `key_path` (`.pub`). Returns Ok(false) when the pub file or a +/// matching agent identity isn't there — callers fall through. +fn agent_auth_with_key( + sess: &ssh2::Session, + user: &str, + key_path: &std::path::Path, +) -> Result { + use base64::Engine as _; + let pub_path = std::path::PathBuf::from(format!("{}.pub", key_path.display())); + let Ok(line) = std::fs::read_to_string(&pub_path) else { + return Ok(false); + }; + let Some(b64) = line.split_whitespace().nth(1) else { + return Ok(false); + }; + let Ok(blob) = base64::engine::general_purpose::STANDARD.decode(b64) else { + return Ok(false); + }; + let mut agent = sess.agent().context("open SSH agent")?; + if agent.connect().is_err() { + return Ok(false); // no agent running — try the key file instead + } + agent.list_identities().context("list agent identities")?; + for identity in agent.identities().context("read agent identities")? { + if identity.blob() == blob.as_slice() { + agent + .userauth(user, &identity) + .with_context(|| format!("agent auth with {}", pub_path.display()))?; + return Ok(true); + } + } + Ok(false) +} + +/// Dial + authenticate a session to `target` and probe for SFTP: TCP +/// connect (bounded), handshake, config-aware auth (see +/// [`authenticate`]), then one SFTP-open attempt — servers without the +/// subsystem (e.g. exe.dev VMs) get the SCP/exec fallback instead. +/// +/// `~/.ssh/config` fills the gaps the URL leaves open (HostName, User, +/// Port, IdentityFile); URL values win. known_hosts is still not +/// checked — the interactive launch goes through the real `ssh` binary +/// with the user's full config. +fn connect_remote(target: &SshTarget) -> Result { + use std::net::ToSocketAddrs; + let cfg = ssh_host_config(&target.host); + let host = cfg.host_name.clone().unwrap_or_else(|| target.host.clone()); + let port = target.port.or(cfg.port).unwrap_or(22); + let addr = format!("{host}:{port}"); + // Bounded connect + per-operation timeouts: a wedged remote should + // fail with context, never hang the resume silently. + let sock = addr + .to_socket_addrs() + .with_context(|| format!("resolve {addr}"))? + .next() + .with_context(|| format!("no address for {addr}"))?; + let tcp = std::net::TcpStream::connect_timeout(&sock, std::time::Duration::from_secs(10)) + .with_context(|| format!("connect to {addr}"))?; + let mut sess = ssh2::Session::new().context("create SSH session")?; + sess.set_tcp_stream(tcp); + sess.set_timeout(30_000); // ms; applies to handshake/auth/channel ops + sess.handshake() + .with_context(|| format!("SSH handshake with {addr}"))?; + // Verify the server host key against ~/.ssh/known_hosts BEFORE + // authenticating or shipping any bytes: the transport uploads the + // full session transcript over this channel ahead of the + // interactive `ssh` launch (which does its own check), so a + // known_hosts mismatch must abort here, not after the leak. + check_known_host(&sess, &host, port)?; + let user = match target.user.clone().or_else(|| cfg.user.clone()) { + Some(u) => u, + None => { + std::env::var("USER").context("no SSH user: put `user@` in the URL or set $USER")? + } + }; + authenticate(&sess, &user, &cfg, &addr)?; + + // SFTP probe: keep it brief — a server without the subsystem may + // just sit on the channel request until a timeout. + sess.set_timeout(5_000); + let sftp = sess.sftp().ok(); + sess.set_timeout(30_000); + if sftp.is_none() { + eprintln!("note: remote has no SFTP subsystem — using SCP fallback"); + } + + Ok(RemoteConn { + key: target.clone(), + sess, + sftp, + }) +} + +/// Recording strategy for tests. `captured()` returns the most recent +/// invocation; the remote-transport calls are recorded as typed values +/// (targets, dirs, `(path, contents)` pairs) instead of shell strings. +#[derive(Default)] +pub struct RecordingExec { + inner: std::sync::Mutex, + homes: std::sync::Mutex>, + mkdirs: std::sync::Mutex>, + /// Written files: (remote path, contents as UTF-8 string). + writes: std::sync::Mutex>, + /// When true, `remote_home` returns an error — simulates an + /// unreachable or unauthenticated remote. + home_fails: bool, + /// Canned set of binaries `remote_which` reports as present. + available: std::collections::BTreeSet, +} + +impl RecordingExec { + /// A recorder whose remote preflight fails, for exercising the + /// abort-before-dispatch path. + pub fn failing_remote() -> Self { + Self { + home_fails: true, + ..Default::default() + } + } + + /// A recorder whose `remote_which` reports exactly `bins` as present. + pub fn with_available<'a>(bins: impl IntoIterator) -> Self { + Self { + available: bins.into_iter().map(String::from).collect(), + ..Default::default() + } + } + + pub fn captured(&self) -> CapturedExec { + self.inner.lock().unwrap().clone() + } + + /// Every `remote_home` (preflight) call, in call order. + pub fn homes(&self) -> Vec { + self.homes.lock().unwrap().clone() + } + + /// Every `remote_mkdirs` call, in call order. + pub fn mkdirs(&self) -> Vec { + self.mkdirs.lock().unwrap().clone() + } + + /// Every `remote_write` call as `(remote path, contents)`. + pub fn writes(&self) -> Vec<(String, String)> { + self.writes.lock().unwrap().clone() + } +} + +/// The fake remote home `RecordingExec` reports; tests key expected +/// paths off it. +pub const RECORDING_REMOTE_HOME: &str = "/home/recording"; + +impl ExecStrategy for RecordingExec { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { + let mut g = self.inner.lock().unwrap(); + *g = CapturedExec { + binary: binary.to_string(), + args: args.to_vec(), + cwd: cwd.to_path_buf(), + }; + Ok(()) + } + + fn remote_home(&self, target: &SshTarget) -> Result { + self.homes.lock().unwrap().push(target.clone()); + if self.home_fails { + anyhow::bail!("connection refused"); + } + Ok(RECORDING_REMOTE_HOME.to_string()) + } + + fn remote_mkdirs(&self, _target: &SshTarget, dir: &str) -> Result<()> { + self.mkdirs.lock().unwrap().push(dir.to_string()); + Ok(()) + } + + fn remote_write(&self, _target: &SshTarget, path: &str, data: &[u8]) -> Result<()> { + self.writes + .lock() + .unwrap() + .push((path.to_string(), String::from_utf8_lossy(data).to_string())); + Ok(()) + } + + fn remote_which( + &self, + _target: &SshTarget, + bins: &[&str], + ) -> Result> { + Ok(bins + .iter() + .filter(|b| self.available.contains(**b)) + .map(|b| b.to_string()) + .collect()) + } +} + +pub(crate) fn exec_harness( + binary: &str, + args: &[String], + cwd: &std::path::Path, + strategy: &dyn ExecStrategy, +) -> Result<()> { + strategy.exec(binary, args, cwd) +} + +/// Remote resume (v3). See the module-level "Remote" section for the +/// full design; in brief the host resolves + projects the session in +/// memory (`cmd_export::claude_session_jsonl`), preflights the +/// remote over SFTP, ships the finished Claude JSONL into the remote's +/// project layout, and `execvp`s an interactive `ssh -t … claude -r +/// `. The remote needs only sshd + the harness — no `path`, no +/// Pathbase, no temp files. +fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Result<()> { + // The remote's interactive picker can't run from here, so pin the + // harness explicitly. Only Claude is wired up so far: the host-side + // projection and layout knowledge are Claude-specific. + match args.harness { + None => anyhow::bail!( + "--remote requires --harness : the host can't run the remote's \ + harness picker, so the target must be pinned" + ), + Some(Harness::Claude) => {} + Some(_) => anyhow::bail!("remote resume currently supports only --harness claude"), + } + + // Validate --via before any remote side effects — mosh/et are reserved + // seams that always error; fail here rather than after shipping files + // and printing persistence notes. + match args.via { + Transport::Ssh => {} + other => { + let _ = launch_invocation(other, remote, "")?; + } + } + + // 1. Resolve + validate locally so a bad document fails on the host, + // not deep inside an SSH session — and project the session fully + // in memory: the remote never sees the toolpath document, only the + // finished harness file. + let (graph, _source) = resolve_input(args)?; + let path = ensure_path_with_agent(&graph)?; + let (session_id, jsonl) = crate::cmd_export::claude_session_jsonl(path)?; + let target = parse_ssh_url(remote)?; + + // 2. Preflight: resolve the remote home over SFTP. First remote + // touch, so reachability/auth failures surface here; the home also + // anchors the Claude layout and the default launch dir. + let home = exec.remote_home(&target).with_context(|| { + format!("probing remote over {remote} — is the host reachable over SSH?") + })?; + if home.is_empty() { + anyhow::bail!("remote probe over {remote} returned no home directory"); + } + eprintln!("remote {remote}: reachable (home {home})"); + + // 3. Resolve the persistence backend BEFORE shipping anything — a + // probe failure here must fall back gracefully (never abort a + // resume that previously never needed a libssh2 exec channel), and + // resolving first avoids shipping the session file only to abort + // partway through on a probe error. + let launch_cwd: Option = args + .cwd + .as_ref() + .map(|dir| normalize_remote_cwd(dir, &home)); + let backend = match resolve_persist_flag(args)? { + Some(b) => b, + None => { + let bins: Vec<&str> = PersistBackend::DISPLAY_ORDER + .iter() + .filter_map(|b| b.bin()) + .collect(); + // A probe failure (e.g. the remote's exec channel misbehaves) + // is not fatal — fall back to no known backends so plain + // resume still works. + let avail = exec.remote_which(&target, &bins).unwrap_or_else(|e| { + eprintln!( + "note: could not probe persistence backends on {remote} ({e}); continuing without persistence" + ); + Default::default() + }); + let cands = persist_candidates(&avail); + let preferred = preferred_backend(&avail); + if crate::fuzzy::available() { + pick_persist_backend(&cands, preferred)? + } else { + if preferred == PersistBackend::Plain { + eprintln!( + "note: no persistence backend found on remote; launching plain (won't survive disconnect)" + ); + } + preferred + } + } + }; + let plan = persist_plan( + Harness::Claude, + &session_id, + launch_cwd.as_deref(), + backend, + &home, + ); + + // 4. Ship: create the Claude project dir and write the projected + // JSONL over SFTP — typed file operations, no remote shell. The + // project dir is keyed on the launch cwd (--cwd, else the remote + // home) with Claude Code's own sanitization, so `claude -r` + // started there finds the session. The cwd is normalized to the + // absolute form the remote shell's `cd` will land on (trailing + // slashes / `.` / `..` / relative-to-home resolved) so the host's + // dir-name key matches what remote Claude computes from its cwd. + let project_path = launch_cwd.clone().unwrap_or_else(|| home.clone()); + let dir_name = claude_project_dir_name(&project_path); + let projects_dir = format!("{home}/.claude/projects/{dir_name}"); + exec.remote_mkdirs(&target, &projects_dir) + .with_context(|| format!("creating {projects_dir} on {remote}"))?; + let dest = format!("{projects_dir}/{session_id}.jsonl"); + exec.remote_write(&target, &dest, jsonl.as_bytes()) + .with_context(|| format!("shipping session file to {remote}:{dest}"))?; + eprintln!("Shipped session {session_id} → {remote}:{dest}"); + + // The launch cwd must exist before the interactive `cd` — create it + // over SFTP too, since resuming into a fresh directory is the normal + // case. + if let Some(dir) = launch_cwd.as_deref() { + exec.remote_mkdirs(&target, dir) + .with_context(|| format!("creating launch dir {dir} on {remote}"))?; + } + + if let Some((path, body)) = &plan.extra_file { + if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { + exec.remote_mkdirs(&target, parent) + .with_context(|| format!("creating {parent} on {remote}"))?; + } + exec.remote_write(&target, path, body) + .with_context(|| format!("shipping {path} to {remote}"))?; + } + if let Some(note) = &plan.post_note { + eprintln!("{note}"); + } + + // 5. Interactive launch of the harness against the shipped session, + // with a real TTY — the one step that stays on the real `ssh` + // binary (it needs the user's terminal and ssh config). + let (binary, argv) = launch_invocation(args.via, remote, &plan.remote_command)?; + let cwd = std::env::current_dir()?; + exec_harness(&binary, &argv, &cwd, exec) +} + +/// Interactive persistence-backend picker, mirroring [`interactive_pick`] +/// (the harness picker): present `describe()` rows via the fuzzy UI, with +/// the probed-preferred backend flagged `(recommended)`, and map the +/// picked row back to its [`PersistBackend`] by leading word. +fn pick_persist_backend( + cands: &[PersistBackend], + preferred: PersistBackend, +) -> Result { + if !crate::fuzzy::available() { + let hint = if crate::fuzzy::embedded_picker_available() { + "rerun in a terminal" + } else { + "install `fzf` (or build with the default `embedded-picker` feature) and rerun in a terminal" + }; + anyhow::bail!("interactive picker requires a TTY; pass `--persist ` or {hint}"); + } + let mut lines: Vec = Vec::with_capacity(cands.len()); + for b in cands { + let suffix = if *b == preferred { + " (recommended)" + } else { + "" + }; + lines.push(format!("{}{}", b.describe(), suffix)); + } + + let header = format!( + "pick a persistence backend (recommended: {})", + preferred.describe() + ); + let opts = crate::fuzzy::PickOptions { + with_nth: "1..", + header: Some(&header), + ..Default::default() + }; + let selected = match crate::fuzzy::pick(&lines, &opts) + .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))? + { + crate::fuzzy::PickResult::Selected(rows) => rows.into_iter().next().unwrap_or_default(), + crate::fuzzy::PickResult::Cancelled => std::process::exit(130), + crate::fuzzy::PickResult::NoMatch => { + anyhow::bail!("fzf returned no match — picker UI was empty?"); + } + }; + + let picked_word = selected.split_whitespace().next().unwrap_or_default(); + for b in cands { + let word = b.describe().split_whitespace().next().unwrap_or_default(); + if picked_word == word { + return Ok(*b); + } + } + anyhow::bail!("picker returned an unrecognized row: {selected}") +} + +/// The name Claude Code gives a project's directory under +/// `~/.claude/projects/`. Host-side mirror of `toolpath-claude`'s +/// (private) `sanitize_project_path` — kept in sync by a unit test that +/// compares against `PathResolver::project_dir`. +fn claude_project_dir_name(project_path: &str) -> String { + project_path.replace(['/', '_', '.'], "-") +} + +/// Normalize a `--cwd` to the absolute path the remote shell's `cd` +/// will land on, so the host's project-dir key matches what remote +/// Claude derives from `getcwd`. Relative paths resolve against the +/// remote `home` (where a `cd`-less ssh command starts); `.`, `..`, +/// `//`, and trailing slashes collapse. Symlinks aren't resolved (the +/// host can't see the remote FS) — an acceptable edge. +fn normalize_remote_cwd(cwd: &std::path::Path, home: &str) -> String { + let raw = cwd.to_string_lossy(); + let combined = if raw.starts_with('/') { + raw.into_owned() + } else { + format!("{home}/{raw}") + }; + let mut out: Vec<&str> = Vec::new(); + for seg in combined.split('/') { + match seg { + "" | "." => {} + ".." => { + out.pop(); + } + s => out.push(s), + } + } + format!("/{}", out.join("/")) +} + +/// The far-side launch command, derived from the same per-harness +/// invocation table the local resume uses (`name()` + [`argv_for`]) so +/// the two can't drift — prefixed with a `cd &&` when a cwd was +/// pinned so the harness starts where the shipped session is keyed. +/// `cwd` is the already-normalized absolute remote path. The directory +/// itself is created over SFTP before launch — this is the only remote +/// shell string left, and it's minimal because a `cd` can't happen +/// anywhere else. +struct PersistPlan { + remote_command: String, + extra_file: Option<(String, Vec)>, + post_note: Option, +} + +/// Reduce a session id to `[A-Za-z0-9_-]` so it's safe to embed both in a +/// multiplexer session name and in a remote file path (the zellij layout +/// file lives at `/.cache/path/zellij-.kdl`). Any character +/// outside that set — including `/` and `.` (so `..` can't traverse) — +/// becomes `-`. +fn sanitize_session_name(session_id: &str) -> String { + session_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect() +} + +fn persist_plan( + harness: Harness, + session_id: &str, + cwd: Option<&str>, + backend: PersistBackend, + home: &str, +) -> PersistPlan { + let launch: Vec = std::iter::once(harness.name().to_string()) + .chain(argv_for(harness, session_id).iter().map(|a| shell_quote(a))) + .collect(); + let inner = launch.join(" "); + let inner = match cwd { + Some(dir) => format!("cd {} && {inner}", shell_quote(dir)), + None => inner, + }; + // Detachable session names can't contain `.`/`:`, and this name also + // feeds a remote file path (the zellij layout file), so sanitize down + // to a safe charset rather than just stripping `.`/`:` — a crafted + // session_id with `/` or `..` must not be able to steer the write. + let name = format!("path-{}", sanitize_session_name(session_id)); + + let mut extra_file = None; + let mut post_note = None; + let remote_command = match backend { + PersistBackend::Plain => inner, + PersistBackend::Tmux => format!( + "tmux new-session -A -s {} {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + PersistBackend::Abduco => format!( + "abduco -A {} sh -c {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + PersistBackend::Dtach => format!( + "dtach -A {} -z sh -c {}", + shell_quote(&format!( + "/tmp/path-dtach-{}", + sanitize_session_name(session_id) + )), + shell_single_quote(&inner) + ), + PersistBackend::Zellij => zellij_plan(&name, &inner, home, &mut extra_file), + PersistBackend::Shpool => shpool_plan(&name, &inner, &mut post_note), + }; + PersistPlan { + remote_command, + extra_file, + post_note, + } +} + +fn zellij_plan( + name: &str, + inner: &str, + home: &str, + extra: &mut Option<(String, Vec)>, +) -> String { + let layout_path = format!("{home}/.cache/path/zellij-{name}.kdl"); + // Single-pane layout that runs INNER via the shell. + let kdl = format!( + "layout {{\n pane command=\"sh\" {{\n args \"-c\" {inner:?}\n }}\n}}\n" + ); + *extra = Some((layout_path.clone(), kdl.into_bytes())); + format!( + "zellij --session {} --layout {}", + shell_quote(name), + shell_quote(&layout_path) + ) +} + +fn shpool_plan(name: &str, inner: &str, note: &mut Option) -> String { + *note = Some(format!( + "shpool has no command arg — in the persistent shell, run:\n {inner}" + )); + format!("shpool attach {}", shell_quote(name)) +} + +/// Quote for the remote shell only when needed — plain flags, ids, and +/// paths stay bare so the echoed recipe reads like something a human +/// would type; anything else gets [`shell_single_quote`]. +fn shell_quote(s: &str) -> String { + let safe = !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@')); + if safe { + s.to_string() + } else { + shell_single_quote(s) + } +} + +/// Parse an SSH remote into a typed [`SshTarget`]. Accepts either a full +/// `ssh://[user@]host[:port][/path]` URL (the optional `/path` is ignored) +/// or a bare `[user@]host[:port]` — including a plain `~/.ssh/config` +/// `Host` alias, whose `HostName`/`User`/`Port`/`IdentityFile` are then +/// resolved by [`connect_remote`] (libssh2 transport) and by the `ssh` +/// binary (interactive launch). Other URL schemes are rejected explicitly. +fn parse_ssh_url(remote: &str) -> Result { + let rest = if let Some(r) = remote.strip_prefix("ssh://") { + r + } else if let Some((scheme, _)) = remote.split_once("://") { + anyhow::bail!( + "remote must be an SSH URL (ssh://…) or a host/alias, got a `{scheme}://` URL: `{remote}`" + ); + } else { + // Bare `[user@]host[:port]` or a ~/.ssh/config Host alias. + remote + }; + + // Strip an optional `/path` component; the authority is everything + // before the first slash. + let authority = match rest.find('/') { + Some(i) => &rest[..i], + None => rest, + }; + if authority.is_empty() { + anyhow::bail!("SSH URL `{remote}` is missing a host"); + } + + // Split a trailing `:port` (all-digit) off the `[user@]host` part. + let (userhost, port) = + match authority.rsplit_once(':') { + Some((uh, p)) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => ( + uh, + Some(p.parse::().with_context(|| { + format!("SSH URL `{remote}` has an out-of-range port `{p}`") + })?), + ), + _ => (authority, None), + }; + if userhost.is_empty() { + anyhow::bail!("SSH URL `{remote}` is missing a host"); + } + + let (user, host) = match userhost.split_once('@') { + Some((u, h)) if !u.is_empty() && !h.is_empty() => (Some(u.to_string()), h.to_string()), + Some(_) => anyhow::bail!("SSH URL `{remote}` has an empty user or host"), + None => (None, userhost.to_string()), + }; + + Ok(SshTarget { user, host, port }) +} + +/// Build the `ssh` invocation for the interactive launch from a full +/// SSH URL and an already-built remote command. Returns `("ssh", argv)` +/// where argv is `[-t]? [-p ]? <[user@]host> `. +/// Pass `tty = true` so the remote harness gets a real terminal. +fn ssh_invocation_tty(remote: &str, remote_cmd: &str, tty: bool) -> Result<(String, Vec)> { + let target = parse_ssh_url(remote)?; + let mut argv = Vec::new(); + if tty { + argv.push("-t".to_string()); + } + if let Some(port) = target.port { + argv.push("-p".to_string()); + argv.push(port.to_string()); + } + argv.push(match &target.user { + Some(user) => format!("{user}@{}", target.host), + None => target.host.clone(), + }); + argv.push(remote_cmd.to_string()); + Ok(("ssh".to_string(), argv)) +} + +/// Single-quote a string for safe interpolation into the remote shell +/// command, escaping any embedded single quotes. +fn shell_single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// Assemble candidate persist backends for the remote persistence picker. +/// Returns backends from DISPLAY_ORDER filtered to those whose bin() is +/// in the available set, plus Plain which is always offered. +fn persist_candidates(available: &std::collections::BTreeSet) -> Vec { + PersistBackend::DISPLAY_ORDER + .into_iter() + .filter(|b| match b.bin() { + None => true, // Plain always offered + Some(bin) => available.contains(bin), + }) + .collect() +} + +/// Pick the preferred backend from the available set. Priority: +/// [Tmux, Zellij, Abduco, Dtach], falling back to Plain if none are available. +fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend { + const PRIORITY: [PersistBackend; 4] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + ]; + PRIORITY + .into_iter() + .find(|b| b.bin().is_some_and(|bin| available.contains(bin))) + .unwrap_or(PersistBackend::Plain) +} + +fn looks_like_pathbase_shorthand(s: &str) -> bool { + // Three non-empty slash-separated segments, none containing whitespace + // or starting with a dot/slash (which would indicate a relative or + // absolute path). + if s.starts_with('.') || s.starts_with('/') { + return false; + } + let segs: Vec<&str> = s.split('/').collect(); + segs.len() == 3 + && segs + .iter() + .all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) +} + +/// Transport protocol for remote resume. v1 implements only SSH; mosh and ET +/// are reserved for future use. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Transport { + Ssh, + Mosh, + Et, +} + +/// Carry the persist-wrapped remote command over the chosen transport. +/// v1 implements only ssh; mosh/et are reserved (spec: Transport axis). +fn launch_invocation( + transport: Transport, + remote: &str, + remote_cmd: &str, +) -> Result<(String, Vec)> { + match transport { + Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), + Transport::Mosh => { + anyhow::bail!("--via mosh is not yet supported (reserved); use --via ssh") + } + Transport::Et => anyhow::bail!("--via et is not yet supported (reserved); use --via ssh"), + } +} + +/// Remote session-persistence backend for `--remote` resume. See the +/// design spec: three launch mechanisms (direct-wrap, layout-wrap for +/// zellij, attach-only for shpool). +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum PersistBackend { + Plain, + Tmux, + Abduco, + Dtach, + Zellij, + Shpool, +} + +impl PersistBackend { + /// Probe target on the remote (`command -v `); `Plain` has none. + fn bin(&self) -> Option<&'static str> { + match self { + PersistBackend::Plain => None, + PersistBackend::Tmux => Some("tmux"), + PersistBackend::Abduco => Some("abduco"), + PersistBackend::Dtach => Some("dtach"), + PersistBackend::Zellij => Some("zellij"), + PersistBackend::Shpool => Some("shpool"), + } + } + + fn describe(&self) -> &'static str { + match self { + PersistBackend::Plain => "plain — no persistence; dies on disconnect", + PersistBackend::Tmux => "tmux — detachable; survives drops, reattachable", + PersistBackend::Abduco => "abduco — minimal detach/attach; survives drops", + PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops", + PersistBackend::Zellij => "zellij — detachable workspace (layout-launched)", + PersistBackend::Shpool => { + "shpool — persistent shell (attach-only; run the command yourself)" + } + } + } + + /// Fixed picker display order. + const DISPLAY_ORDER: [PersistBackend; 6] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + PersistBackend::Shpool, + PersistBackend::Plain, + ]; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_remote_home_accepts_absolute_path() { + assert_eq!( + validate_remote_home("/home/exedev\n").unwrap(), + "/home/exedev" + ); + assert_eq!(validate_remote_home(" /root ").unwrap(), "/root"); + } + + #[test] + fn validate_remote_home_rejects_error_banner() { + // The exact exe.dev banner an unregistered-but-SSH-authenticated + // key produces — must fail early, not become a path component. + let err = validate_remote_home("Please complete registration by running: ssh exe.dev") + .unwrap_err() + .to_string(); + assert!(err.contains("not an absolute path"), "got: {err}"); + assert!(err.contains("unregistered key"), "got: {err}"); + } + + #[test] + fn validate_remote_home_rejects_empty_and_multiline() { + assert!( + validate_remote_home(" ") + .unwrap_err() + .to_string() + .contains("nothing") + ); + // A leading path with trailing banner lines is still rejected. + assert!(validate_remote_home("/home/x\nextra chatter").is_err()); + } + + #[test] + fn ssh_invocation_parses_user_host_port_and_path() { + let (binary, argv) = ssh_invocation_tty( + "ssh://dev@example.com:2222/home/dev/project", + "path resume 'owner/repo/slug'", + false, + ) + .unwrap(); + assert_eq!(binary, "ssh"); + assert_eq!( + argv, + vec![ + "-p".to_string(), + "2222".to_string(), + "dev@example.com".to_string(), + "path resume 'owner/repo/slug'".to_string(), + ] + ); + } + + #[test] + fn ssh_invocation_without_port_omits_p_flag() { + let (_binary, argv) = + ssh_invocation_tty("ssh://example.com", "path resume 'abc'", false).unwrap(); + assert_eq!( + argv, + vec!["example.com".to_string(), "path resume 'abc'".to_string()] + ); + } + + #[test] + fn ssh_invocation_rejects_non_ssh_url() { + let err = parse_ssh_url("https://example.com/x").unwrap_err(); + assert!(err.to_string().contains("host/alias"), "actual: {err}"); + assert!(err.to_string().contains("https://"), "actual: {err}"); + } + + #[test] + fn parse_ssh_url_accepts_bare_alias() { + // A plain ~/.ssh/config Host alias — no scheme, no user, no port. + // HostName/User/Port/IdentityFile get resolved downstream from + // the config; here it's just the host slot. + assert_eq!( + parse_ssh_url("mybox").unwrap(), + SshTarget { + user: None, + host: "mybox".to_string(), + port: None, + } + ); + } + + #[test] + fn parse_ssh_url_accepts_bare_user_host_port() { + assert_eq!( + parse_ssh_url("dev@example.com:2222").unwrap(), + SshTarget { + user: Some("dev".to_string()), + host: "example.com".to_string(), + port: Some(2222), + } + ); + } + + #[test] + fn ssh_invocation_passes_bare_alias_to_ssh_binary() { + // The interactive launch hands the alias straight to `ssh`, which + // resolves HostName/User/Port/ProxyJump natively. + let (binary, argv) = ssh_invocation_tty("mybox", "path resume 'abc'", true).unwrap(); + assert_eq!(binary, "ssh"); + assert_eq!( + argv, + vec![ + "-t".to_string(), + "mybox".to_string(), + "path resume 'abc'".to_string(), + ] + ); + } + + #[test] + fn ssh_invocation_tty_prepends_dash_t() { + let (_binary, argv) = ssh_invocation_tty( + "ssh://dev@example.com:2222", + "path resume '/tmp/x.json'", + true, + ) + .unwrap(); + assert_eq!( + argv, + vec![ + "-t".to_string(), + "-p".to_string(), + "2222".to_string(), + "dev@example.com".to_string(), + "path resume '/tmp/x.json'".to_string(), + ] + ); + } + + #[test] + fn parse_ssh_url_extracts_user_host_port_and_ignores_path() { + assert_eq!( + parse_ssh_url("ssh://dev@example.com:2222/home/dev/project").unwrap(), + SshTarget { + user: Some("dev".to_string()), + host: "example.com".to_string(), + port: Some(2222), + } + ); + assert_eq!( + parse_ssh_url("ssh://example.com").unwrap(), + SshTarget { + user: None, + host: "example.com".to_string(), + port: None, + } + ); + } + + #[test] + fn parse_ssh_url_rejects_out_of_range_port() { + let err = parse_ssh_url("ssh://example.com:99999").unwrap_err(); + assert!(err.to_string().contains("out-of-range"), "actual: {err}"); + } + + #[test] + fn ssh_config_matches_wildcard_host_and_expands_identity() { + // The exe.dev shape: a wildcard Host block pinning an identity. + // libssh2 doesn't read ~/.ssh/config, so the transport must — or + // agent auth picks whatever key happens to be first. + let config = "Host exe.dev *.exe.xyz\n\ + \x20 IdentitiesOnly yes\n\ + \x20 IdentityFile ~/.ssh/id_ed25519_signing\n\ + \x20 StrictHostKeyChecking accept-new\n"; + let cfg = parse_ssh_config( + config, + "pathremote.exe.xyz", + std::path::Path::new("/home/u"), + ); + assert_eq!( + cfg.identity_files, + vec![std::path::PathBuf::from("/home/u/.ssh/id_ed25519_signing")] + ); + assert_eq!(cfg.user, None); + // A non-matching host gets nothing. + let other = parse_ssh_config(config, "example.com", std::path::Path::new("/home/u")); + assert!(other.identity_files.is_empty()); + } + + #[test] + fn ssh_config_first_value_wins_across_blocks() { + let config = "Host other\n\ + \x20 User nope\n\ + Host pathremote.*\n\ + \x20 User dev\n\ + \x20 Port 2200\n\ + \x20 HostName real.example.com\n\ + Host *\n\ + \x20 User fallback\n\ + \x20 Port 9\n"; + let cfg = parse_ssh_config(config, "pathremote.exe.xyz", std::path::Path::new("/h")); + assert_eq!(cfg.user.as_deref(), Some("dev")); + assert_eq!(cfg.port, Some(2200)); + assert_eq!(cfg.host_name.as_deref(), Some("real.example.com")); + } + + #[test] + fn ssh_config_handles_padded_equals_and_identities_only() { + // OpenSSH allows `Key = value`; a naive first-separator split + // would leave `= value`. And IdentitiesOnly must be parsed so + // auth won't fall back to an arbitrary agent key. + let config = "Host exe.dev\n\ + \x20 User = dev\n\ + \x20 Port = 2200\n\ + \x20 IdentityFile = ~/.ssh/id_pinned\n\ + \x20 IdentitiesOnly yes\n"; + let cfg = parse_ssh_config(config, "exe.dev", std::path::Path::new("/home/u")); + assert_eq!(cfg.user.as_deref(), Some("dev")); + assert_eq!(cfg.port, Some(2200)); + assert_eq!( + cfg.identity_files, + vec![std::path::PathBuf::from("/home/u/.ssh/id_pinned")] + ); + assert!(cfg.identities_only); + } + + #[test] + fn authenticate_refuses_arbitrary_agent_key_when_identities_pinned() { + // No live session, so we can't exercise the ssh2 calls — but the + // guard's decision is pure: a config with pinned identities (or + // IdentitiesOnly) must not reach the any-key fallback. We assert + // that via the config shape the guard keys off. + let pinned = SshHostConfig { + identity_files: vec![std::path::PathBuf::from("/home/u/.ssh/id_pinned")], + ..Default::default() + }; + assert!(!pinned.identity_files.is_empty() || pinned.identities_only); + let only = SshHostConfig { + identities_only: true, + ..Default::default() + }; + assert!(!only.identity_files.is_empty() || only.identities_only); + } + + #[test] + fn normalize_remote_cwd_matches_remote_getcwd() { + // Host-side normalization must equal what the remote shell's `cd` + // lands on, else the project-dir key won't match remote Claude's. + let home = "/home/dev"; + assert_eq!( + normalize_remote_cwd(std::path::Path::new("/srv/work/"), home), + "/srv/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("/srv/./work"), home), + "/srv/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("/srv/x/../work"), home), + "/srv/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("work"), home), + "/home/dev/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("./work"), home), + "/home/dev/work" + ); + } + + #[test] + fn validate_session_id_rejects_path_traversal() { + use crate::cmd_export::validate_session_id; + assert!(validate_session_id("4523d750-77e7-4a41-922f-5b949064f429").is_ok()); + assert!(validate_session_id("../../../.ssh/authorized_keys").is_err()); + assert!(validate_session_id(".hidden").is_err()); + assert!(validate_session_id("has/slash").is_err()); + assert!(validate_session_id("").is_err()); + } + + #[test] + fn claude_project_dir_name_matches_projector_sanitization() { + // The host-side mirror of Claude Code's project-dir sanitization + // must agree with toolpath-claude's (private) implementation, or + // the shipped file lands where `claude -r` won't look. Compare + // against the resolver's own dir name. + let resolver = toolpath_claude::PathResolver::new(); + let expected = resolver + .project_dir("/srv/my_app/v1.2") + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + assert_eq!(claude_project_dir_name("/srv/my_app/v1.2"), expected); + } -fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec) { - let workspace = cwd.to_string_lossy().into_owned(); - if binary_on_path("cursor", None) { - ("cursor".to_string(), vec![workspace]) - } else { - #[cfg(target_os = "macos")] - { - ( - "open".to_string(), - vec!["-a".into(), "Cursor".into(), workspace], + #[test] + fn remote_launch_command_derives_from_harness_table() { + // Binary + argv come from the same per-harness table the local + // resume uses; safe args stay bare so the recipe reads cleanly. + assert_eq!( + persist_plan( + Harness::Claude, + "sess-1", + None, + PersistBackend::Plain, + "/home/u" ) - } - #[cfg(all(unix, not(target_os = "macos")))] - { - ("xdg-open".to_string(), vec![workspace]) - } - #[cfg(not(unix))] - { - ("cursor".to_string(), vec![workspace]) - } + .remote_command, + "claude -r sess-1" + ); + // Directory creation happens over SFTP before launch — the shell + // string stays minimal: just the cd and the harness. + assert_eq!( + persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/work"), + PersistBackend::Plain, + "/home/u" + ) + .remote_command, + "cd /srv/work && claude -r sess-1" + ); } -} -/// Project a Path into the chosen harness's on-disk layout under `cwd`, -/// returning the projected session id. -pub(crate) fn project_into_harness( - path: &TPath, - harness: Harness, - cwd: &std::path::Path, -) -> Result { - match harness { - Harness::Claude => crate::cmd_export::project_claude(path, cwd), - Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), - Harness::Codex => crate::cmd_export::project_codex(path, cwd), - Harness::Copilot => crate::cmd_export::project_copilot(path, cwd), - Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), - Harness::Cursor => crate::cmd_export::project_cursor(path, cwd), - Harness::Pi => crate::cmd_export::project_pi(path, cwd), + #[test] + fn remote_launch_command_tmux_wraps_for_detachable_sessions() { + // --tmux: the whole launch runs inside a named tmux session so + // it survives SSH disconnects; -A re-attaches on a second run. + assert_eq!( + persist_plan( + Harness::Claude, + "sess-1", + None, + PersistBackend::Tmux, + "/home/u" + ) + .remote_command, + "tmux new-session -A -s path-sess-1 'claude -r sess-1'" + ); + assert_eq!( + persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/work"), + PersistBackend::Tmux, + "/home/u" + ) + .remote_command, + "tmux new-session -A -s path-sess-1 'cd /srv/work && claude -r sess-1'" + ); } -} -/// What `exec_harness` saw (for tests). -#[derive(Debug, Clone, Default)] -pub struct CapturedExec { - pub binary: String, - pub args: Vec, - pub cwd: std::path::PathBuf, -} + #[test] + fn persist_plan_direct_wrap_backends() { + let id = "sess-1"; + let plain = persist_plan(Harness::Claude, id, None, PersistBackend::Plain, "/home/u"); + assert_eq!(plain.remote_command, "claude -r sess-1"); + assert!(plain.extra_file.is_none() && plain.post_note.is_none()); + + let tmux = persist_plan( + Harness::Claude, + id, + Some("/srv/w"), + PersistBackend::Tmux, + "/home/u", + ); + assert_eq!( + tmux.remote_command, + "tmux new-session -A -s path-sess-1 'cd /srv/w && claude -r sess-1'" + ); -/// Pluggable exec backend. Production uses `RealExec` (`execvp` on -/// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`. -pub trait ExecStrategy { - fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>; -} + let abduco = persist_plan(Harness::Claude, id, None, PersistBackend::Abduco, "/home/u"); + assert_eq!( + abduco.remote_command, + "abduco -A path-sess-1 sh -c 'claude -r sess-1'" + ); -/// Production implementation. On Unix this never returns on success -/// (the current process is replaced); on Windows it spawns the child, -/// waits, and propagates the exit code. -pub struct RealExec; + let dtach = persist_plan(Harness::Claude, id, None, PersistBackend::Dtach, "/home/u"); + assert_eq!( + dtach.remote_command, + "dtach -A /tmp/path-dtach-sess-1 -z sh -c 'claude -r sess-1'" + ); + } -impl ExecStrategy for RealExec { - fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { - let mut cmd = std::process::Command::new(binary); - cmd.args(args); - cmd.current_dir(cwd); + #[test] + fn persist_plan_shpool_attach_only_with_note() { + let p = persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/w"), + PersistBackend::Shpool, + "/home/u", + ); + assert_eq!(p.remote_command, "shpool attach path-sess-1"); + let note = p.post_note.expect("shpool note"); + assert!( + note.contains("cd /srv/w && claude -r sess-1"), + "note: {note}" + ); + assert!(p.extra_file.is_none()); + } - eprintln!( - "Resuming: {} {} (cwd: {})", - binary, - args.join(" "), - cwd.display() + #[test] + fn persist_plan_sanitizes_session_name() { + // A hostile session id must not leak `/` or `..` into the + // multiplexer session name or the zellij layout file path. + let hostile = "../evil"; + let tmux = persist_plan( + Harness::Claude, + hostile, + None, + PersistBackend::Tmux, + "/home/u", + ); + // The session-name slot (the `-s ` argument) must contain no + // `/` or `..` — unlike the inner harness argv, which legitimately + // carries the raw session id as a CLI argument to `claude -r`. + let name_arg = tmux + .remote_command + .split_whitespace() + .nth(4) + .unwrap_or_default(); + assert!( + !name_arg.contains('/') && !name_arg.contains(".."), + "tmux session name leaks traversal/slash: {name_arg}" ); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - // exec only returns if it fails. - let err = cmd.exec(); - anyhow::bail!( - "couldn't exec `{}`: {}. Recipe: {} {} (run from {})", - binary, - err, - binary, - args.join(" "), - cwd.display() - ); - } - #[cfg(not(unix))] - { - let status = cmd - .spawn() - .with_context(|| format!("spawn {}", binary))? - .wait() - .with_context(|| format!("wait for {}", binary))?; - std::process::exit(status.code().unwrap_or(1)); + let dtach = persist_plan( + Harness::Claude, + hostile, + None, + PersistBackend::Dtach, + "/home/u", + ); + // The socket path is the `-A ` argument, third whitespace + // token; check just that token, not the whole command (whose + // trailing inner argv legitimately carries the raw hostile id). + let socket_arg = dtach + .remote_command + .split_whitespace() + .nth(2) + .unwrap_or_default(); + assert!( + !socket_arg + .trim_start_matches("/tmp/path-dtach-") + .contains('/'), + "dtach socket path leaks a slash beyond the fixed /tmp/ prefix: {socket_arg}" + ); + + let zellij = persist_plan( + Harness::Claude, + hostile, + None, + PersistBackend::Zellij, + "/home/u", + ); + let (layout_path, _) = zellij.extra_file.expect("zellij ships a layout file"); + assert!( + !layout_path + .trim_start_matches("/home/u/.cache/path/zellij-") + .contains('/'), + "zellij layout path leaks a slash beyond the fixed prefix: {layout_path}" + ); + assert!( + !layout_path.contains(".."), + "zellij layout path leaks traversal: {layout_path}" + ); + } + + #[test] + fn shell_quote_leaves_safe_strings_bare() { + assert_eq!(shell_quote("-r"), "-r"); + assert_eq!(shell_quote("/srv/work"), "/srv/work"); + assert_eq!(shell_quote("has space"), "'has space'"); + assert_eq!(shell_quote(""), "''"); + } + + /// Write a minimal agent-bearing Path to a temp file and return + /// `--remote` args pointing at it (harness pinned to Claude). + fn remote_args_with_doc(dir: &std::path::Path) -> ResumeArgs { + let mut path = make_convo_path_for_resume("claude-code://remote-v1-test"); + path.steps[0].step.actor = "agent:claude-code".to_string(); + let graph = toolpath::v1::Graph::from_path(path); + let doc = dir.join("doc.json"); + std::fs::write(&doc, graph.to_json().unwrap()).unwrap(); + ResumeArgs { + input: doc.to_string_lossy().to_string(), + cwd: None, + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + remote: Some("ssh://dev@example.com:2222".to_string()), + tmux: false, + persist: None, + via: Transport::Ssh, } } -} -/// Recording strategy for tests. `captured()` returns the most recent -/// invocation. -#[derive(Default)] -pub struct RecordingExec { - inner: std::sync::Mutex, -} + #[test] + fn remote_resume_probes_ships_then_launches() { + // v3 over SFTP: host resolves AND projects the session locally, + // preflights by resolving the remote home (typed transport call, + // no shell), writes the projected JSONL into the remote's Claude + // layout, then launches an interactive `ssh -t … claude -r `. + let td = tempfile::tempdir().unwrap(); + let rec = RecordingExec::default(); + run_with_strategy(remote_args_with_doc(td.path()), &rec).unwrap(); -impl RecordingExec { - pub fn captured(&self) -> CapturedExec { - self.inner.lock().unwrap().clone() + // 1. preflight: one home lookup against the parsed target. + let homes = rec.homes(); + assert_eq!(homes.len(), 1, "exactly one preflight"); + assert_eq!( + homes[0], + SshTarget { + user: Some("dev".to_string()), + host: "example.com".to_string(), + port: Some(2222), + } + ); + + // 2. ship: mkdir + write into the remote Claude layout, keyed on + // the remote home (no --cwd pinned). The written bytes are + // Claude JSONL, not the raw toolpath doc. + let projects_dir = format!( + "{RECORDING_REMOTE_HOME}/.claude/projects/{}", + claude_project_dir_name(RECORDING_REMOTE_HOME) + ); + assert_eq!(rec.mkdirs(), vec![projects_dir.clone()]); + let writes = rec.writes(); + assert_eq!(writes.len(), 1, "exactly one file written"); + let (dest, body) = &writes[0]; + assert_eq!(dest, &format!("{projects_dir}/remote-v1-test.jsonl")); + assert!( + body.contains("\"sessionId\":\"remote-v1-test\""), + "written bytes should be projected JSONL: {body}" + ); + + // 3. launch: interactive `ssh -t … claude -r ''` with the id + // derived from the doc's `claude-code://remote-v1-test` key. + let cap = rec.captured(); + assert_eq!(cap.binary, "ssh"); + assert!( + cap.args.iter().any(|a| a == "-t"), + "launch needs -t: {:?}", + cap.args + ); + assert!( + cap.args.iter().any(|a| a == "claude -r remote-v1-test"), + "launch cmd: {:?}", + cap.args + ); } -} -impl ExecStrategy for RecordingExec { - fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { - let mut g = self.inner.lock().unwrap(); - *g = CapturedExec { - binary: binary.to_string(), - args: args.to_vec(), - cwd: cwd.to_path_buf(), - }; - Ok(()) + #[test] + fn remote_resume_with_cwd_creates_launch_dir_over_sftp() { + // A pinned --cwd is created via a typed mkdir call — not a shell + // `mkdir -p` baked into the launch string. + let td = tempfile::tempdir().unwrap(); + let mut args = remote_args_with_doc(td.path()); + args.cwd = Some(std::path::PathBuf::from("/srv/fresh")); + let rec = RecordingExec::default(); + run_with_strategy(args, &rec).unwrap(); + + assert!( + rec.mkdirs().contains(&"/srv/fresh".to_string()), + "launch dir should be created over SFTP: {:?}", + rec.mkdirs() + ); + let cap = rec.captured(); + assert!( + cap.args + .iter() + .any(|a| a == "cd /srv/fresh && claude -r remote-v1-test"), + "launch should cd only (no shell mkdir): {:?}", + cap.args + ); } -} -pub(crate) fn exec_harness( - binary: &str, - args: &[String], - cwd: &std::path::Path, - strategy: &dyn ExecStrategy, -) -> Result<()> { - strategy.exec(binary, args, cwd) -} + #[test] + fn remote_resume_rejects_non_claude_harness() { + let td = tempfile::tempdir().unwrap(); + let mut args = remote_args_with_doc(td.path()); + args.harness = Some(Harness::Codex); + let rec = RecordingExec::default(); + let err = run_with_strategy(args, &rec).unwrap_err(); + assert!( + err.to_string().contains("claude"), + "error should name the supported harness: {err}" + ); + assert!(rec.homes().is_empty(), "must fail before any remote touch"); + } -fn looks_like_pathbase_shorthand(s: &str) -> bool { - // Three non-empty slash-separated segments, none containing whitespace - // or starting with a dot/slash (which would indicate a relative or - // absolute path). - if s.starts_with('.') || s.starts_with('/') { - return false; + #[test] + fn remote_resume_aborts_when_preflight_fails() { + // If the remote preflight fails (unreachable, bad auth), abort + // before writing anything or dispatching. + let td = tempfile::tempdir().unwrap(); + let rec = RecordingExec::failing_remote(); + let err = run_with_strategy(remote_args_with_doc(td.path()), &rec).unwrap_err(); + assert!( + err.to_string().contains("remote"), + "error should explain the preflight failure: {err}" + ); + assert!( + rec.writes().is_empty() && rec.mkdirs().is_empty(), + "must not touch the remote after a failed preflight" + ); + assert!(rec.captured().binary.is_empty(), "must not dispatch"); } - let segs: Vec<&str> = s.split('/').collect(); - segs.len() == 3 - && segs - .iter() - .all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn remote_resume_without_harness_errors() { + let td = tempfile::tempdir().unwrap(); + let mut args = remote_args_with_doc(td.path()); + args.harness = None; + let rec = RecordingExec::default(); + let err = run_with_strategy(args, &rec).unwrap_err(); + assert!(err.to_string().contains("--harness"), "actual: {err}"); + assert!(rec.homes().is_empty(), "must fail before any remote touch"); + } #[test] fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { @@ -607,6 +2480,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, }; let recorder = RecordingExec::default(); @@ -729,6 +2606,37 @@ mod tests { assert!(err.to_string().contains("inline `Path`"), "actual: {}", err); } + #[test] + fn resolve_persist_flag_maps_tmux_and_rejects_conflict() { + let mut a = ResumeArgs { + input: "claude-x".to_string(), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, + }; + a.remote = Some("ssh://h".into()); + a.tmux = true; + assert_eq!( + resolve_persist_flag(&a).unwrap(), + Some(PersistBackend::Tmux) + ); + + a.persist = Some(PersistBackend::Dtach); + assert!(resolve_persist_flag(&a).is_err()); // both --tmux and --persist + + a.tmux = false; + assert_eq!( + resolve_persist_flag(&a).unwrap(), + Some(PersistBackend::Dtach) + ); + } + #[test] fn resolve_input_file_path() { let tmp = tempfile::tempdir().unwrap(); @@ -743,6 +2651,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -777,6 +2689,10 @@ mod tests { no_cache: true, // skip cache write in tests force: false, url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -838,6 +2754,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, }; let result = resolve_input(&args); @@ -866,6 +2786,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); @@ -1094,6 +3018,20 @@ mod tests { } } + #[test] + fn launch_invocation_ssh_and_deferred_transports() { + let (bin, argv) = launch_invocation(Transport::Ssh, "ssh://h", "claude -r x").unwrap(); + assert_eq!(bin, "ssh"); + assert_eq!( + argv, + vec!["-t".to_string(), "h".to_string(), "claude -r x".to_string()] + ); + + let err = launch_invocation(Transport::Mosh, "ssh://h", "claude -r x").unwrap_err(); + assert!(err.to_string().contains("not yet supported"), "{err}"); + assert!(launch_invocation(Transport::Et, "ssh://h", "x").is_err()); + } + #[test] fn exec_strategy_recording_captures_invocation() { let recorder = RecordingExec::default(); @@ -1111,4 +3049,80 @@ mod tests { assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]); assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x")); } + + #[test] + fn persist_backend_bin_and_order() { + assert_eq!(PersistBackend::Plain.bin(), None); + assert_eq!(PersistBackend::Tmux.bin(), Some("tmux")); + assert_eq!(PersistBackend::Shpool.bin(), Some("shpool")); + // Display order is stable and complete. + assert_eq!(PersistBackend::DISPLAY_ORDER.len(), 6); + assert_eq!(PersistBackend::DISPLAY_ORDER[0], PersistBackend::Tmux); + assert!(PersistBackend::Tmux.describe().contains("detach")); + } + + #[test] + fn persist_plan_zellij_ships_layout() { + let p = persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/w"), + PersistBackend::Zellij, + "/home/u", + ); + assert_eq!( + p.remote_command, + "zellij --session path-sess-1 --layout /home/u/.cache/path/zellij-path-sess-1.kdl" + ); + let (path, body) = p.extra_file.expect("layout shipped"); + assert_eq!(path, "/home/u/.cache/path/zellij-path-sess-1.kdl"); + let body = String::from_utf8(body).unwrap(); + assert!( + body.contains("cd /srv/w && claude -r sess-1"), + "body: {body}" + ); + assert!(body.contains("pane"), "must be a KDL layout: {body}"); + } + + #[test] + fn recording_exec_remote_which_returns_canned_set() { + let rec = RecordingExec::with_available(["tmux", "dtach"]); + let got = rec + .remote_which( + &SshTarget { + user: None, + host: "h".into(), + port: None, + }, + &["tmux", "zellij", "dtach"], + ) + .unwrap(); + assert!(got.contains("tmux") && got.contains("dtach")); + assert!(!got.contains("zellij")); + } + + #[test] + fn persist_candidates_and_preference() { + use std::collections::BTreeSet; + let avail: BTreeSet = ["dtach", "zellij"].iter().map(|s| s.to_string()).collect(); + let cands = persist_candidates(&avail); + // DISPLAY_ORDER filtered to available + always Plain, in order. + assert_eq!( + cands, + vec![ + PersistBackend::Zellij, + PersistBackend::Dtach, + PersistBackend::Plain + ] + ); + assert_eq!(preferred_backend(&avail), PersistBackend::Zellij); // tmux absent -> zellij + + let none: BTreeSet = BTreeSet::new(); + assert_eq!(persist_candidates(&none), vec![PersistBackend::Plain]); + assert_eq!(preferred_backend(&none), PersistBackend::Plain); + + let with_tmux: BTreeSet = + ["tmux", "shpool"].iter().map(|s| s.to_string()).collect(); + assert_eq!(preferred_backend(&with_tmux), PersistBackend::Tmux); // shpool never preferred over tmux + } } diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..39eeeceb 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,7 +8,9 @@ #![cfg(not(target_os = "emscripten"))] -use path_cli::cmd_resume::{RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::cmd_resume::{ + PersistBackend, RecordingExec, ResumeArgs, Transport, run_with_strategy, +}; use path_cli::harness::Harness; mod support; @@ -265,6 +267,10 @@ fn cache_id_input_loads_and_projects() { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, }; let recorder = RecordingExec::default(); @@ -350,3 +356,125 @@ fn explicit_harness_not_on_path_errors() { assert!(s.contains("isn't on PATH"), "actual: {s}"); assert!(s.contains("claude"), "actual: {s}"); } + +// ── Remote resume over SSH ────────────────────────────────────────── + +/// With `--remote `, resume should be dispatched to the remote +/// host over SSH rather than exec'ing a local harness: the session is +/// projected locally and the JSONL shipped into the remote's Claude +/// layout (no `path` on the remote), and the final recorded invocation +/// must be `ssh -t` targeting the remote host and launching +/// `claude -r ` directly (id computed host-side from the doc). +#[test] +fn remote_flag_dispatches_resume_over_ssh() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binaries(&["ssh", "claude"]); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:claude-code", "claude-code://resume-remote-int"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let mut args = args_explicit(doc_file, cwd.path(), Harness::Claude); + args.remote = Some("ssh://dev@example.com:2222/home/dev/project".to_string()); + + let recorder = RecordingExec::default(); + run_with_strategy(args, &recorder).unwrap(); + + // Ship: the locally-projected JSONL is written over SFTP into the + // remote's Claude projects layout — typed transport calls, no shell + // strings and no `path` on the remote. + let writes = recorder.writes(); + assert_eq!(writes.len(), 1, "exactly one file written"); + let (dest, body) = &writes[0]; + assert!( + dest.contains(".claude/projects/") && dest.ends_with("resume-remote-int.jsonl"), + "file should land in the remote Claude layout, got {dest}" + ); + assert!( + body.contains("\"sessionId\":\"resume-remote-int\""), + "written bytes should carry the projected JSONL" + ); + + // Launch: interactive ssh -t running the harness directly. + let cap = recorder.captured(); + assert_eq!( + cap.binary, "ssh", + "remote resume should exec ssh, not the local harness (got {})", + cap.binary + ); + assert!( + cap.args.iter().any(|a| a.contains("example.com")), + "ssh argv should target the remote host, got {:?}", + cap.args + ); + assert!( + cap.args + .iter() + .any(|a| a.contains("claude -r resume-remote-int")), + "ssh should launch `claude -r ` on the remote, got {:?}", + cap.args + ); +} + +/// With `--persist dtach` pinned explicitly (skipping the probe/picker), +/// the launch command must be wrapped in `dtach -A /tmp/path-dtach-` +/// so the remote session survives an SSH disconnect. +#[test] +fn remote_resume_persist_dtach_records_launch_and_ships() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binaries(&["ssh", "claude"]); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:claude-code", "claude-code://resume-persist-dtach"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let mut args = args_explicit(doc_file, cwd.path(), Harness::Claude); + args.remote = Some("ssh://h".to_string()); + args.persist = Some(PersistBackend::Dtach); + + let rec = RecordingExec::with_available(["dtach"]); + run_with_strategy(args, &rec).unwrap(); + + let cap = rec.captured(); + assert_eq!(cap.binary, "ssh"); + assert!( + cap.args + .iter() + .any(|a| a.contains("dtach -A /tmp/path-dtach-")), + "{:?}", + cap.args + ); +} + +/// `--remote` without `--harness` must fail fast on the host with a +/// clear message: the remote resume runs over a non-interactive SSH +/// session where the harness picker has no TTY, and the host can't run +/// the picker either (it never resolves the doc in v0). +#[test] +fn remote_without_harness_errors_before_dispatch() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binaries(&["ssh", "claude"]); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:claude-code", "claude-code://resume-remote-nohar"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let mut args = args_explicit(doc_file, cwd.path(), Harness::Claude); + args.harness = None; + args.remote = Some("ssh://dev@example.com:2222".to_string()); + + let recorder = RecordingExec::default(); + let err = run_with_strategy(args, &recorder).unwrap_err(); + let s = err.to_string(); + assert!( + s.contains("--harness"), + "error should mention --harness: {s}" + ); + assert!( + recorder.captured().binary.is_empty(), + "must not dispatch ssh when --harness is missing" + ); +} diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index bf7597ba..3a7f13ed 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -11,7 +11,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use path_cli::cmd_resume::ResumeArgs; +use path_cli::cmd_resume::{ResumeArgs, Transport}; use path_cli::harness::Harness; /// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or @@ -27,11 +27,20 @@ pub fn env_lock() -> std::sync::MutexGuard<'static, ()> { .unwrap_or_else(|e| e.into_inner()) } -/// RAII guard that pins `$HOME` and `$TOOLPATH_CONFIG_DIR` to a tempdir. +/// RAII guard that pins `$HOME`, `$TOOLPATH_CONFIG_DIR`, and +/// `$XDG_DATA_HOME` to a tempdir. +/// +/// `$XDG_DATA_HOME` matters because the opencode `PathResolver` prefers it +/// over `$HOME` when locating `opencode.db`. Without pinning it, a machine +/// that sets `$XDG_DATA_HOME` (common on Linux; also set on this dev box) +/// would send the opencode projector to the *real* user database — the +/// test would fail with "table … already exists" and, worse, mutate the +/// user's live opencode data. Pinning it keeps every harness sandboxed. pub struct ScopedHome { _td: tempfile::TempDir, prev_home: Option, prev_config: Option, + prev_xdg_data: Option, } impl ScopedHome { @@ -39,14 +48,17 @@ impl ScopedHome { let td = tempfile::tempdir().unwrap(); let prev_home = std::env::var_os("HOME"); let prev_config = std::env::var_os("TOOLPATH_CONFIG_DIR"); + let prev_xdg_data = std::env::var_os("XDG_DATA_HOME"); unsafe { std::env::set_var("HOME", td.path()); std::env::set_var("TOOLPATH_CONFIG_DIR", td.path().join(".toolpath")); + std::env::set_var("XDG_DATA_HOME", td.path().join(".local/share")); } Self { _td: td, prev_home, prev_config, + prev_xdg_data, } } @@ -66,6 +78,10 @@ impl Drop for ScopedHome { Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v), None => std::env::remove_var("TOOLPATH_CONFIG_DIR"), } + match &self.prev_xdg_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } } } } @@ -187,6 +203,10 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, } } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 0ed4b496..acb66c2f 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.1" +version = "0.12.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/project.rs b/crates/toolpath-claude/src/project.rs index 5435649a..d2cf7a42 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -103,7 +103,13 @@ fn project_view(view: &ConversationView) -> std::result::Result = HashMap::new(); + // Maps a dropped/synthesized turn id → the parent its children + // should point at instead. The value is itself optional: a + // content-empty seed at the chain *root* has no parent, so its + // children must inherit `None` (not fall back to the seed's own id, + // which was never emitted). Presence-in-map, not Some-ness, decides + // whether a rewrite applies. + let mut parent_rewrites: HashMap> = HashMap::new(); // Message-group accounting. The IR carries a message's total // `token_usage` only on the group's final turn; real Claude JSONL stamps @@ -126,11 +132,17 @@ fn project_view(view: &ConversationView) -> std::result::Result match parent_rewrites.get(pid) { + Some(rewritten) => rewritten.clone(), + None => Some(pid.clone()), + }, + None => None, + }; match &turn.role { Role::User => { @@ -140,6 +152,28 @@ fn project_view(view: &ConversationView) -> std::result::Result { + // Drop content-empty assistant turns. Claude Code streams a + // message as several JSONL lines, the first an empty "seed" + // (text: "") superseded by the real-text line. Projecting that + // seed as `content:[{type:text, text:""}]` yields an + // API-invalid message — on the next resumed turn Anthropic + // rejects the replayed transcript with "text content blocks + // must be non-empty". Skip the seed and re-link any child that + // pointed at it to its parent, keeping the uuid chain intact. + // (Turns carrying tool_result events are never skipped — those + // events would be orphaned.) + // Content-empty *and* not carrying attached tool-result + // events (those would be orphaned if we dropped the turn). + let is_droppable_seed = + turn.is_content_empty() && !tool_result_events_by_parent.contains_key(&turn.id); + if is_droppable_seed { + // Always record the rewrite — including `None` for a + // seed at the chain root — so children never dangle + // onto this dropped turn's id. + parent_rewrites.insert(turn.id.clone(), effective_parent); + continue; + } + // Grouped: the message total on every line of the split. // Ungrouped: the turn's own usage. let wire_usage: Option = match turn.group_id.as_deref() @@ -170,7 +204,7 @@ fn project_view(view: &ConversationView) -> std::result::Result std::result::Result = content_entries(&convo) + .iter() + .filter(|e| e.entry_type == "assistant") + .collect(); + + // The empty seed is gone; only the real-text line survives. + assert_eq!(assistants.len(), 1, "empty seed line must be dropped"); + let entry = assistants[0]; + assert_eq!(entry.uuid, "real"); + // Re-linked past the dropped seed to its parent. + assert_eq!( + entry.parent_uuid.as_deref(), + Some("u1"), + "surviving turn must re-parent to the dropped seed's parent" + ); + + // No assistant message anywhere carries an empty/whitespace text block. + for e in content_entries(&convo) + .iter() + .filter(|e| e.entry_type == "assistant") + { + if let Some(MessageContent::Parts(parts)) = + e.message.as_ref().and_then(|m| m.content.as_ref()) + { + for p in parts { + if let ContentPart::Text { text } = p { + assert!(!text.trim().is_empty(), "empty text content block leaked"); + } + } + } + } + + // Usage from the group is preserved on the surviving line. + let msg = entry.message.as_ref().unwrap(); + assert_eq!(msg.usage.as_ref().unwrap().output_tokens, Some(224)); + } + + #[test] + fn test_projector_drops_content_empty_seed_at_chain_root() { + // A content-empty assistant seed at the ROOT of the chain (no + // parent) must not leave the surviving turn pointing at the + // dropped seed's never-emitted uuid. Cross-harness IR can put a + // content-empty assistant at position 0; the surviving child must + // inherit `None`, not dangle onto the seed. + let mut seed = assistant_turn("seed", ""); + seed.parent_id = None; // chain root + let mut real = assistant_turn("real", "Real answer."); + real.parent_id = Some("seed".into()); + + let view = make_view("sess-1", vec![seed, real]); + let convo = ClaudeProjector.project(&view).unwrap(); + + let assistants: Vec<&ConversationEntry> = content_entries(&convo) + .iter() + .filter(|e| e.entry_type == "assistant") + .collect(); + assert_eq!(assistants.len(), 1, "rootless empty seed must be dropped"); + assert_eq!(assistants[0].uuid, "real"); + assert_eq!( + assistants[0].parent_uuid, None, + "child of a dropped rootless seed must inherit None, not dangle on the seed id" + ); + } + // ── Permission-mode preamble ───────────────────────────────────── #[test] diff --git a/crates/toolpath-codex/src/io.rs b/crates/toolpath-codex/src/io.rs index 25441a9a..bc5340a4 100644 --- a/crates/toolpath-codex/src/io.rs +++ b/crates/toolpath-codex/src/io.rs @@ -173,7 +173,11 @@ mod tests { let (_t, io) = setup(); let day = io.resolver().sessions_root().unwrap().join("2026/04/21"); fs::create_dir_all(&day).unwrap(); - fs::write(day.join("rollout-2026-04-21T09-00-00-bbb.jsonl"), "not json").unwrap(); + fs::write( + day.join("rollout-2026-04-21T09-00-00-bbb.jsonl"), + "not json", + ) + .unwrap(); let ids = io.list_session_ids().unwrap(); assert_eq!(ids.len(), 2); diff --git a/crates/toolpath-codex/src/paths.rs b/crates/toolpath-codex/src/paths.rs index 7ca50b51..73a88ef2 100644 --- a/crates/toolpath-codex/src/paths.rs +++ b/crates/toolpath-codex/src/paths.rs @@ -141,7 +141,10 @@ impl PathResolver { /// isn't stem-shaped or the file isn't at its dated path (the caller /// falls back to the tree walk). fn rollout_file_for_stem(&self, session_id: &str) -> Result> { - let Some(date) = session_id.strip_prefix("rollout-").and_then(|r| r.get(..10)) else { + let Some(date) = session_id + .strip_prefix("rollout-") + .and_then(|r| r.get(..10)) + else { return Ok(None); }; let parts: Vec<&str> = date.split('-').collect(); diff --git a/crates/toolpath-convo/Cargo.toml b/crates/toolpath-convo/Cargo.toml index 43652540..6ec4f1d0 100644 --- a/crates/toolpath-convo/Cargo.toml +++ b/crates/toolpath-convo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-convo" -version = "0.11.1" +version = "0.12.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-convo/src/lib.rs b/crates/toolpath-convo/src/lib.rs index dcf3c3e2..592103f9 100644 --- a/crates/toolpath-convo/src/lib.rs +++ b/crates/toolpath-convo/src/lib.rs @@ -321,6 +321,23 @@ pub struct Turn { pub file_mutations: Vec, } +impl Turn { + /// True when the turn carries no renderable content of its own — no + /// visible text (ignoring whitespace), thinking, tool uses, + /// delegations, or file mutations. Such turns are streaming "seeds" + /// or placeholders that projectors may safely drop. Note this is + /// intrinsic to the turn; a caller that also tracks attached events + /// (e.g. tool-result entries keyed by parent) must check those + /// separately. + pub fn is_content_empty(&self) -> bool { + self.text.trim().is_empty() + && self.thinking.is_none() + && self.tool_uses.is_empty() + && self.delegations.is_empty() + && self.file_mutations.is_empty() + } +} + /// A complete conversation from any provider. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ConversationView { @@ -1153,4 +1170,69 @@ mod tests { let view: ConversationView = serde_json::from_str(json).unwrap(); assert!(view.events.is_empty()); } + + #[test] + fn test_turn_is_content_empty() { + // Turn has no Default; build a whitespace-only base and clone it. + let base = Turn { + id: "t1".into(), + parent_id: None, + group_id: None, + role: Role::Assistant, + timestamp: "2026-01-01T00:00:00Z".into(), + text: " ".into(), // whitespace-only counts as empty + thinking: None, + tool_uses: vec![], + model: None, + stop_reason: None, + token_usage: None, + attributed_token_usage: None, + environment: None, + delegations: vec![], + file_mutations: vec![], + }; + assert!(base.is_content_empty()); + + // Any one kind of content makes it non-empty. + assert!( + !Turn { + text: "hi".into(), + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + thinking: Some("…".into()), + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + tool_uses: vec![ToolInvocation::default()], + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + delegations: vec![DelegatedWork { + agent_id: "a".into(), + prompt: "p".into(), + turns: vec![], + result: None, + }], + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + file_mutations: vec![FileMutation::default()], + ..base.clone() + } + .is_content_empty() + ); + } } diff --git a/docs/agents/remote-resume-targets.md b/docs/agents/remote-resume-targets.md new file mode 100644 index 00000000..7df81843 --- /dev/null +++ b/docs/agents/remote-resume-targets.md @@ -0,0 +1,119 @@ +# Remote resume targets: exe.dev and Sprite + +Runbook for standing up an SSH-reachable box that `path resume --remote` can +target. `path resume --remote ` shells out to the real `ssh` +binary for the interactive launch, and uses libssh2 (raw TCP dial) for the +preflight/ship half — so a target must be reachable as a plain `ssh` host with +`path` **and** `claude` installed. + +See also: `crates/path-cli/src/cmd_resume.rs` module docs (the remote protocol) +and the design spec `docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`. + +--- + +## exe.dev — ✅ verified working + +exe.dev is SSH-native (VMs at `.exe.xyz` support full SSH), so it drops +straight into the remote-resume contract. This is the reference setup. + +**Account / key** +- Account `me@robertdelanghe.dev`. Auth is by SSH key; registration is a one-time + interactive `ssh exe.dev` in a **real terminal** (the CLI `!` shell has no TTY + and can't complete it). +- `~/.ssh/config` pins the key: + ``` + Host exe.dev *.exe.xyz + IdentitiesOnly yes + IdentityFile ~/.ssh/id_ed25519_signing + StrictHostKeyChecking accept-new + ``` + +**Provision + prepare a VM** +```bash +ssh exe.dev new --name=pathremote --json # -> pathremote.exe.xyz (Ubuntu 24.04 x86_64, user exedev) +# claude ships pre-installed at /usr/local/bin/claude + +# install path on the box: +ssh pathremote.exe.xyz 'curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal' +ssh pathremote.exe.xyz 'sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential pkg-config libssl-dev cmake' +tar czf - --exclude=target --exclude=.git . | ssh pathremote.exe.xyz 'rm -rf ~/toolpath && mkdir -p ~/toolpath && tar xzf - -C ~/toolpath' +ssh pathremote.exe.xyz 'cd ~/toolpath && source ~/.cargo/env && cargo build --release -p path-cli' # ~3m on 2 CPUs +ssh pathremote.exe.xyz 'sudo cp ~/toolpath/target/release/path /usr/local/bin/path' +``` + +**Authenticate claude** (real terminal — OAuth device flow): +```bash +ssh -t pathremote.exe.xyz claude # complete the login URL in your browser +``` + +**Verify** +```bash +path resume --remote ssh://pathremote.exe.xyz --harness claude +# also works via a config alias: --remote pathbox +``` + +**Housekeeping:** persistent VM = billing. `ssh exe.dev rm pathremote` to delete; +`ssh exe.dev ls --json` to list. Rebuild + recopy `path` if the branch's +remote-resume code changes materially. + +--- + +## Sprite (Fly.io) — 📋 documented, not yet stood up + +Sprite is authed (org `robert-delanghe`, card on file, token minted) but **does +not fit the SSH contract out of the box**: it exposes `sprite console` / +`sprite exec` / `sprite proxy`, not a raw `ssh` endpoint. The lift is to give it +one. + +### The endpoint decision (do option A) + +- **A — sshd inside + `sprite proxy` → `localhost:`** *(recommended)*. + Dialing `localhost:` is a **raw TCP socket**, which libssh2 (the + preflight/ship half) handles — so both ship and launch work. +- **B — `ProxyCommand` shim over `sprite exec`**. Hits a known limitation: + **libssh2 does not honor `ProxyCommand`**, so the interactive launch would work + but the file-ship step fails. Do not use until the ssh-CLI ship fallback exists + (tracked as an open question in the design spec). + +### Steps (option A) + +1. **Create a sprite** + ```bash + sprite create -o robert-delanghe pathremote + ``` +2. **Install + start sshd inside it** (via `sprite console` / `sprite exec`) + ```bash + sprite exec -- sudo apt-get update -qq + sprite exec -- sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server + sprite exec -- sudo sed -i 's/#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config + sprite exec -- 'mkdir -p ~/.ssh && printf "%s\n" "$(cat ~/.ssh/id_ed25519_signing.pub)" >> ~/.ssh/authorized_keys' # push your pubkey + sprite exec -- sudo service ssh start + ``` +3. **Forward the SSH port to localhost** + ```bash + sprite proxy 2222 # maps a local port to the sprite's :22 (confirm exact mapping syntax with `sprite proxy --help`) + ``` +4. **Install `path` + `claude`** — same recipe as exe.dev, but over the tunnel + (`ssh -p 2222 user@localhost …`). Then OAuth `claude` in a real terminal: + ```bash + ssh -t -p 2222 user@localhost claude + ``` +5. **Verify** + ```bash + path resume --remote ssh://user@localhost:2222 --harness claude + ``` + +### Open items for Sprite +- Confirm `sprite proxy` direction/port-mapping syntax (`sprite proxy --help`). +- The proxy tunnel must stay up for the duration of the resume (ship + launch). +- Alternative to sshd: if a future `path` gains a `sprite exec`-based transport + (a `--via sprite` seam), Sprite could skip sshd entirely — not planned. + +--- + +## Quick reference + +| Target | SSH fit | `path` | `claude` | Status | +|---|---|---|---|---| +| exe.dev `pathremote.exe.xyz` | native | ✅ built (this branch) | ✅ OAuth'd | verified working | +| Sprite | needs sshd + `sprite proxy` | ⬜ | ⬜ | documented, not stood up | diff --git a/docs/agents/remote-session-persistence-landscape.md b/docs/agents/remote-session-persistence-landscape.md new file mode 100644 index 00000000..e1874ac3 --- /dev/null +++ b/docs/agents/remote-session-persistence-landscape.md @@ -0,0 +1,144 @@ +# Remote session persistence & transport: the landscape + +Reference for `path resume --remote`. Captures the tool landscape behind the +`--persist` (session-survival) and `--via` (connection-survival) axes, and why +reachability is delegated to `~/.ssh/config`. Companion to the design spec +(`docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`) and the +targets runbook (`docs/agents/remote-resume-targets.md`). + +## The four independent layers + +Remote resume touches four *separate* concerns that **stack rather than compete**. +The classic mistake is asking one tool to cover two layers (expecting tmux to +solve roaming, or mosh to solve NAT). Keep them separate: + +| Layer | Concern | In `path` | +|---|---|---| +| **Reachability** | Can I connect at all? (NAT, mesh, broker) | `~/.ssh/config` — not a flag | +| **Connection-survival** | Does the link survive roaming/sleep/IP change? | `--via ssh\|mosh\|et` | +| **Session-survival** | Does work survive a client death / reattach elsewhere? | `--persist ` | +| **Workspace** | Panes / tabs / layout | a `--persist` backend (zellij) | + +--- + +## Layer 2 — Session survival (`--persist`) + +Anchors the session **server-side** so the harness keeps running under a daemon +even if the client dies, the laptop reboots, or you reattach from another machine. + +### Command-wrapping (clean fit — hand them the command) +- **tmux** — the incumbent. `tmux new-session -A -s ''`; `-A` + attaches-or-creates so re-running reattaches. Full multiplexer (panes, tabs, + status bar, copy-mode) that happens to persist. +- **abduco** — minimal detach/attach only (from the dvtm author; splitting is + left to dvtm). `abduco -A `. Mature, tiny; no output-replay on + reattach, rougher resize handling. +- **dtach** — the ~20-year-old ancestor; does even less (no session listing, you + manage socket paths). `dtach -A `. Works, but little reason over + abduco/shpool today. + +### Layout-wrapping +- **zellij** — the modern Rust "tmux designed this decade": discoverable + keybindings (status bar shows modes), floating/stacked panes, **resurrection** + (serializes sessions to disk → survives a reboot, re-runs pane commands on + attach after confirmation), WASM plugins with a capability/permission model, + KDL layouts as declarative workspace-as-data. No "new named session running + command X" one-liner — the supported path is a **KDL layout** with the command + in a pane, launched `zellij --session --layout `. + +### Attach-only (persistence, no multiplexer) +- **shpool** (Google) — "shell pool": a per-user daemon holds PTYs; `shpool + attach ` reconnects or creates. Persistence *only* — no panes/tabs/status + bar/prefix; your terminal emulator's native scrollback keeps working (a real + ergonomic win). One client per session (reattaching steals from a dead + connection). **No command arg** — attach always starts the configured shell, + so it can't be handed a one-shot command; needs `loginctl enable-linger` to + survive full logout. Best paired with a terminal/WM that owns splitting. + +### Restore-after-reboot +Only **zellij** reconstructs layout + pane commands declaratively. tmux needs +`resurrect`/`continuum` bolted on. Nothing truly restores process state — it's +all "re-run the commands" reconstruction. + +--- + +## Layer 1.5 — Connection survival (`--via`) + +Keeps one client↔host link alive; does **not** anchor the session server-side. + +- **mosh** — state-sync protocol (SSP) over UDP: client and server sync a + terminal-screen state object, so it survives IP changes / sleep / roaming. + No server-side session daemon beyond its own process, no attach-from-elsewhere, + **no scrollback** (its biggest wart). SSH for the initial handshake, then its + own UDP port. `mosh host -- ` (mosh owns the TTY, so no `ssh -t`). +- **Eternal Terminal (et)** — mosh's idea over **TCP** with native scrollback + intact and transparent auto-reconnect. SSH handshake, then its own port. + Knocks: needs its own daemon + open port; development has slowed. +- **SSH3** (misnomer, not an official successor) — SSH semantics over HTTP/3/QUIC; + QUIC gives connection migration natively (mosh's property, standardized at the + transport). Researchy; not dependable yet. + +In `path`: `--via` swaps the launch client binary; the persistence wrapping is +identical. v1 implements only `ssh`; `mosh`/`et` are reserved (seam in place). + +--- + +## Layer 1 — Reachability (delegated to `~/.ssh/config`) + +These dissolve the *reachability* problem, not the reconnect problem. **None are +a `path` flag** — they're how the host resolves/connects, expressed as a `Host` +block (`ProxyCommand`, `ProxyJump`, or a mesh hostname). Because `--remote` +accepts a `~/.ssh/config` alias, resume rides all of them for free. + +**Mesh / overlay (no public inbound ports):** +- **Tailscale SSH** — WireGuard mesh where `tailscaled` terminates SSH; auth via + your IdP, no keys to manage, no open ports. WireGuard endpoint migration keeps + the *tunnel* alive across IP changes (a plain TCP session inside can still + notice long outages — run mosh/shpool *over* tailscale, not instead). +- **WireGuard / Nebula / NetBird** — same idea, less product around it. + +**Rendezvous / brokered (outbound-only from the host):** +- **Cloudflare Tunnel + `cloudflared access ssh`** — outbound tunnel, SSH brokered + through Cloudflare's edge, optional access policies. +- **upterm / tmate** — reverse-tunnel a session through a relay for sharing / + reaching a NATed box (tmate is literally a tmux fork with a rendezvous server). +- **Teleport / Boundary** — enterprise: certificate-based, audited, + session-recorded access brokers. Heavy, but the only family where the transport + itself has an attribution story (every session tied to a signed identity cert, + recorded, policy-checked before the PTY is granted). + +**Browser-as-client:** +- **ttyd / gotty / sshx** — expose a terminal over WebSocket/HTTP (sshx adds + multiplayer + E2E encryption). "Any device, zero client install"; run behind one + of the mesh/broker layers, not raw. + +### Known limitation +libssh2 (the probe/ship half of `--remote`) **does not honor `ProxyCommand`/ +`ProxyJump`** — it dials a raw TCP socket. A host reachable *only* through a +broker/jump (Cloudflare Tunnel, bastion-only) will launch fine (the `ssh` CLI +honors the config) but **fail at the ship step**. Candidate mitigation: fall back +to shipping over the `ssh` CLI (`ssh host 'cat > dest'` / scp) when the libssh2 +dial fails and a `ProxyCommand` is configured. Tracked as an open question. + +--- + +## Composition guidance + +The layers stack. Pick per concern: + +- **Reachability:** ssh-config alias over Tailscale (identity-attributed) or a + broker; plain host if directly routable. +- **Connection survival:** mosh/et if you roam; skip if the link is stable. +- **Session survival:** tmux (ubiquitous), zellij (workspace + resurrection), + shpool (minimal, terminal-native scrollback), abduco/dtach (tiny). + +For agent runs in a claude-box-style setup, a strong pairing is **Tailscale SSH + +shpool**: identity-attributed transport (auth logged against a signed identity, +not a static `authorized_keys`) with server-side session persistence — versus +mosh, which adds a second auth path and a long-lived UDP daemon to the attack +surface. When you want the workspace layer too, that's **zellij** instead of +shpool. + +The niche bifurcates cleanly and there's no middle tool: transport persistence +(mosh, et), session persistence (shpool, abduco), workspace persistence (zellij, +tmux). Reachability is its own axis on top. diff --git a/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md b/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md new file mode 100644 index 00000000..f2e9b404 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md @@ -0,0 +1,772 @@ +# Remote Persistence Picker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `path resume --remote` choose a remote session-persistence backend (plain/tmux/abduco/dtach/zellij/shpool) via a picker mirroring the harness picker, plus a reserved `--via ssh|mosh|et` transport axis. + +**Architecture:** All work is in `crates/path-cli/src/cmd_resume.rs`. A `PersistBackend` enum + pure `persist_plan()` produce a `PersistPlan { remote_command, extra_file, post_note }`; a `Transport` enum + `launch_invocation()` seam carries it. A one-shot `remote_which` probe over the existing libssh2 `ExecStrategy` feeds candidate assembly + pre-selection, which the picker resolves. `run_remote` is rewired to: probe → pick backend → build plan → ship (+ extra_file) → note → launch via transport. + +**Tech Stack:** Rust (edition 2024), clap `ValueEnum`, ssh2 (libssh2), existing skim/fzf picker in `crates/path-cli/src/skim_picker.rs`. + +## Global Constraints + +- Package `path-cli`; no new dependencies. +- Session name is `format!("path-{}", session_id.replace(['.', ':'], "-"))` — reuse verbatim (tmux/abduco/dtach/zellij/shpool all key on it). +- `INNER` = existing inner launch string built by `remote_launch_command`'s head: `[cd && ] `, quoted via `shell_quote`/`shell_single_quote`. +- Remote command strings must be valid remote-shell syntax (they're passed as one argv element to `ssh` and re-split remotely). Reuse `shell_single_quote` for nesting. +- Backend display order: `tmux, zellij, abduco, dtach, shpool, plain`. Pre-selection priority: `tmux > zellij > abduco > dtach > plain` (shpool never auto-preferred). +- `--via` v1 implements only `ssh`; `mosh`/`et` return a clear "not yet supported" error. +- `--tmux` becomes a deprecated alias for `--persist tmux`; error if combined with `--persist`. +- TDD: failing test → run (fail) → implement → run (pass) → commit, per task. +- Reference: design spec `docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`. + +--- + +### Task 1: `PersistBackend` enum + clap/describe + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` (near the `Harness` enum) +- Test: same file `#[cfg(test)] mod tests` + +**Interfaces:** +- Produces: `enum PersistBackend { Plain, Tmux, Abduco, Dtach, Zellij, Shpool }`; `PersistBackend::bin(&self) -> Option<&'static str>`; `PersistBackend::describe(&self) -> &'static str`; `PersistBackend::DISPLAY_ORDER: [PersistBackend; 6]`; derives `clap::ValueEnum`, `Copy`, `Clone`, `PartialEq`, `Eq`, `Debug`. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_backend_bin_and_order() { + assert_eq!(PersistBackend::Plain.bin(), None); + assert_eq!(PersistBackend::Tmux.bin(), Some("tmux")); + assert_eq!(PersistBackend::Shpool.bin(), Some("shpool")); + // Display order is stable and complete. + assert_eq!(PersistBackend::DISPLAY_ORDER.len(), 6); + assert_eq!(PersistBackend::DISPLAY_ORDER[0], PersistBackend::Tmux); + assert!(PersistBackend::Tmux.describe().contains("detach")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p path-cli --lib persist_backend_bin_and_order` +Expected: FAIL — `PersistBackend` not found. + +- [ ] **Step 3: Write minimal implementation** +```rust +/// Remote session-persistence backend for `--remote` resume. See the +/// design spec: three launch mechanisms (direct-wrap, layout-wrap for +/// zellij, attach-only for shpool). +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum PersistBackend { + Plain, + Tmux, + Abduco, + Dtach, + Zellij, + Shpool, +} + +impl PersistBackend { + /// Probe target on the remote (`command -v `); `Plain` has none. + fn bin(&self) -> Option<&'static str> { + match self { + PersistBackend::Plain => None, + PersistBackend::Tmux => Some("tmux"), + PersistBackend::Abduco => Some("abduco"), + PersistBackend::Dtach => Some("dtach"), + PersistBackend::Zellij => Some("zellij"), + PersistBackend::Shpool => Some("shpool"), + } + } + + fn describe(&self) -> &'static str { + match self { + PersistBackend::Plain => "plain — no persistence; dies on disconnect", + PersistBackend::Tmux => "tmux — detachable; survives drops, reattachable", + PersistBackend::Abduco => "abduco — minimal detach/attach; survives drops", + PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops", + PersistBackend::Zellij => "zellij — detachable workspace (layout-launched)", + PersistBackend::Shpool => "shpool — persistent shell (attach-only; run the command yourself)", + } + } + + /// Fixed picker display order. + const DISPLAY_ORDER: [PersistBackend; 6] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + PersistBackend::Shpool, + PersistBackend::Plain, + ]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p path-cli --lib persist_backend_bin_and_order` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): PersistBackend enum for remote persistence backends" +``` + +--- + +### Task 2: `PersistPlan` + `persist_plan()` for direct-wrap backends + +Replaces `remote_launch_command`. Covers `plain/tmux/abduco/dtach`; zellij/shpool land in Tasks 3–4 (return `Plain`-shaped output until then is NOT acceptable — implement their arms as `todo!()`-free explicit branches that Tasks 3/4 fill; here we route them through the direct path with a placeholder that Task 3/4 replace, so keep them out of this task's tests). + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` (`remote_launch_command` → `persist_plan`; update its 2 call sites and the `tmux:`-bool test constructors) +- Test: same file + +**Interfaces:** +- Consumes: `remote_launch_command`'s existing INNER-building head (harness/argv/cwd/quoting). +- Produces: + ```rust + struct PersistPlan { + remote_command: String, + extra_file: Option<(String, Vec)>, // (remote path, contents) — zellij layout + post_note: Option, // shpool guidance + } + fn persist_plan(harness: Harness, session_id: &str, cwd: Option<&str>, backend: PersistBackend, home: &str) -> PersistPlan + ``` + `home` is the resolved remote home (for zellij layout path in Task 3). + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_plan_direct_wrap_backends() { + let id = "sess-1"; + let plain = persist_plan(Harness::Claude, id, None, PersistBackend::Plain, "/home/u"); + assert_eq!(plain.remote_command, "claude -r sess-1"); + assert!(plain.extra_file.is_none() && plain.post_note.is_none()); + + let tmux = persist_plan(Harness::Claude, id, Some("/srv/w"), PersistBackend::Tmux, "/home/u"); + assert_eq!( + tmux.remote_command, + "tmux new-session -A -s path-sess-1 'cd /srv/w && claude -r sess-1'" + ); + + let abduco = persist_plan(Harness::Claude, id, None, PersistBackend::Abduco, "/home/u"); + assert_eq!(abduco.remote_command, "abduco -A path-sess-1 sh -c 'claude -r sess-1'"); + + let dtach = persist_plan(Harness::Claude, id, None, PersistBackend::Dtach, "/home/u"); + assert_eq!( + dtach.remote_command, + "dtach -A /tmp/path-dtach-sess-1 -z sh -c 'claude -r sess-1'" + ); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p path-cli --lib persist_plan_direct_wrap_backends` +Expected: FAIL — `persist_plan`/`PersistPlan` not found. + +- [ ] **Step 3: Write minimal implementation** + +Add the struct + function; keep the INNER-building head from `remote_launch_command`. (zellij/shpool arms delegate to a `TODO(task-3/4)` explicit branch — implement as shown so it compiles and the direct arms pass; Tasks 3–4 replace the marked arms.) +```rust +struct PersistPlan { + remote_command: String, + extra_file: Option<(String, Vec)>, + post_note: Option, +} + +fn persist_plan( + harness: Harness, + session_id: &str, + cwd: Option<&str>, + backend: PersistBackend, + home: &str, +) -> PersistPlan { + // INNER — identical to the old remote_launch_command head. + let launch: Vec = std::iter::once(harness.name().to_string()) + .chain(argv_for(harness, session_id).iter().map(|a| shell_quote(a))) + .collect(); + let inner = launch.join(" "); + let inner = match cwd { + Some(dir) => format!("cd {} && {inner}", shell_quote(dir)), + None => inner, + }; + let name = format!("path-{}", session_id.replace(['.', ':'], "-")); + + let mut extra_file = None; + let mut post_note = None; + let remote_command = match backend { + PersistBackend::Plain => inner, + PersistBackend::Tmux => format!( + "tmux new-session -A -s {} {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + PersistBackend::Abduco => format!( + "abduco -A {} sh -c {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + PersistBackend::Dtach => format!( + "dtach -A {} -z sh -c {}", + shell_quote(&format!("/tmp/path-dtach-{}", session_id.replace(['.', ':'], "-"))), + shell_single_quote(&inner) + ), + PersistBackend::Zellij => zellij_plan(&name, &inner, home, &mut extra_file), + PersistBackend::Shpool => shpool_plan(&name, &inner, &mut post_note), + }; + PersistPlan { remote_command, extra_file, post_note } +} +``` +Add temporary stubs so it compiles (Tasks 3–4 replace them): +```rust +fn zellij_plan(name: &str, _inner: &str, _home: &str, _extra: &mut Option<(String, Vec)>) -> String { + // TODO(task-3): layout-wrap. Temporary: attach/create by name. + format!("zellij attach --create {}", shell_quote(name)) +} +fn shpool_plan(name: &str, _inner: &str, _note: &mut Option) -> String { + // TODO(task-4): attach-only + post_note. + format!("shpool attach {}", shell_quote(name)) +} +``` +Then replace the two `remote_launch_command(...)` call sites in `run_remote` with `persist_plan(Harness::Claude, &session_id, launch_cwd.as_deref(), backend, &home)` — but `backend` doesn't exist until Task 8; for now pass `PersistBackend::Tmux` if `args.tmux` else `PersistBackend::Plain`, and use `.remote_command`. Delete `remote_launch_command` and update the two `remote_launch_command_*` tests to call `persist_plan(...).remote_command`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p path-cli --lib persist_plan_direct_wrap_backends persist && cargo test -p path-cli resume` +Expected: PASS (all resume tests green). + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): persist_plan + PersistPlan replacing remote_launch_command (direct-wrap backends)" +``` + +--- + +### Task 3: zellij layout-wrap + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`zellij_plan`); Test: same file. + +**Interfaces:** +- Produces: `zellij_plan` fills `extra_file = Some(("/.cache/path/zellij-.kdl", ))` and returns `zellij --session --layout `. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_plan_zellij_ships_layout() { + let p = persist_plan(Harness::Claude, "sess-1", Some("/srv/w"), PersistBackend::Zellij, "/home/u"); + assert_eq!( + p.remote_command, + "zellij --session path-sess-1 --layout /home/u/.cache/path/zellij-path-sess-1.kdl" + ); + let (path, body) = p.extra_file.expect("layout shipped"); + assert_eq!(path, "/home/u/.cache/path/zellij-path-sess-1.kdl"); + let body = String::from_utf8(body).unwrap(); + assert!(body.contains("cd /srv/w && claude -r sess-1"), "body: {body}"); + assert!(body.contains("pane"), "must be a KDL layout: {body}"); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib persist_plan_zellij_ships_layout` +Expected: FAIL — assertion (stub returns `attach --create`, no extra_file). + +- [ ] **Step 3: Implement** +```rust +fn zellij_plan( + name: &str, + inner: &str, + home: &str, + extra: &mut Option<(String, Vec)>, +) -> String { + let layout_path = format!("{home}/.cache/path/zellij-{name}.kdl"); + // Single-pane layout that runs INNER via the shell. `close_on_exit` + // keeps the pane if the harness exits so output stays visible. + let kdl = format!( + "layout {{\n pane command=\"sh\" {{\n args \"-c\" {inner:?}\n }}\n}}\n" + ); + *extra = Some((layout_path.clone(), kdl.into_bytes())); + format!( + "zellij --session {} --layout {}", + shell_quote(name), + shell_quote(&layout_path) + ) +} +``` +Note: the `.cache/path` dir is created by the ship step (Task 9 mkdirs the `extra_file`'s parent). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib persist_plan_zellij_ships_layout` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): zellij layout-wrap persistence backend" +``` + +--- + +### Task 4: shpool attach-only + post_note + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`shpool_plan`); Test: same file. + +**Interfaces:** +- Produces: `shpool_plan` returns `shpool attach ` and sets `post_note = Some("In the shpool session, run: ")`. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_plan_shpool_attach_only_with_note() { + let p = persist_plan(Harness::Claude, "sess-1", Some("/srv/w"), PersistBackend::Shpool, "/home/u"); + assert_eq!(p.remote_command, "shpool attach path-sess-1"); + let note = p.post_note.expect("shpool note"); + assert!(note.contains("cd /srv/w && claude -r sess-1"), "note: {note}"); + assert!(p.extra_file.is_none()); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib persist_plan_shpool_attach_only_with_note` +Expected: FAIL — no post_note. + +- [ ] **Step 3: Implement** +```rust +fn shpool_plan(name: &str, inner: &str, note: &mut Option) -> String { + *note = Some(format!( + "shpool has no command arg — in the persistent shell, run:\n {inner}" + )); + format!("shpool attach {}", shell_quote(name)) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib persist_plan_shpool_attach_only_with_note` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): shpool attach-only persistence backend + post-note" +``` + +--- + +### Task 5: `Transport` enum + `launch_invocation` seam + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs`; Test: same file. + +**Interfaces:** +- Produces: `enum Transport { Ssh, Mosh, Et }` (derives `clap::ValueEnum`, `Copy`, `Clone`, `PartialEq`, `Eq`, `Debug`); `fn launch_invocation(transport: Transport, remote: &str, remote_cmd: &str) -> Result<(String, Vec)>`. `Ssh` delegates to existing `ssh_invocation_tty(remote, remote_cmd, true)`; `Mosh`/`Et` bail. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn launch_invocation_ssh_and_deferred_transports() { + let (bin, argv) = launch_invocation(Transport::Ssh, "ssh://h", "claude -r x").unwrap(); + assert_eq!(bin, "ssh"); + assert_eq!(argv, vec!["-t".to_string(), "h".to_string(), "claude -r x".to_string()]); + + let err = launch_invocation(Transport::Mosh, "ssh://h", "claude -r x").unwrap_err(); + assert!(err.to_string().contains("not yet supported"), "{err}"); + assert!(launch_invocation(Transport::Et, "ssh://h", "x").is_err()); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib launch_invocation_ssh_and_deferred_transports` +Expected: FAIL — not found. + +- [ ] **Step 3: Implement** +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Transport { + Ssh, + Mosh, + Et, +} + +/// Carry the persist-wrapped remote command over the chosen transport. +/// v1 implements only ssh; mosh/et are reserved (spec: Transport axis). +fn launch_invocation( + transport: Transport, + remote: &str, + remote_cmd: &str, +) -> Result<(String, Vec)> { + match transport { + Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), + Transport::Mosh => anyhow::bail!( + "--via mosh is not yet supported (reserved); use --via ssh" + ), + Transport::Et => anyhow::bail!( + "--via et is not yet supported (reserved); use --via ssh" + ), + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib launch_invocation_ssh_and_deferred_transports` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): Transport enum + launch_invocation seam (ssh; mosh/et reserved)" +``` + +--- + +### Task 6: `remote_which` probe on `ExecStrategy` + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`trait ExecStrategy`, `RealExec` impl, `RecordingExec` impl + fields); Test: same file. + +**Interfaces:** +- Produces: `fn remote_which(&self, target: &SshTarget, bins: &[&str]) -> Result>` on `ExecStrategy`. `RealExec` runs one exec channel: `for b in bins; do command -v "$b" >/dev/null 2>&1 && echo "$b"; done`. `RecordingExec` returns a canned set (new field `available: BTreeSet`, default all). + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn recording_exec_remote_which_returns_canned_set() { + let rec = RecordingExec::with_available(["tmux", "dtach"]); + let got = rec + .remote_which(&SshTarget { user: None, host: "h".into(), port: None }, &["tmux", "zellij", "dtach"]) + .unwrap(); + assert!(got.contains("tmux") && got.contains("dtach")); + assert!(!got.contains("zellij")); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib recording_exec_remote_which_returns_canned_set` +Expected: FAIL — no `remote_which` / `with_available`. + +- [ ] **Step 3: Implement** + +Add to the trait: +```rust +/// Which of `bins` exist on the remote (`command -v`). One exec channel. +fn remote_which( + &self, + target: &SshTarget, + bins: &[&str], +) -> Result>; +``` +`RealExec` impl (uses `with_conn` + `exec_channel_capture`, like `remote_home`'s SCP branch): +```rust +fn remote_which( + &self, + target: &SshTarget, + bins: &[&str], +) -> Result> { + if bins.is_empty() { + return Ok(Default::default()); + } + let probe = bins + .iter() + .map(|b| format!("command -v {} >/dev/null 2>&1 && echo {}", shell_single_quote(b), shell_single_quote(b))) + .collect::>() + .join("; "); + self.with_conn(target, |conn| { + let out = exec_channel_capture(&conn.sess, &probe)?; + Ok(out.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()) + }) +} +``` +`RecordingExec`: add field `available: std::collections::BTreeSet`, constructor `with_available`, and impl: +```rust +pub fn with_available<'a>(bins: impl IntoIterator) -> Self { + Self { available: bins.into_iter().map(String::from).collect(), ..Default::default() } +} +// in impl ExecStrategy for RecordingExec: +fn remote_which(&self, _t: &SshTarget, bins: &[&str]) -> Result> { + Ok(bins.iter().filter(|b| self.available.contains(**b)).map(|b| b.to_string()).collect()) +} +``` +Default `RecordingExec` (via `Default`) has an empty `available`; existing tests that expect a working launch must use `with_available(["tmux","abduco","dtach","zellij","shpool"])` OR pass an explicit `--persist` that skips probing (Task 8 makes `--persist` skip the probe requirement). Keep existing tests green by having them pass `--persist plain` (no probe needed) or `with_available`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib recording_exec_remote_which_returns_canned_set` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): ExecStrategy::remote_which probe (+ RecordingExec canned availability)" +``` + +--- + +### Task 7: CLI flags `--persist` / `--via` + `--tmux` deprecation + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`struct ResumeArgs`); Test: same file. + +**Interfaces:** +- Produces on `ResumeArgs`: `pub persist: Option` (`#[arg(long, value_enum, requires = "remote")]`), `pub via: Transport` (`#[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")]`). Keep `pub tmux: bool`. New `fn resolve_persist_flag(args: &ResumeArgs) -> Result>`: returns `Some(Tmux)` for `--tmux` (with a deprecation eprintln), `Some(x)` for `--persist x`, error if both set, `None` if neither. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn resolve_persist_flag_maps_tmux_and_rejects_conflict() { + let mut a = crate::cmd_resume::test_args("claude-x"); // helper builds a minimal ResumeArgs + a.remote = Some("ssh://h".into()); + a.tmux = true; + assert_eq!(resolve_persist_flag(&a).unwrap(), Some(PersistBackend::Tmux)); + + a.persist = Some(PersistBackend::Dtach); + assert!(resolve_persist_flag(&a).is_err()); // both --tmux and --persist + + a.tmux = false; + assert_eq!(resolve_persist_flag(&a).unwrap(), Some(PersistBackend::Dtach)); +} +``` +(If no `test_args` helper exists, construct `ResumeArgs { .. }` inline with all fields — check the struct's current fields first.) + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib resolve_persist_flag_maps_tmux_and_rejects_conflict` +Expected: FAIL — fields/fn missing. + +- [ ] **Step 3: Implement** + +Add fields to `ResumeArgs`: +```rust +/// Remote session-persistence backend. Skips the picker. Requires --remote. +#[arg(long, value_enum, requires = "remote")] +pub persist: Option, + +/// Transport for the interactive launch. ssh (default); mosh/et reserved. +#[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")] +pub via: Transport, +``` +Add the resolver: +```rust +fn resolve_persist_flag(args: &ResumeArgs) -> Result> { + match (args.tmux, args.persist) { + (true, Some(_)) => anyhow::bail!("--tmux is a deprecated alias for --persist tmux; don't combine it with --persist"), + (true, None) => { + eprintln!("note: --tmux is deprecated; use --persist tmux"); + Ok(Some(PersistBackend::Tmux)) + } + (false, p) => Ok(p), + } +} +``` +Update every `ResumeArgs { … }` construction in tests to add `persist: None, via: Transport::Ssh,`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib resolve_persist_flag_maps_tmux_and_rejects_conflict && cargo test -p path-cli resume` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): --persist and --via flags (+ --tmux deprecation alias)" +``` + +--- + +### Task 8: Backend candidate assembly + pre-selection (pure) + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs`; Test: same file. + +**Interfaces:** +- Produces: + ```rust + fn persist_candidates(available: &std::collections::BTreeSet) -> Vec // plain + available, in DISPLAY_ORDER + fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend // priority; plain if none + ``` + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_candidates_and_preference() { + use std::collections::BTreeSet; + let avail: BTreeSet = ["dtach", "zellij"].iter().map(|s| s.to_string()).collect(); + let cands = persist_candidates(&avail); + // DISPLAY_ORDER filtered to available + always Plain, in order. + assert_eq!(cands, vec![PersistBackend::Zellij, PersistBackend::Dtach, PersistBackend::Plain]); + assert_eq!(preferred_backend(&avail), PersistBackend::Zellij); // tmux absent -> zellij + + let none: BTreeSet = BTreeSet::new(); + assert_eq!(persist_candidates(&none), vec![PersistBackend::Plain]); + assert_eq!(preferred_backend(&none), PersistBackend::Plain); + + let with_tmux: BTreeSet = ["tmux", "shpool"].iter().map(|s| s.to_string()).collect(); + assert_eq!(preferred_backend(&with_tmux), PersistBackend::Tmux); // shpool never preferred over tmux +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib persist_candidates_and_preference` +Expected: FAIL — not found. + +- [ ] **Step 3: Implement** +```rust +fn persist_candidates(available: &std::collections::BTreeSet) -> Vec { + PersistBackend::DISPLAY_ORDER + .into_iter() + .filter(|b| match b.bin() { + None => true, // Plain always offered + Some(bin) => available.contains(bin), + }) + .collect() +} + +fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend { + const PRIORITY: [PersistBackend; 4] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + ]; + PRIORITY + .into_iter() + .find(|b| b.bin().is_some_and(|bin| available.contains(bin))) + .unwrap_or(PersistBackend::Plain) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib persist_candidates_and_preference` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): persist candidate assembly + preferred-backend selection" +``` + +--- + +### Task 9: Wire `run_remote` (probe → pick → plan → ship → note → launch) + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`run_remote`); Test: same file + `crates/path-cli/tests/resume.rs`. + +**Interfaces:** +- Consumes: everything above. Picker UI: for the interactive selection, mirror the existing harness picker (`crate::skim_picker` usage in `share`/`resume` — read `pick_harness`/its call site and follow the same pattern). Selection resolution: + 1. `resolve_persist_flag(args)?` → if `Some(b)`, use `b` (skip probe/picker). + 2. else probe `remote_which(&target, &bins_of_all_backends)`; `cands = persist_candidates(&avail)`. + 3. if interactive (stdin+stderr TTY): fuzzy-pick from `cands` with `preferred_backend(&avail)` pre-selected; else use `preferred_backend(&avail)` (print a note if it fell back to `Plain`). + +- [ ] **Step 1: Write the failing integration test** (in `crates/path-cli/tests/resume.rs`, using `RecordingExec::with_available`) +```rust +#[test] +fn remote_resume_persist_dtach_records_launch_and_ships() { + let rec = RecordingExec::with_available(["dtach"]); + let mut args = /* build ResumeArgs for a file input */; + args.remote = Some("ssh://h".into()); + args.harness = Some(Harness::Claude); + args.persist = Some(PersistBackend::Dtach); + run_with_strategy(&args, &rec).unwrap(); + let cap = rec.captured(); + assert_eq!(cap.binary, "ssh"); + assert!(cap.args.iter().any(|a| a.contains("dtach -A /tmp/path-dtach-")), "{:?}", cap.args); +} +``` +(Model construction on the existing `remote_resume_*` integration tests in the same file.) + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --test resume remote_resume_persist_dtach` +Expected: FAIL — `persist`/wiring absent. + +- [ ] **Step 3: Implement** + +In `run_remote`, after `let home = exec.remote_home(...)?` and before the ship step, resolve the backend: +```rust +let backend = match resolve_persist_flag(args)? { + Some(b) => b, + None => { + let bins: Vec<&str> = PersistBackend::DISPLAY_ORDER.iter().filter_map(|b| b.bin()).collect(); + let avail = exec.remote_which(&target, &bins) + .with_context(|| format!("probing persistence backends on {remote}"))?; + let cands = persist_candidates(&avail); + let preferred = preferred_backend(&avail); + if io_is_interactive() { // stdin+stderr TTY, mirror existing picker guard + pick_persist_backend(&cands, preferred)? // fuzzy UI mirroring pick_harness + } else { + if preferred == PersistBackend::Plain { + eprintln!("note: no persistence backend on remote; launching plain (survives nothing)"); + } + preferred + } + } +}; +let plan = persist_plan(Harness::Claude, &session_id, launch_cwd.as_deref(), backend, &home); +``` +Then: build `projects_dir`/ship JSONL as today, PLUS ship `plan.extra_file` (mkdir its parent, write it): +```rust +if let Some((path, body)) = &plan.extra_file { + if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { + exec.remote_mkdirs(&target, parent).with_context(|| format!("creating {parent} on {remote}"))?; + } + exec.remote_write(&target, path, body).with_context(|| format!("shipping {path} to {remote}"))?; +} +if let Some(note) = &plan.post_note { + eprintln!("{note}"); +} +``` +Finally replace the launch: +```rust +let (binary, argv) = launch_invocation(args.via, remote, &plan.remote_command)?; +``` +Implement `pick_persist_backend` mirroring the harness picker (read `skim_picker.rs` + the `pick_harness` call site; present `describe()` rows, pre-select `preferred`). Implement `io_is_interactive()` if not already present (reuse the existing TTY check used by `p import`). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --test resume && cargo test -p path-cli --lib cmd_resume && cargo clippy -p path-cli -- -D warnings` +Expected: PASS + clean. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs crates/path-cli/tests/resume.rs +git commit -m "feat(resume): wire persistence picker + transport through run_remote" +``` + +--- + +### Task 10: Docs + version bump + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (module docs), `CLAUDE.md` (resume bullet), `docs/agents/remote-resume-targets.md`, `crates/path-cli/Cargo.toml`, root `Cargo.toml`, `site/_data/crates.json`, `CHANGELOG.md`. + +- [ ] **Step 1: Update module docs + CLAUDE.md** + +In `cmd_resume.rs` module docs, document `--persist ` (six backends, three mechanisms), `--via ssh|mosh|et`, and that reachability rides `~/.ssh/config` aliases. In `CLAUDE.md`'s `path resume` bullet, add a sentence on the persistence picker + `--via`. + +- [ ] **Step 2: Version bump** (per the release checklist) + +Bump `path-cli` minor in `crates/path-cli/Cargo.toml`, root `Cargo.toml` `[workspace.dependencies]`, `site/_data/crates.json`, and add a `CHANGELOG.md` entry describing the persistence picker + `--via`. + +- [ ] **Step 3: Verify build + full suite** + +Run: `cargo test -p path-cli && cargo clippy --workspace -- -D warnings` +Expected: PASS + clean. + +- [ ] **Step 4: Commit** +```bash +git add -A +git commit -m "docs(resume): document persistence picker + --via; bump path-cli" +``` + +--- + +## Self-Review + +- **Spec coverage:** `--persist` 6 backends (Tasks 1–4, 8–9); 3 mechanisms — direct (T2), layout/zellij (T3), attach-only/shpool (T4); `--via ssh|mosh|et` seam (T5); probe (T6); flags + `--tmux` deprecation (T7); picker + pre-selection + non-TTY default (T8–9); reachability via alias (already shipped; documented T10); tests throughout; version bump (T10). Covered. +- **Open questions from spec** (zellij `--session … --layout` on existing session; libssh2 ProxyCommand ship fallback) are intentionally out of v1 — noted in spec, not tasks. +- **Type consistency:** `PersistBackend`, `PersistPlan { remote_command, extra_file, post_note }`, `Transport`, `persist_plan(harness, session_id, cwd, backend, home)`, `remote_which(target, bins) -> BTreeSet`, `persist_candidates`/`preferred_backend`, `resolve_persist_flag`, `launch_invocation(transport, remote, remote_cmd)` — used consistently across tasks. +- **Placeholder scan:** the only forward-refs are the Task-2 zellij/shpool stubs, explicitly replaced in Tasks 3–4; `pick_persist_backend`/`io_is_interactive` are specified to mirror the existing harness picker (named files to read at execution). diff --git a/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md b/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md new file mode 100644 index 00000000..4e523451 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md @@ -0,0 +1,210 @@ +# Remote resume: session-persistence backend picker + +**Status:** design +**Date:** 2026-07-23 +**Area:** `crates/path-cli/src/cmd_resume.rs` (`--remote` path) + +## Problem + +`path resume --remote ` launches an interactive +`ssh -t host 'claude -r '`. When the SSH link drops, the harness dies and +the resumed work is lost. The branch already added an opt-in `--tmux` flag that +wraps the launch in `tmux new-session -A -s path-` so it survives drops and +is re-attachable — but it is (a) opt-in, (b) hardcoded to tmux, and (c) blind to +whether the remote even has tmux. + +We want the persistence layer to be a **first-class, selectable choice**, modeled +on the existing harness picker (`share`, `resume`, `p import`): probe what the +remote supports, present the options in the fuzzy picker, pre-select a sane +default, and let a flag skip the picker entirely. + +## Goals + +- Choose a remote session-persistence backend the same way we choose a harness: + interactive fuzzy picker by default, `--persist ` to skip it. +- Only offer backends actually installed on the remote (probe once, up front). +- Default to a working persistence backend when one is available, falling back + to a plain launch when none is — never fail *because* a backend is missing. +- Support six backends spanning three launch mechanisms (below). +- Preserve `--tmux` as a deprecated alias so existing usage keeps working. + +## Non-goals + +- **Implementing** transport-layer persistence (mosh, Eternal Terminal) in v1. + These wrap the SSH *connection*, not the remote command — a separate axis from + `--persist`. v1 reserves the flag shape (`--via ssh|mosh`, below) but only + implements `ssh`; mosh lands later. +- Restore-after-reboot / resurrection semantics beyond what each backend already + does on its own. +- Local (non-`--remote`) resume is unchanged. + +## The four independent layers + +Remote resume touches four *separate* concerns that stack rather than compete. +Conflating any two is the classic mistake (expecting tmux to solve roaming, or +mosh to solve NAT). This design keeps them separate: + +| Layer | Concern | How we express it | +|---|---|---| +| **Reachability** | Can I even connect? (NAT, mesh, broker) | **`~/.ssh/config`** — not a flag | +| **Connection-survival** | Does the link survive roaming/sleep? | **`--via ssh\|mosh\|et`** | +| **Session-survival** | Does work survive a client death? | **`--persist `** | +| **Workspace** | Panes/tabs/layout | a `--persist` backend (zellij) | + +### Reachability is delegated to ssh config (no flag) + +Tailscale SSH, WireGuard, Cloudflare Tunnel (`cloudflared access ssh`), +Teleport, Nebula, NetBird — all of these are expressed as a `Host` block +(`ProxyCommand`, `ProxyJump`, or just a mesh hostname). Because `--remote` +accepts a `~/.ssh/config` alias (see the parsing change on this branch), +resume rides every one of them for free — the CLI launch honors the full config. + +**Known limitation:** the **libssh2 probe/ship half does not honor +`ProxyCommand`/`ProxyJump`** — libssh2 dials a raw TCP socket. So a host reachable +*only* through a broker/jump (Cloudflare Tunnel, a bastion) will launch fine but +**fail at the ship step**. Mitigation (candidate, not v1): when the libssh2 dial +fails and the config has a `ProxyCommand`, fall back to shipping over the `ssh` +CLI (`ssh host 'cat > dest'` / scp), which honors the config. Recorded as an open +question; v1 documents the limitation and errors clearly. + +## Transport axis (`--via`) — forward-looking + +Connection-survival only. Orthogonal to `--persist`; the belt-and-suspenders +combo is `--via mosh` + a `--persist` backend. + +- `--via ssh` — **default, the only v1 implementation.** Interactive + `ssh -t host ''`. +- `--via mosh` — **deferred.** `mosh host -- ''` (mosh owns + the TTY, so no `-t`; needs `mosh-server` on the remote → joins the up-front + probe). +- `--via et` — **deferred.** Eternal Terminal: `et host -c ''` + (TCP auto-reconnect, native scrollback; needs `etserver` + its port → probe). + +The persistence wrapping is identical across transports — `--via` only swaps how +the wrapped command is carried. Designing the flag in now keeps the launch code +factored around a `Transport` seam so mosh/et are additive drop-ins, not a +refactor. The libssh2 probe/ship half is unaffected by `--via`. + +## Backends and launch mechanisms + +Let `INNER` be the existing inner launch string: `[cd && ]claude -r `. +Session name is `path-` (already used by the tmux path). + +Backends fall into **three mechanisms**: + +### 1. Direct command-wrap (hand them INNER) +| Backend | Remote command | +|---|---| +| `plain` | `INNER` | +| `tmux` | `tmux new-session -A -s path- 'INNER'` | +| `abduco` | `abduco -A path- sh -c 'INNER'` | +| `dtach` | `dtach -A /tmp/path-dtach- -z sh -c 'INNER'` | + +Reattach on a later resume is automatic: tmux `-A`, abduco `-A`, dtach `-A` all +attach-or-create by name. + +### 2. Layout-wrap (zellij) +zellij has no "new named session running command X" one-liner; the supported +path is a KDL layout file with the command in a pane. + +- **Ship** an extra file `~/.cache/path/zellij-.kdl` alongside the session + JSONL, containing a single pane that runs `INNER`. +- **Launch**: `zellij --session path- --layout ~/.cache/path/zellij-.kdl` + — creates with the layout when new, attaches when the session already exists. +- Reattach later: same command (zellij attaches an existing session). + +### 3. Attach-only (shpool) +`shpool attach ` only ever starts a **shell** — there is no supported way +to run a one-shot command in it. So shpool does **not** auto-run the harness. + +- **Ship** the session JSONL as normal. +- **Launch**: `ssh -t host 'shpool attach path-'` — drops the user into a + persistent shell. +- **Guidance**: before handing off, print the exact command to run: + `run in the shpool session: cd && claude -r `. + +This is honest about shpool's model rather than faking a launch it can't do. + +## User-facing surface + +### Flags +- `--persist ` — `plain|tmux|abduco|dtach|zellij|shpool`. Skips the + picker. Requires `--remote`. +- `--tmux` — **deprecated alias** for `--persist tmux`. Kept working; emits a + one-line deprecation note pointing at `--persist tmux`. Errors if combined + with an explicit `--persist`. +- `--via ` — `ssh` (default) | `mosh` | `et` (both deferred; error + with a "not yet supported" message in v1). Requires `--remote`. See the + Transport axis section. + +### Picker behavior (mirrors the harness picker) +1. After the remote is reachable and home is resolved, **probe** the remote once: + `command -v tmux zellij abduco dtach shpool` in a single exec channel → + the set of available backends. +2. Candidate list = `plain` + every available backend (in a fixed display order: + `tmux, zellij, abduco, dtach, shpool, plain`), each with a short description + of what it buys (`tmux — detachable, survives drops`, `shpool — persistent + shell (attach-only)`, `plain — no persistence`, …). +3. **Pre-select** the highest-priority available backend by the order + `tmux > zellij > abduco > dtach > plain` (shpool is not auto-preferred because + it can't auto-launch). If none installed, pre-select `plain`. +4. **Skip the picker** when: `--persist` given; or `--tmux` given; or not + interactive (no TTY on stdin+stderr) — in which case use the pre-selected + default. Emit a note when falling back to `plain` because nothing was + installed. + +## Internal design + +- New `enum PersistBackend { Plain, Tmux, Abduco, Dtach, Zellij, Shpool }` with: + - `bin() -> Option<&str>` (probe target; `Plain` → `None`). + - `describe() -> &str` (picker row text). + - display/parse for clap (`ValueEnum`) and the picker. +- New `struct PersistPlan { remote_command: String, extra_file: Option<(String, Vec)>, post_note: Option }` + built from `(backend, session_id, launch_cwd)`. `extra_file` is the zellij + layout; `post_note` is the shpool guidance. +- `ExecStrategy` gains `fn remote_which(&self, target, bins: &[&str]) -> Result>` + (single exec channel running `command -v …`), so probing is mockable in tests. +- `run_remote` sequence becomes: resolve/project → connect/home → **probe** → + **resolve backend** (flag or picker) → **build PersistPlan** → ship JSONL + (+ `extra_file`) → print `post_note` → interactive launch of + `plan.remote_command` **via the selected transport**. +- New `enum Transport { Ssh, Mosh, Et }`. A `fn launch_invocation(transport, + remote, remote_cmd) -> (binary, argv)` seam replaces the direct + `ssh_invocation_tty` call: `Ssh` → today's `ssh -t …`; `Mosh`/`Et` → v1 returns + a "not yet supported" error (shape reserved). The probe/ship half never sees + `Transport`. +- `remote_launch_command(harness, id, cwd, tmux: bool)` is replaced by + `persist_plan(harness, id, cwd, backend) -> PersistPlan`. Existing + `tmux: bool` call sites and tests migrate to `PersistBackend`. + +### Quoting +The nested quoting (`ssh 'sh -c '\''cd … && claude …'\'''`) is the sharp edge. +Reuse `shell_single_quote` and add a focused unit test per backend asserting the +exact remote command string for a representative `INNER` (with and without +`--cwd`), so the escaping is pinned. + +## Testing + +- Unit: `persist_plan` output string per backend (× cwd / no-cwd); `PersistBackend` + clap parse + display; picker candidate assembly + pre-selection given a probed + availability set; `--tmux` → tmux mapping + conflict-with-`--persist` error. +- `remote_which` exercised via `RecordingExec` (records the probe, returns a + canned availability set). +- Integration (`tests/resume.rs`, `RecordingExec`): `--persist tmux/abduco/dtach` + record the expected `remote_command`; zellij records the shipped layout file; + shpool records the attach command + post-note; non-TTY defaults to the + pre-selected backend. +- Keep the existing `remote_resume_*` tests green (migrated to the new enum). + +## Rollout / compat + +- Pre-1.0; `--tmux` stays as a deprecated alias (no hard break). +- Bump `path-cli` per the release checklist (minor: additive CLI surface). + +## Open questions + +- zellij `--session X --layout Y` when the session already exists: confirm it + attaches (ignoring the layout) rather than erroring. If it errors, launch logic + branches on "session exists" (probe with `zellij list-sessions`). +- dtach socket dir: `/tmp/path-dtach-` is world-readable-parent; acceptable + for a single-user box, but consider `${XDG_RUNTIME_DIR:-/tmp}`. diff --git a/site/_data/crates.json b/site/_data/crates.json index 99bcad0b..b6e8d9e4 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -9,7 +9,7 @@ }, { "name": "toolpath-convo", - "version": "0.11.1", + "version": "0.12.0", "description": "Provider-agnostic conversation types, traits, and Toolpath-Path derivation", "docs": "https://docs.rs/toolpath-convo", "crate": "https://crates.io/crates/toolpath-convo", @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.1", + "version": "0.12.2", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.16.0", + "version": "0.18.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli",