diff --git a/CHANGELOG.md b/CHANGELOG.md index 13265c85..47b75e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,94 @@ 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 three backends: `tmux`, `dtach`, and `plain` (none). + `tmux` wraps the launch in an attach-or-create named session; `dtach` + uses `dtach -A -r winch sh -c '…'` (`-r winch` makes Claude's + self-repainting TUI redraw on reattach); `plain` is a bare launch. The + remote is probed for installed backends; `--persist X` skips the + picker; a non-TTY invocation auto-picks the best available (tmux > + dtach > plain). `--tmux` is a **deprecated alias** for `--persist + tmux` — combining both is an error. + - **Why only two real backends.** The job is narrow — hold one + `claude -r` PTY across a disconnect — and Claude collapses the usual + multiplexer trade-offs: `claude -r` *is* the crash-recovery layer + (conversation state lives in Claude Code's session files, not the + terminal, so you re-run `-r` rather than reattach to a corpse), and + Claude's Ink TUI repaints itself (no server-side terminal model + needed). That deletes tmux's two distinguishing wins for this use, + leaving `tmux` (tested default, ubiquitous, introspectable) and + `dtach` (no daemon, socket-is-the-API, argv-exec so no quoting-class + bug). `abduco`/`zellij`/`shpool` were evaluated and cut — they added + daemons, KDL layouts, and nesting guards for zero gain here. Both + shipped backends are verified live end-to-end (tmux: persistence + + real `claude -r` resume; dtach: socket lifecycle + argv fidelity). + - New `--transport` flag names the carrier protocol for the + interactive launch stream. One value — `ssh` — implemented and + default; the flag exists as the extension seam. It selects *carriage + only*, not routing/reachability (`~/.ssh/config`) or credentials. + - 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`. **The ship now + honors it too:** libssh2 dials a raw socket and can't traverse + `ProxyJump`/`ProxyCommand`, so when the host declares a proxy (or a + direct dial fails), the probe/ship/realpath fall back to the `ssh` + CLI (`ssh host 'mkdir -p … && cat > …'`), which honors the full + config. Previously such a host launched fine but silently failed at + the ship step. + - **Symlink-safe `--cwd`**: the remote launch cwd is now canonicalized + over SFTP (`remote_realpath`) before keying the shipped project dir, + so a `--cwd` through a symlink (e.g. macOS `/tmp` → `/private/tmp`) + ships to the *physical* path Claude's `getcwd` reports — previously + the session shipped to a dir `claude -r` never scanned and silently + failed to resume. Found and fixed by a live end-to-end run. + +## 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..de9be048 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -35,6 +35,90 @@ //! 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 holder, chosen from three backends +//! ([`PersistBackend`]): `tmux` (attach-or-create named session), +//! `dtach` (`dtach -A -r winch sh -c '…'` — no daemon, +//! socket-is-the-API, `-r winch` redraws the self-repainting TUI on +//! reattach), and `plain` (no wrapping — dies on disconnect, always +//! offered). The set is deliberately small: `claude -r` reconstructs +//! the conversation on crash and Claude's TUI repaints itself, so a +//! heavier terminal-modeling multiplexer (abduco/zellij/shpool were +//! evaluated) earns nothing for a one-process attach. +//! +//! Without `--persist`, a non-TTY invocation picks the best available +//! backend automatically ([`preferred_backend`]: tmux > 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 (`--transport ssh`) +//! +//! `--transport` selects the carrier protocol for the interactive byte +//! stream ([`launch_invocation`]). Only `ssh` today; the flag exists as +//! the extension seam (a new carrier is a variant + match arm). It is +//! deliberately *not* routing/reachability — that's `~/.ssh/config` — or +//! credentials. +//! +//! ## 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 +164,63 @@ 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, + + /// Carrier protocol for the interactive launch stream. Only `ssh` + /// today; the flag exists as the extension seam. + #[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")] + pub transport: 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 +558,1949 @@ 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<()>; + + /// Canonicalize an existing remote `path` — resolving symlinks to the + /// physical absolute path the remote's `getcwd` reports. Needed + /// because Claude Code keys its project dir on the *canonical* cwd, + /// so on hosts where e.g. `/tmp` → `/private/tmp` (macOS) a `--cwd` + /// through a symlink would otherwise ship the session to a directory + /// `claude -r` never looks in. `path` must already exist. + fn remote_realpath(&self, target: &SshTarget, path: &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>, +} + +/// The transport chosen for a target's file operations. +enum RemoteConn { + /// A live libssh2 session; SFTP channel present unless the server + /// lacks the subsystem (`None` ⇒ SCP/exec-channel fallback). + Libssh2 { + key: SshTarget, + sess: ssh2::Session, + sftp: Option, + }, + /// Shell out to the `ssh` CLI for every op. Used when the host is + /// only reachable via a `ProxyJump`/`ProxyCommand` (libssh2 can't + /// traverse those) or a direct libssh2 dial failed. The CLI honors + /// the full `~/.ssh/config`, so it reaches bastions/tunnels/meshes. + Cli { key: SshTarget }, +} + +impl RemoteConn { + fn key(&self) -> &SshTarget { + match self { + RemoteConn::Libssh2 { key, .. } | RemoteConn::Cli { key } => key, + } + } +} + +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 { + RemoteConn::Libssh2 { + sftp: Some(sftp), .. + } => { + let home = sftp + .realpath(std::path::Path::new(".")) + .context("resolve remote home directory")?; + validate_remote_home(&home.to_string_lossy()) + } + RemoteConn::Libssh2 { + sess, sftp: None, .. + } => validate_remote_home(&exec_channel_capture(sess, "pwd")?), + RemoteConn::Cli { key } => validate_remote_home(&ssh_cli_capture(key, "pwd")?), + }) + } + + fn remote_mkdirs(&self, target: &SshTarget, dir: &str) -> Result<()> { + self.with_conn(target, |conn| match conn { + RemoteConn::Libssh2 { + 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(()) + } + RemoteConn::Libssh2 { + sess, sftp: None, .. + } => { + exec_channel_capture(sess, &format!("mkdir -p {}", shell_single_quote(dir))) + .with_context(|| format!("create remote dir {dir}"))?; + Ok(()) + } + RemoteConn::Cli { key } => { + ssh_cli_capture(key, &format!("mkdir -p {}", shell_single_quote(dir))) + .with_context(|| format!("create remote dir {dir}"))?; + Ok(()) + } + }) + } + + fn remote_realpath(&self, target: &SshTarget, path: &str) -> Result { + // `cd && pwd -P` gives the physical (symlink-resolved) + // directory, matching Claude's getcwd. + let pwd_p = format!("cd {} && pwd -P", shell_single_quote(path)); + self.with_conn(target, |conn| { + let raw = match conn { + // SFTP realpath resolves symlinks + relative components. + RemoteConn::Libssh2 { + sftp: Some(sftp), .. + } => sftp + .realpath(std::path::Path::new(path)) + .with_context(|| format!("realpath {path} on remote"))? + .to_string_lossy() + .to_string(), + RemoteConn::Libssh2 { + sess, sftp: None, .. + } => exec_channel_capture(sess, &pwd_p) + .with_context(|| format!("realpath {path} on remote"))?, + RemoteConn::Cli { key } => ssh_cli_capture(key, &pwd_p) + .with_context(|| format!("realpath {path} on remote"))?, + }; + let real = raw.trim().to_string(); + if real.is_empty() { + anyhow::bail!("realpath for {path} returned nothing"); + } + Ok(real) + }) + } + + fn remote_write(&self, target: &SshTarget, path: &str, data: &[u8]) -> Result<()> { + use std::io::Write; + self.with_conn(target, |conn| match conn { + RemoteConn::Libssh2 { + 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(()) + } + RemoteConn::Libssh2 { + sess, sftp: None, .. + } => { + // SCP protocol upload — libssh2's scp_send, no external binary. + let mut ch = 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(()) + } + RemoteConn::Cli { key } => { + // `ssh host 'cat > '` with the bytes on stdin. + ssh_cli_pipe(key, &format!("cat > {}", shell_single_quote(path)), data) + .with_context(|| format!("write remote file {path}")) + } + }) + } + + 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 = match conn { + RemoteConn::Libssh2 { sess, .. } => exec_channel_capture(sess, &probe)?, + RemoteConn::Cli { key } => ssh_cli_capture(key, &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")) + } +} + +/// Base `ssh` argv for a non-interactive CLI file op: `[-p ]? +/// <[user@]host>`. Uses the real `ssh` binary so `~/.ssh/config` — +/// including `ProxyJump`/`ProxyCommand` — is honored. +fn ssh_cli_base_argv(target: &SshTarget) -> Vec { + let mut argv = Vec::new(); + 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 +} + +/// Run `cmd` on the remote via the `ssh` CLI and return its stdout. +fn ssh_cli_capture(target: &SshTarget, cmd: &str) -> Result { + let mut argv = ssh_cli_base_argv(target); + argv.push(cmd.to_string()); + let out = std::process::Command::new("ssh") + .args(&argv) + .output() + .with_context(|| format!("run `ssh … {cmd}`"))?; + if !out.status.success() { + anyhow::bail!( + "`ssh … {cmd}` failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +/// Run `cmd` on the remote via the `ssh` CLI, feeding `data` to its stdin. +fn ssh_cli_pipe(target: &SshTarget, cmd: &str, data: &[u8]) -> Result<()> { + use std::io::Write; + let mut argv = ssh_cli_base_argv(target); + argv.push(cmd.to_string()); + let mut child = std::process::Command::new("ssh") + .args(&argv) + .stdin(std::process::Stdio::piped()) + .spawn() + .with_context(|| format!("spawn `ssh … {cmd}`"))?; + child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("ssh stdin unavailable"))? + .write_all(data) + .context("write to ssh stdin")?; + let status = child.wait().context("wait for ssh")?; + if !status.success() { + anyhow::bail!("`ssh … {cmd}` failed ({status})"); + } + Ok(()) +} + +/// 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, + /// `ProxyJump`/`ProxyCommand` is configured for this host. libssh2 + /// dials a raw TCP socket and can't traverse a jump/proxy, so the + /// SFTP ship must fall back to the `ssh` CLI (which honors them). + has_proxy: 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; + } + "proxyjump" | "proxycommand" + if !value.is_empty() && !value.eq_ignore_ascii_case("none") => + { + cfg.has_proxy = 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. +/// +/// **Proxy/bastion fallback:** libssh2 dials a raw TCP socket and can't +/// traverse `ProxyJump`/`ProxyCommand`, so if the host's ssh_config +/// declares a proxy — or a direct dial fails (bastion-only host) — this +/// returns a [`RemoteConn::Cli`] that shells out to the `ssh` binary +/// (which honors the full config) for every file op. Without it, a host +/// reachable only through a broker would launch fine but fail at the +/// ship step. +/// +/// `~/.ssh/config` fills the gaps the URL leaves open (HostName, User, +/// Port, IdentityFile); URL values win. known_hosts is still not +/// checked on the libssh2 path — 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); + + // A proxied host can't be reached by libssh2's raw socket — go + // straight to the ssh CLI, which honors ProxyJump/ProxyCommand. + if cfg.has_proxy { + eprintln!( + "note: {} uses a ProxyJump/ProxyCommand in ~/.ssh/config; shipping via the ssh CLI", + target.host + ); + return Ok(RemoteConn::Cli { + key: target.clone(), + }); + } + + 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}"); + // Resolve + bounded connect. A resolve/connect failure on a + // config-less host often means it's only reachable through a jump + // the ssh CLI knows about — fall back rather than abort. + let dialed = addr + .to_socket_addrs() + .ok() + .and_then(|mut a| a.next()) + .and_then(|sock| { + std::net::TcpStream::connect_timeout(&sock, std::time::Duration::from_secs(10)).ok() + }); + let Some(tcp) = dialed else { + eprintln!( + "note: direct connect to {addr} failed; falling back to the ssh CLI (honors ~/.ssh/config)" + ); + return Ok(RemoteConn::Cli { + key: target.clone(), + }); + }; + 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::Libssh2 { + 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>, + /// Paths passed to `remote_realpath`, in call order. + realpaths: 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, + /// When set, `remote_realpath` returns this regardless of input — + /// simulates a symlinked cwd (e.g. `/tmp` → `/private/tmp`). + realpath_as: Option, +} + +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() + } + } + + /// A recorder whose `remote_realpath` always returns `canonical` — + /// simulates a symlinked cwd resolving to a different physical path. + pub fn with_realpath(canonical: &str) -> Self { + Self { + realpath_as: Some(canonical.to_string()), + ..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_realpath` call, in call order. + pub fn realpaths(&self) -> Vec { + self.realpaths.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_realpath(&self, _target: &SshTarget, path: &str) -> Result { + // Record the request; return the canned canonical path when set + // (simulating a symlink), else identity. + self.realpaths.lock().unwrap().push(path.to_string()); + Ok(self.realpath_as.clone().unwrap_or_else(|| path.to_string())) + } + + 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"), + } + + // 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. + // Create + canonicalize the launch cwd up front. Claude keys its + // project dir on the *physical* cwd (symlinks resolved), so we mkdir + // the requested dir, then realpath it — otherwise a `--cwd` through a + // symlink (e.g. macOS `/tmp` → `/private/tmp`) ships the session to a + // dir `claude -r` never scans. No `--cwd` → the SFTP-realpath'd home. + let launch_cwd: Option = match args.cwd.as_ref() { + Some(dir) => { + let normalized = normalize_remote_cwd(dir, &home); + exec.remote_mkdirs(&target, &normalized) + .with_context(|| format!("creating launch dir {normalized} on {remote}"))?; + let canonical = exec + .remote_realpath(&target, &normalized) + .unwrap_or_else(|e| { + eprintln!("note: could not canonicalize {normalized} on {remote} ({e}); using it as-is"); + normalized.clone() + }); + Some(canonical) + } + None => None, + }; + 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 remote_command = persist_plan(Harness::Claude, &session_id, launch_cwd.as_deref(), backend); + + // 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}"); + + // (launch cwd was created + canonicalized before shipping, above.) + + // 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.transport, remote, &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. +/// Reduce a session id to `[A-Za-z0-9_-]` so it's safe to embed in a +/// multiplexer session name and a dtach socket path. 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() +} + +/// Build the remote command that launches the harness under the chosen +/// persistence backend. Two backends earn their place for a +/// single-process `claude -r` attach — `tmux` (tested default, +/// ubiquitous, introspectable) and `dtach` (no daemon, socket-is-the-API, +/// `-r winch` so Claude's self-repainting TUI redraws on reattach) — +/// plus `plain` (no persistence). `claude -r` reconstructs the +/// conversation on crash, so a heavier terminal-modeling multiplexer +/// buys nothing here. +fn persist_plan( + harness: Harness, + session_id: &str, + cwd: Option<&str>, + backend: PersistBackend, +) -> String { + 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-{}", sanitize_session_name(session_id)); + + match backend { + PersistBackend::Plain => inner, + PersistBackend::Tmux => format!( + "tmux new-session -A -s {} {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + // `-A` attach-or-create; `-r winch` sends SIGWINCH on attach so a + // full-screen TUI (Claude is Ink-based) repaints itself. The + // command runs via `sh -c` as a single arg, so no re-splitting. + PersistBackend::Dtach => format!( + "dtach -A {} -r winch sh -c {}", + shell_quote(&format!( + "/tmp/path-dtach-{}", + sanitize_session_name(session_id) + )), + shell_single_quote(&inner) + ), + } +} + +/// 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: tmux > dtach, +/// falling back to Plain if neither is installed. +fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend { + const PRIORITY: [PersistBackend; 2] = [PersistBackend::Tmux, 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)) +} + +/// Carrier protocol for the interactive byte stream of a remote resume. +/// Just `ssh` today — the enum exists as the extension seam (a future +/// carrier is a new variant + match arm). It is deliberately *not* +/// routing/reachability (that's `~/.ssh/config`) or credentials. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum Transport { + #[default] + Ssh, +} + +/// Carry the persist-wrapped remote command over the chosen transport. +fn launch_invocation( + transport: Transport, + remote: &str, + remote_cmd: &str, +) -> Result<(String, Vec)> { + match transport { + Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), + } +} + +/// Remote session-persistence backend for `--remote` resume. Scoped to +/// what a single-process `claude -r` attach actually needs: `tmux` +/// (tested default, ubiquitous, introspectable), `dtach` (no daemon, +/// socket-is-the-API, `-r winch` redraw), and `plain` (none). `claude +/// -r` reconstructs the conversation on crash and Claude's TUI repaints +/// itself, so heavier terminal-modeling multiplexers earn nothing here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum PersistBackend { + Plain, + Tmux, + Dtach, +} + +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::Dtach => Some("dtach"), + } + } + + fn describe(&self) -> &'static str { + match self { + PersistBackend::Plain => "plain — no persistence; dies on disconnect", + PersistBackend::Tmux => "tmux — detachable; survives drops, reattachable", + PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops (-r winch redraw)", + } + } + + /// Fixed picker display / preference order (tmux > dtach > plain). + const DISPLAY_ORDER: [PersistBackend; 3] = [ + PersistBackend::Tmux, + PersistBackend::Dtach, + 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); + assert!(!cfg.has_proxy, "no proxy in this config"); + } + + #[test] + fn ssh_config_detects_proxy_jump_and_command() { + // libssh2 can't traverse these, so connect_remote must route the + // ship through the ssh CLI when either is present for the host. + let jump = parse_ssh_config( + "Host prod\n ProxyJump bastion\n", + "prod", + std::path::Path::new("/h"), + ); + assert!(jump.has_proxy); + + let cmd = parse_ssh_config( + "Host prod\n ProxyCommand cloudflared access ssh --hostname %h\n", + "prod", + std::path::Path::new("/h"), + ); + assert!(cmd.has_proxy); + + // `ProxyCommand none` / no proxy → direct (libssh2) path. + let none = parse_ssh_config( + "Host prod\n ProxyCommand none\n", + "prod", + std::path::Path::new("/h"), + ); + assert!(!none.has_proxy); + let plain = parse_ssh_config("Host prod\n User x\n", "prod", std::path::Path::new("/h")); + assert!(!plain.has_proxy); + } + + #[test] + fn ssh_cli_base_argv_shape() { + // The CLI-fallback file ops invoke `ssh [-p port] [user@]host …`. + assert_eq!( + ssh_cli_base_argv(&SshTarget { + user: Some("dev".into()), + host: "h".into(), + port: Some(2222) + }), + vec!["-p".to_string(), "2222".to_string(), "dev@h".to_string()] + ); + assert_eq!( + ssh_cli_base_argv(&SshTarget { + user: None, + host: "alias".into(), + port: None + }), + vec!["alias".to_string()] + ); + } -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]) - } + #[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); } -} -/// 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 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" + ); } -} -/// 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 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()); + } -/// 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<()>; -} + #[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); + } -/// 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; + #[test] + fn persist_plan_plain_and_tmux_and_dtach() { + let id = "sess-1"; + // plain: bare command; with cwd, just a cd prefix. + assert_eq!( + persist_plan(Harness::Claude, id, None, PersistBackend::Plain), + "claude -r sess-1" + ); + assert_eq!( + persist_plan( + Harness::Claude, + id, + Some("/srv/work"), + PersistBackend::Plain + ), + "cd /srv/work && claude -r sess-1" + ); + // tmux: attach-or-create named session wrapping the command. + assert_eq!( + persist_plan(Harness::Claude, id, None, PersistBackend::Tmux), + "tmux new-session -A -s path-sess-1 'claude -r sess-1'" + ); + assert_eq!( + persist_plan(Harness::Claude, id, Some("/srv/w"), PersistBackend::Tmux), + "tmux new-session -A -s path-sess-1 'cd /srv/w && claude -r sess-1'" + ); + // dtach: attach-or-create on a socket, `-r winch` so the TUI redraws. + assert_eq!( + persist_plan(Harness::Claude, id, None, PersistBackend::Dtach), + "dtach -A /tmp/path-dtach-sess-1 -r winch 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_sanitizes_session_name() { + // A hostile session id must not leak `/` or `..` into the tmux + // session name or the dtach socket path. + let hostile = "../evil"; + let tmux = persist_plan(Harness::Claude, hostile, None, PersistBackend::Tmux); + // The `-s ` argument (5th token) must carry no `/` or `..` — + // unlike the inner harness argv, which legitimately carries the raw + // session id as an argument to `claude -r`. + let name_arg = tmux.split_whitespace().nth(4).unwrap_or_default(); + assert!( + !name_arg.contains('/') && !name_arg.contains(".."), + "tmux session name leaks traversal/slash: {name_arg}" + ); - eprintln!( - "Resuming: {} {} (cwd: {})", - binary, - args.join(" "), - cwd.display() + let dtach = persist_plan(Harness::Claude, hostile, None, PersistBackend::Dtach); + // The socket path is the `-A ` argument (3rd token). + let socket_arg = dtach.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}" ); + } - #[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)); + #[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, + transport: 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 +2529,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + transport: Transport::Ssh, }; let recorder = RecordingExec::default(); @@ -729,6 +2655,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, + transport: 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 +2700,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + transport: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -777,6 +2738,10 @@ mod tests { no_cache: true, // skip cache write in tests force: false, url: None, + remote: None, + tmux: false, + persist: None, + transport: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -838,6 +2803,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + transport: Transport::Ssh, }; let result = resolve_input(&args); @@ -866,6 +2835,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + tmux: false, + persist: None, + transport: Transport::Ssh, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); @@ -1094,6 +3067,16 @@ mod tests { } } + #[test] + fn launch_invocation_ssh() { + 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()] + ); + } + #[test] fn exec_strategy_recording_captures_invocation() { let recorder = RecordingExec::default(); @@ -1111,4 +3094,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::Dtach.bin(), Some("dtach")); + // Display order is stable and complete (tmux > dtach > plain). + assert_eq!(PersistBackend::DISPLAY_ORDER.len(), 3); + assert_eq!(PersistBackend::DISPLAY_ORDER[0], PersistBackend::Tmux); + assert!(PersistBackend::Tmux.describe().contains("detach")); + } + + #[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; + // Only dtach installed → [dtach, plain]; dtach preferred. + let avail: BTreeSet = ["dtach"].iter().map(|s| s.to_string()).collect(); + assert_eq!( + persist_candidates(&avail), + vec![PersistBackend::Dtach, PersistBackend::Plain] + ); + assert_eq!(preferred_backend(&avail), PersistBackend::Dtach); + + let none: BTreeSet = BTreeSet::new(); + assert_eq!(persist_candidates(&none), vec![PersistBackend::Plain]); + assert_eq!(preferred_backend(&none), PersistBackend::Plain); + + // tmux always wins when present. + let both: BTreeSet = ["tmux", "dtach"].iter().map(|s| s.to_string()).collect(); + assert_eq!(preferred_backend(&both), PersistBackend::Tmux); + } + + #[test] + fn persist_backend_describe_covers_every_variant() { + use PersistBackend::*; + let expected = [ + (Plain, "plain — no persistence; dies on disconnect"), + (Tmux, "tmux — detachable; survives drops, reattachable"), + ( + Dtach, + "dtach — tiny detach/attach; survives drops (-r winch redraw)", + ), + ]; + // DISPLAY_ORDER holds every variant, so matching its length keeps + // this test honest when a new backend is added. + assert_eq!( + expected.len(), + PersistBackend::DISPLAY_ORDER.len(), + "describe test must cover every backend" + ); + for (backend, text) in expected { + assert_eq!(backend.describe(), text, "describe() for {backend:?}"); + let name = format!("{backend:?}").to_lowercase(); + assert!( + backend.describe().starts_with(&name), + "{backend:?} blurb should start with its name" + ); + } + } } diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..b9e0ca75 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, + transport: Transport::Ssh, }; let recorder = RecordingExec::default(); @@ -350,3 +356,236 @@ 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 + ); +} + +/// With no `--persist`, `run_remote` probes the remote for available +/// backends and auto-selects the preferred one (tmux over zellij), then +/// wraps the launch in it — the session survives disconnects without the +/// user naming a backend. +#[test] +fn remote_resume_auto_selects_preferred_backend_when_persist_omitted() { + 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-auto-persist"); + 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 = None; // no explicit backend → probe + auto-select + + let rec = RecordingExec::with_available(["zellij", "tmux"]); + run_with_strategy(args, &rec).unwrap(); + + // The session file still ships, and the launch wraps in tmux (preferred). + assert_eq!(rec.writes().len(), 1, "session file should ship"); + let cap = rec.captured(); + assert_eq!(cap.binary, "ssh"); + assert!( + cap.args.iter().any(|a| a.contains("tmux new-session")), + "auto-selected launch should wrap in tmux, got {:?}", + cap.args + ); +} + +/// With no `--persist` and no persistence backend installed on the +/// remote, `run_remote` falls back to a plain launch (`claude -r`) — +/// still ships and launches, just without a detachable wrapper. +#[test] +fn remote_resume_falls_back_to_plain_when_no_backend_available() { + 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-plain-fallback"); + 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 = None; + + let rec = RecordingExec::with_available([]); // nothing installed + run_with_strategy(args, &rec).unwrap(); + + assert_eq!(rec.writes().len(), 1, "session file should still ship"); + let cap = rec.captured(); + assert!( + cap.args.iter().any(|a| a.contains("claude -r")), + "should still launch claude, got {:?}", + cap.args + ); + assert!( + !cap.args.iter().any(|a| a.contains("tmux new-session") + || a.contains("zellij") + || a.contains("dtach") + || a.contains("abduco")), + "no persistence wrapper when none available, got {:?}", + cap.args + ); +} + +/// A `--cwd` through a symlink (e.g. macOS `/tmp` → `/private/tmp`) must +/// key the shipped project dir on the *canonical* path — otherwise +/// `claude -r`, which uses the physical cwd, looks in a dir the session +/// was never shipped to. Regression for the live-verified macOS bug. +#[test] +fn remote_resume_ships_to_canonical_cwd_when_symlinked() { + 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-symlink-cwd"); + 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.cwd = Some(std::path::PathBuf::from("/tmp/work")); // logical + args.persist = Some(PersistBackend::Plain); + + // Remote reports the physical path (symlink resolved). + let rec = RecordingExec::with_realpath("/private/tmp/work"); + run_with_strategy(args, &rec).unwrap(); + + // Session file shipped under the CANONICAL project dir, not the logical one. + let (dest, _) = &rec.writes()[0]; + assert!( + dest.contains("/.claude/projects/-private-tmp-work/"), + "should ship to canonical project dir, got {dest}" + ); + assert!( + !dest.contains("/projects/-tmp-work/"), + "must not ship to the logical (symlinked) dir, got {dest}" + ); + // And the launch cd's into the canonical path too. + let cap = rec.captured(); + assert!( + cap.args.iter().any(|a| a.contains("cd /private/tmp/work")), + "launch should cd into the canonical cwd, got {:?}", + 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..0d7441a0 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, + transport: 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..acfe235d --- /dev/null +++ b/docs/agents/remote-resume-targets.md @@ -0,0 +1,57 @@ +# Remote resume target: exe.dev + +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, with an +`ssh`-CLI fallback for proxied/bastion hosts) for the preflight/ship half. The +host does all the toolpath work and ships the finished session file, so the +target needs only `sshd` + `claude` — **no `path` on the remote**. + +See `crates/path-cli/src/cmd_resume.rs` module docs for the full remote protocol. + +--- + +## 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 a VM** (no `path` install needed — the host ships the session): +```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 +``` +Optionally install a persistence backend if you want `--persist`: +```bash +ssh pathremote.exe.xyz 'sudo apt-get update -qq && sudo apt-get install -y -qq tmux' # or dtach +``` + +**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. + +**Proxied/bastion hosts:** a target reachable only through a `ProxyJump`/ +`ProxyCommand` works too — the ship falls back from libssh2 to the `ssh` CLI +(which honors `~/.ssh/config`) automatically. 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",