From c7caa9088088140bcd2cc1eb1b00ebb27012dbbc Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:02:50 -0400 Subject: [PATCH 01/39] fix(claude): drop empty streaming-seed assistant lines on projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code streams an assistant message as several JSONL lines — the first an empty "seed" (text: "") superseded by the real-text line, both sharing one message.id. The projector wrote that seed back as content:[{"type":"text","text":""}], 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 messages: text content blocks must be non-empty so a 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 no attached tool-result events), re-linking the following turn's parentUuid to the dropped seed's parent via the existing parent_rewrites machinery so the uuid chain stays intact. The group token total is re-expanded onto the surviving line as before. Verified end-to-end by re-exporting a real captured session: empty text blocks 1 -> 0, chain re-linked past the seed. toolpath-claude 0.12.0 -> 0.12.1. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 15 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/project.rs | 109 ++++++++++++++++++++++++-- site/_data/crates.json | 2 +- 6 files changed, 120 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c13fa974..95f729ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to the Toolpath workspace are documented here. +## Project: drop empty streaming-seed assistant lines — 2026-07-23 + +- **`toolpath-claude`** (0.12.1): 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. + ## Derive: resolve duplicate step ids — 2026-07-01 - **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived diff --git a/Cargo.lock b/Cargo.lock index 301920f1..54cfdfc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.0" +version = "0.12.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 835a95e6..4f981bc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.12.1", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 5f463412..0ed4b496 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.0" +version = "0.12.1" 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..8aec6d92 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -140,6 +140,29 @@ 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.) + let is_content_empty = turn.text.trim().is_empty() + && turn.thinking.is_none() + && turn.tool_uses.is_empty() + && turn.delegations.is_empty() + && turn.file_mutations.is_empty() + && !tool_result_events_by_parent.contains_key(&turn.id); + if is_content_empty { + if let Some(parent) = effective_parent { + parent_rewrites.insert(turn.id.clone(), 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() @@ -1103,14 +1126,15 @@ mod tests { .iter() .filter(|e| e.entry_type == "assistant") .collect(); - assert_eq!(assistants.len(), 2); - for entry in &assistants { - let msg = entry.message.as_ref().unwrap(); - assert_eq!(msg.id.as_deref(), Some("msg_A")); - let u = msg.usage.as_ref().expect("every line carries usage"); - assert_eq!(u.output_tokens, Some(997)); - assert_eq!(u.cache_creation_input_tokens, Some(429_831)); - } + // a2 is the content-empty tail of the split; it's dropped (an empty + // text block would be API-invalid on resume). The group total it + // carried is re-expanded onto the surviving line via `group_total`. + assert_eq!(assistants.len(), 1); + let msg = assistants[0].message.as_ref().unwrap(); + assert_eq!(msg.id.as_deref(), Some("msg_A")); + let u = msg.usage.as_ref().expect("surviving line carries usage"); + assert_eq!(u.output_tokens, Some(997)); + assert_eq!(u.cache_creation_input_tokens, Some(429_831)); } #[test] @@ -1158,6 +1182,75 @@ mod tests { assert!(a.iter().all(|t| t.attributed_token_usage.is_none())); } + // ── Empty streaming-seed assistant lines ───────────────────────── + + #[test] + fn test_projector_skips_empty_text_seed_and_relinks_chain() { + // Claude Code streams an assistant message as multiple JSONL lines; + // the first is an empty "seed" (text: "") superseded by the real-text + // line, both sharing one message id. Projecting the empty seed as + // `content:[{type:text, text:""}]` produces an API-invalid message — + // on the next resumed turn Anthropic rejects it with "text content + // blocks must be non-empty". The projector must drop the content-empty + // seed and re-link the following turn's parent to the seed's parent so + // the uuid chain stays intact. + let usage = TokenUsage { + input_tokens: Some(5710), + output_tokens: Some(224), + ..Default::default() + }; + let mut seed = assistant_turn("seed", ""); + seed.parent_id = Some("u1".into()); + seed.group_id = Some("msg_A".into()); + seed.stop_reason = Some("end_turn".into()); + let mut real = assistant_turn("real", "Chocolate, vanilla, strawberry."); + real.parent_id = Some("seed".into()); + real.group_id = Some("msg_A".into()); + real.token_usage = Some(usage); + + let view = make_view( + "sess-1", + vec![user_turn("u1", "icecream flavors"), seed, real], + ); + let convo = ClaudeProjector.project(&view).unwrap(); + + let assistants: Vec<&ConversationEntry> = 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)); + } + // ── Permission-mode preamble ───────────────────────────────────── #[test] diff --git a/site/_data/crates.json b/site/_data/crates.json index dc86dffe..f95edbfc 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.0", + "version": "0.12.1", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", From 3ca1a6d76aa1dcdd0cf4d2f0c6ded0671e5d35c8 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Thu, 23 Jul 2026 13:43:38 -0400 Subject: [PATCH 02/39] feat(resume): add `path resume --remote ` (v0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch a resume to a remote host that has `path` installed, over SSH. The host builds an `ssh` invocation and hands off; the remote does the resolve, harness pick, projection, and exec. Host/remote arg split: - `--remote` is the only host-side arg (never forwarded — would recurse) - every other arg (input, --harness, --cwd, --no-cache, --force, --url) is forwarded into the far-side `path resume` - `--harness` is required with `--remote`: the remote picker has no TTY over a non-interactive SSH session, and the host can't pick either (v0 never resolves the doc locally), so fail fast on the host Toward empathic/toolpath#140. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_resume.rs | 227 +++++++++++++++++++++++++++ crates/path-cli/tests/resume.rs | 72 +++++++++ crates/path-cli/tests/support/mod.rs | 1 + 3 files changed, 300 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index ae9e21d4..9d9e55ac 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -35,6 +35,17 @@ //! integration tests use [`RecordingExec`] to capture //! `(binary, args, cwd)` without launching anything. //! +//! ## Remote (`--remote ssh://[user@]host[:port][/path]`) +//! +//! v0: dispatch the whole resume to a remote host that has `path` +//! installed, over SSH. The host does **no** local resolution or +//! projection — it builds an `ssh` command and hands off. `--remote` +//! itself is the only host-side arg (forwarding it would recurse); +//! every other arg is forwarded verbatim into the far-side +//! `path resume …` so the remote does the resolve, harness pick, +//! projection, and exec. `--harness` is effectively required here: +//! without it the remote's interactive picker has no TTY over SSH. +//! //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` //! for the full design. @@ -83,6 +94,13 @@ 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]`). When set, the + /// resume is dispatched to the remote host rather than exec'ing a + /// local harness. + #[arg(long)] + pub remote: Option, } pub fn run(args: ResumeArgs) -> Result<()> { @@ -92,6 +110,28 @@ pub fn run(args: ResumeArgs) -> Result<()> { /// 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<()> { + // v0 remote resume: forward `path resume ` to a remote host + // over SSH, where `path` is installed and does the resolve itself. + // No local resolution or harness projection happens. + if let Some(remote) = args.remote.as_deref() { + // The picker can't run over a non-interactive SSH session (no + // TTY on the remote), and the host can't run it either — in v0 + // it never resolves the doc, so it knows neither the source + // harness nor what's installed on the remote. Require an + // explicit pin and fail fast here rather than deep on the far + // side. + if args.harness.is_none() { + anyhow::bail!( + "--remote requires --harness : the remote resume runs over a \ + non-interactive SSH session, so the harness picker has no TTY" + ); + } + let remote_cmd = remote_resume_command(&args); + let (binary, argv) = ssh_invocation(remote, &remote_cmd)?; + let cwd = std::env::current_dir()?; + return exec_harness(&binary, &argv, &cwd, exec); + } + let (graph, source_harness) = resolve_input(&args)?; let path = ensure_path_with_agent(&graph)?; @@ -586,6 +626,90 @@ pub(crate) fn exec_harness( strategy.exec(binary, args, cwd) } +/// Build the far-side `path resume …` command line for a v0 remote +/// resume. Every resume arg *except* `--remote` (which is host-only — +/// forwarding it would recurse) is passed through so the remote does +/// the resolve, harness pick, projection, and exec. `--harness` in +/// particular is essential: without it the remote picker needs a TTY +/// the SSH connection doesn't provide. +fn remote_resume_command(args: &ResumeArgs) -> String { + let mut parts = vec![shell_single_quote(&args.input)]; + if let Some(harness) = args.harness { + parts.push("--harness".to_string()); + parts.push(harness_value(harness)); + } + if let Some(cwd) = args.cwd.as_ref() { + parts.push("--cwd".to_string()); + parts.push(shell_single_quote(&cwd.display().to_string())); + } + if args.no_cache { + parts.push("--no-cache".to_string()); + } + if args.force { + parts.push("--force".to_string()); + } + if let Some(url) = args.url.as_ref() { + parts.push("--url".to_string()); + parts.push(shell_single_quote(url)); + } + format!("path resume {}", parts.join(" ")) +} + +/// The lowercase CLI value for a `--harness` choice (e.g. `claude`), +/// taken from clap's own value table so it stays in sync with the enum. +fn harness_value(harness: HarnessArg) -> String { + use clap::ValueEnum; + harness + .to_possible_value() + .expect("HarnessArg has no skipped variants") + .get_name() + .to_string() +} + +/// Build the `ssh` invocation for a v0 remote resume from a full SSH +/// URL (`ssh://[user@]host[:port][/path]`) and an already-built remote +/// command. Returns `("ssh", argv)` where argv is +/// `[-p ,]? <[user@]host> `. +fn ssh_invocation(remote: &str, remote_cmd: &str) -> Result<(String, Vec)> { + let rest = remote + .strip_prefix("ssh://") + .with_context(|| format!("remote must be a full SSH URL (ssh://…), got `{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)), + _ => (authority, None), + }; + if userhost.is_empty() { + anyhow::bail!("SSH URL `{remote}` is missing a host"); + } + + let mut argv = Vec::new(); + if let Some(port) = port { + argv.push("-p".to_string()); + argv.push(port.to_string()); + } + argv.push(userhost.to_string()); + 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"'\''")) +} + 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 @@ -604,6 +728,104 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { mod tests { use super::*; + /// Minimal `ResumeArgs` with only `input` set — the base for + /// exercising `remote_resume_command` arg forwarding. + fn remote_args(input: &str) -> ResumeArgs { + ResumeArgs { + input: input.to_string(), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + remote: Some("ssh://h".to_string()), + } + } + + #[test] + fn ssh_invocation_parses_user_host_port_and_path() { + let (binary, argv) = ssh_invocation( + "ssh://dev@example.com:2222/home/dev/project", + "path resume 'owner/repo/slug'", + ) + .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("ssh://example.com", "path resume 'abc'").unwrap(); + assert_eq!( + argv, + vec!["example.com".to_string(), "path resume 'abc'".to_string()] + ); + } + + #[test] + fn ssh_invocation_rejects_non_ssh_url() { + let err = ssh_invocation("https://example.com/x", "path resume 'abc'").unwrap_err(); + assert!(err.to_string().contains("full SSH URL"), "actual: {err}"); + } + + #[test] + fn remote_resume_command_forwards_input_only_by_default() { + let cmd = remote_resume_command(&remote_args("owner/repo/slug")); + assert_eq!(cmd, "path resume 'owner/repo/slug'"); + } + + #[test] + fn remote_resume_command_single_quotes_input_with_spaces() { + let cmd = remote_resume_command(&remote_args("a b")); + assert_eq!(cmd, "path resume 'a b'"); + } + + #[test] + fn remote_resume_command_forwards_harness() { + let mut args = remote_args("x"); + args.harness = Some(HarnessArg::Claude); + assert_eq!( + remote_resume_command(&args), + "path resume 'x' --harness claude" + ); + } + + #[test] + fn remote_resume_command_forwards_cache_url_and_cwd_flags() { + let mut args = remote_args("x"); + args.no_cache = true; + args.force = true; + args.url = Some("https://pb.example".to_string()); + args.cwd = Some(std::path::PathBuf::from("/srv/work")); + let cmd = remote_resume_command(&args); + assert!(cmd.contains("--no-cache"), "missing --no-cache: {cmd}"); + assert!(cmd.contains("--force"), "missing --force: {cmd}"); + assert!( + cmd.contains("--url 'https://pb.example'"), + "missing --url: {cmd}" + ); + assert!(cmd.contains("--cwd '/srv/work'"), "missing --cwd: {cmd}"); + } + + #[test] + fn remote_resume_command_never_forwards_the_remote_flag() { + let mut args = remote_args("x"); + args.remote = Some("ssh://elsewhere:22".to_string()); + let cmd = remote_resume_command(&args); + assert!( + !cmd.contains("--remote") && !cmd.contains("elsewhere"), + "remote flag must stay host-side: {cmd}" + ); + } + #[test] fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { let _env = crate::config::TEST_ENV_LOCK @@ -631,6 +853,7 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, }; let recorder = RecordingExec::default(); @@ -768,6 +991,7 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -802,6 +1026,7 @@ mod tests { no_cache: true, // skip cache write in tests force: false, url: None, + remote: None, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -863,6 +1088,7 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, }; let result = resolve_input(&args); @@ -891,6 +1117,7 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 32cac640..0868a546 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -268,6 +268,7 @@ fn cache_id_input_loads_and_projects() { no_cache: false, force: false, url: None, + remote: None, }; let recorder = RecordingExec::default(); @@ -353,3 +354,74 @@ 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 recorded +/// invocation must be `ssh`, target the remote host, and run `path +/// resume` on the far side. +#[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(), HarnessArg::Claude); + args.remote = Some("ssh://dev@example.com:2222/home/dev/project".to_string()); + + let recorder = RecordingExec::default(); + run_with_strategy(args, &recorder).unwrap(); + + 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("path resume")), + "ssh should invoke `path resume` on the remote, 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(), HarnessArg::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 578b5af1..b5d3419e 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -186,6 +186,7 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: HarnessArg) -> ResumeA no_cache: false, force: false, url: None, + remote: None, } } From 3570767d07ceb82f5f06fe05c5d9744cb56a4e68 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:09:52 -0400 Subject: [PATCH 03/39] feat(resume): preflight remote `path --version` before remote dispatch `path resume --remote` now probes `ssh host 'path --version'` before the interactive handoff and echoes `host path: / remote path: `. This confirms SSH is reachable and `path` is installed remotely; a failed probe aborts with a clear error instead of dropping the user into a doomed session. Adds a `capture` method to the `ExecStrategy` seam (RealExec runs the command and returns trimmed stdout; RecordingExec records probes and can simulate a failing probe) so both the probe and the dispatch stay testable without touching a real host. Toward empathic/toolpath#140. Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/src/cmd_resume.rs | 118 ++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 9d9e55ac..56a43dc7 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -46,6 +46,13 @@ //! projection, and exec. `--harness` is effectively required here: //! without it the remote's interactive picker has no TTY over SSH. //! +//! Before handing off, the host runs a **version preflight**: +//! `ssh host 'path --version'`, captured and echoed as +//! `host path: / remote path: `. It confirms SSH is reachable +//! and `path` is installed on the remote; if the probe fails the host +//! aborts with a clear error instead of dropping the user into a +//! doomed interactive session. +//! //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` //! for the full design. @@ -126,6 +133,23 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<() non-interactive SSH session, so the harness picker has no TTY" ); } + // Preflight: probe the remote's `path --version` over SSH and echo + // it back alongside the host's version. This confirms SSH is + // reachable and `path` is installed before we hand off to an + // interactive resume; a failure aborts here with a clear error + // rather than dropping the user into a doomed session. + let (ssh_bin, probe_argv) = ssh_invocation(remote, "path --version")?; + let remote_version = exec.capture(&ssh_bin, &probe_argv).with_context(|| { + format!( + "probing remote `path` over {remote} — is `path` installed there and is the host reachable over SSH?" + ) + })?; + eprintln!( + "host path: {} / remote path: {}", + env!("CARGO_PKG_VERSION"), + remote_version.trim() + ); + let remote_cmd = remote_resume_command(&args); let (binary, argv) = ssh_invocation(remote, &remote_cmd)?; let cwd = std::env::current_dir()?; @@ -546,6 +570,11 @@ pub struct CapturedExec { /// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`. pub trait ExecStrategy { fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>; + + /// Run a command and return its trimmed stdout. Used for the remote + /// version preflight (`ssh host 'path --version'`). Errors if the + /// command can't be spawned or exits non-zero. + fn capture(&self, binary: &str, args: &[String]) -> Result; } /// Production implementation. On Unix this never returns on success @@ -590,6 +619,18 @@ impl ExecStrategy for RealExec { std::process::exit(status.code().unwrap_or(1)); } } + + fn capture(&self, binary: &str, args: &[String]) -> Result { + let out = std::process::Command::new(binary) + .args(args) + .output() + .with_context(|| format!("run `{}`", binary))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + anyhow::bail!("`{} {}` failed: {}", binary, args.join(" "), stderr.trim()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } } /// Recording strategy for tests. `captured()` returns the most recent @@ -597,12 +638,30 @@ impl ExecStrategy for RealExec { #[derive(Default)] pub struct RecordingExec { inner: std::sync::Mutex, + probes: std::sync::Mutex>, + /// When true, `capture` returns an error instead of a fake version — + /// simulates a remote where `path` is missing or SSH is unreachable. + probe_fails: bool, } impl RecordingExec { + /// A recorder whose version probe fails, for exercising the + /// abort-before-dispatch path. + pub fn failing_probe() -> Self { + Self { + probe_fails: true, + ..Default::default() + } + } + pub fn captured(&self) -> CapturedExec { self.inner.lock().unwrap().clone() } + + /// Every `capture` (version-probe) invocation, in call order. + pub fn probes(&self) -> Vec { + self.probes.lock().unwrap().clone() + } } impl ExecStrategy for RecordingExec { @@ -615,6 +674,18 @@ impl ExecStrategy for RecordingExec { }; Ok(()) } + + fn capture(&self, binary: &str, args: &[String]) -> Result { + self.probes.lock().unwrap().push(CapturedExec { + binary: binary.to_string(), + args: args.to_vec(), + cwd: std::path::PathBuf::new(), + }); + if self.probe_fails { + anyhow::bail!("`path: command not found`"); + } + Ok("path 0.0.0-recording".to_string()) + } } pub(crate) fn exec_harness( @@ -826,6 +897,53 @@ mod tests { ); } + #[test] + fn remote_resume_probes_remote_version_before_dispatch() { + // A `--remote` resume first probes the remote's `path --version` + // (echoing it back to the host), then dispatches the interactive + // resume. Both go through the ExecStrategy so we can capture them. + let mut args = remote_args("owner/repo/slug"); + args.harness = Some(HarnessArg::Claude); + + let rec = RecordingExec::default(); + run_with_strategy(args, &rec).unwrap(); + + let probes = rec.probes(); + assert_eq!(probes.len(), 1, "exactly one version probe expected"); + assert_eq!(probes[0].binary, "ssh"); + assert!( + probes[0].args.iter().any(|a| a == "path --version"), + "probe should run `path --version` on the remote, got {:?}", + probes[0].args + ); + + // Dispatch still happens after the probe. + let cap = rec.captured(); + assert_eq!(cap.binary, "ssh"); + assert!( + cap.args.iter().any(|a| a.starts_with("path resume")), + "dispatch should run `path resume` on the remote, got {:?}", + cap.args + ); + } + + #[test] + fn remote_resume_aborts_when_version_probe_fails() { + // If the remote probe fails (no path, no SSH), abort before the + // interactive dispatch rather than hand off to a doomed session. + let mut args = remote_args("owner/repo/slug"); + args.harness = Some(HarnessArg::Claude); + + let rec = RecordingExec::failing_probe(); + let err = run_with_strategy(args, &rec).unwrap_err(); + assert!( + err.to_string().contains("remote") || err.to_string().contains("path"), + "error should explain the remote probe failure: {err}" + ); + // No dispatch was recorded. + assert!(rec.captured().binary.is_empty()); + } + #[test] fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { let _env = crate::config::TEST_ENV_LOCK From 770bd9d22e7e49734813d92feffd02d565fe185f Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:18:31 -0400 Subject: [PATCH 04/39] =?UTF-8?q?feat(resume):=20remote=20resume=20v1=20?= =?UTF-8?q?=E2=80=94=20host=20resolves=20+=20stages=20over=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 replaces v0's forward-the-ref mechanism. Instead of the remote resolving the document from Pathbase, the host now resolves it locally and ships the hydrated JSON, so the remote needs only `path` + the target harness installed — no Pathbase access. Flow (run_remote): 1. resolve + validate the doc on the host (fail fast on bad/non-agent input, before touching SSH); 2. version preflight `ssh host 'path --version'` (echo + abort on failure, unchanged from the prior commit); 3. stage the JSON via `ssh host 'cat > /tmp/toolpath-resume-.json'` piped on stdin — best-effort cleanup (execvp precludes a trailing rm); 4. execvp interactive `ssh -t host 'path resume --harness X [-C cwd]'` so the remote harness gets a real terminal. Only --cwd forwards alongside the staged file; the resolution-only flags (--no-cache/--force/--url) are moot once the doc is a local file on the remote and are dropped. ExecStrategy gains `pipe` (stdin staging) alongside `capture`; RecordingExec records staged bytes so the whole probe→stage→dispatch sequence is testable without a real host. Verified end-to-end against the real binary: resolves a Pathbase URL, probes, and errors clearly when the remote is unreachable. Toward empathic/toolpath#140. Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/src/cmd_resume.rs | 413 +++++++++++++++++++++--------- 1 file changed, 299 insertions(+), 114 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 56a43dc7..30ceb77f 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -37,21 +37,31 @@ //! //! ## Remote (`--remote ssh://[user@]host[:port][/path]`) //! -//! v0: dispatch the whole resume to a remote host that has `path` -//! installed, over SSH. The host does **no** local resolution or -//! projection — it builds an `ssh` command and hands off. `--remote` -//! itself is the only host-side arg (forwarding it would recurse); -//! every other arg is forwarded verbatim into the far-side -//! `path resume …` so the remote does the resolve, harness pick, -//! projection, and exec. `--harness` is effectively required here: -//! without it the remote's interactive picker has no TTY over SSH. +//! v1 (two-phase, host resolves): the **host** resolves the document +//! locally, ships the hydrated JSON to the remote, and the remote only +//! projects + execs — so the remote needs `path` and the target harness +//! installed, but no Pathbase access. Steps ([`run_remote`]): //! -//! Before handing off, the host runs a **version preflight**: -//! `ssh host 'path --version'`, captured and echoed as -//! `host path: / remote path: `. It confirms SSH is reachable -//! and `path` is installed on the remote; if the probe fails the host -//! aborts with a clear error instead of dropping the user into a -//! doomed interactive session. +//! 1. **Resolve + validate 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. +//! 2. **Version preflight** — `ssh host 'path --version'`, captured and +//! echoed as `host path: / remote path: `. Confirms SSH is +//! reachable and `path` is installed; a failed probe aborts here +//! rather than dropping the user into a doomed session. +//! 3. **Stage** — `ssh host 'cat > /tmp/toolpath-resume-.json'` +//! with the resolved JSON on stdin. Cleanup is best-effort (the next +//! step `execvp`s the harness, so a trailing `rm` would never run; +//! the OS reaps `/tmp`). +//! 4. **Resume** — `execvp` an interactive `ssh -t host 'path resume +//! --harness X [-C cwd]'`. The `-t` gives the remote +//! harness a real terminal. +//! +//! `--harness` is required with `--remote`: the host can't run the +//! remote's picker, so the target must be pinned. Only `--cwd` is +//! forwarded alongside the staged file; the resolution-only flags +//! (`--no-cache`/`--force`/`--url`) are moot once the doc is a local +//! file on the remote and are dropped. //! //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` //! for the full design. @@ -121,39 +131,7 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<() // over SSH, where `path` is installed and does the resolve itself. // No local resolution or harness projection happens. if let Some(remote) = args.remote.as_deref() { - // The picker can't run over a non-interactive SSH session (no - // TTY on the remote), and the host can't run it either — in v0 - // it never resolves the doc, so it knows neither the source - // harness nor what's installed on the remote. Require an - // explicit pin and fail fast here rather than deep on the far - // side. - if args.harness.is_none() { - anyhow::bail!( - "--remote requires --harness : the remote resume runs over a \ - non-interactive SSH session, so the harness picker has no TTY" - ); - } - // Preflight: probe the remote's `path --version` over SSH and echo - // it back alongside the host's version. This confirms SSH is - // reachable and `path` is installed before we hand off to an - // interactive resume; a failure aborts here with a clear error - // rather than dropping the user into a doomed session. - let (ssh_bin, probe_argv) = ssh_invocation(remote, "path --version")?; - let remote_version = exec.capture(&ssh_bin, &probe_argv).with_context(|| { - format!( - "probing remote `path` over {remote} — is `path` installed there and is the host reachable over SSH?" - ) - })?; - eprintln!( - "host path: {} / remote path: {}", - env!("CARGO_PKG_VERSION"), - remote_version.trim() - ); - - let remote_cmd = remote_resume_command(&args); - let (binary, argv) = ssh_invocation(remote, &remote_cmd)?; - let cwd = std::env::current_dir()?; - return exec_harness(&binary, &argv, &cwd, exec); + return run_remote(&args, remote, exec); } let (graph, source_harness) = resolve_input(&args)?; @@ -575,6 +553,12 @@ pub trait ExecStrategy { /// version preflight (`ssh host 'path --version'`). Errors if the /// command can't be spawned or exits non-zero. fn capture(&self, binary: &str, args: &[String]) -> Result; + + /// Run a command, feeding `input` to its stdin, and wait for it to + /// finish. Used to stage the resolved document onto the remote + /// (`ssh host 'cat > '`). Errors if the command can't be + /// spawned or exits non-zero. + fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> Result<()>; } /// Production implementation. On Unix this never returns on success @@ -631,6 +615,38 @@ impl ExecStrategy for RealExec { } Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } + + fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> Result<()> { + use std::io::Write; + use std::process::Stdio; + let mut child = std::process::Command::new(binary) + .args(args) + .stdin(Stdio::piped()) + .spawn() + .with_context(|| format!("spawn `{}`", binary))?; + // Take + drop the handle after writing so the child sees EOF. + { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("`{}` stdin unavailable", binary))?; + stdin + .write_all(input) + .with_context(|| format!("write to `{}` stdin", binary))?; + } + let status = child + .wait() + .with_context(|| format!("wait for `{}`", binary))?; + if !status.success() { + anyhow::bail!( + "`{} {}` failed (exit {:?})", + binary, + args.join(" "), + status.code() + ); + } + Ok(()) + } } /// Recording strategy for tests. `captured()` returns the most recent @@ -639,6 +655,8 @@ impl ExecStrategy for RealExec { pub struct RecordingExec { inner: std::sync::Mutex, probes: std::sync::Mutex>, + /// Staged documents: (invocation, stdin bytes as UTF-8 string). + staged: std::sync::Mutex>, /// When true, `capture` returns an error instead of a fake version — /// simulates a remote where `path` is missing or SSH is unreachable. probe_fails: bool, @@ -662,6 +680,11 @@ impl RecordingExec { pub fn probes(&self) -> Vec { self.probes.lock().unwrap().clone() } + + /// Every `pipe` (stage) invocation as `(invocation, stdin string)`. + pub fn staged(&self) -> Vec<(CapturedExec, String)> { + self.staged.lock().unwrap().clone() + } } impl ExecStrategy for RecordingExec { @@ -686,6 +709,18 @@ impl ExecStrategy for RecordingExec { } Ok("path 0.0.0-recording".to_string()) } + + fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> Result<()> { + self.staged.lock().unwrap().push(( + CapturedExec { + binary: binary.to_string(), + args: args.to_vec(), + cwd: std::path::PathBuf::new(), + }, + String::from_utf8_lossy(input).to_string(), + )); + Ok(()) + } } pub(crate) fn exec_harness( @@ -697,14 +732,80 @@ pub(crate) fn exec_harness( strategy.exec(binary, args, cwd) } -/// Build the far-side `path resume …` command line for a v0 remote -/// resume. Every resume arg *except* `--remote` (which is host-only — -/// forwarding it would recurse) is passed through so the remote does -/// the resolve, harness pick, projection, and exec. `--harness` in -/// particular is essential: without it the remote picker needs a TTY -/// the SSH connection doesn't provide. -fn remote_resume_command(args: &ResumeArgs) -> String { - let mut parts = vec![shell_single_quote(&args.input)]; +/// v1 remote resume: the host resolves the document locally, stages the +/// JSON onto the remote via `ssh host 'cat > '`, then hands off +/// to an interactive `ssh -t host 'path resume …'`. The remote +/// needs `path` + the target harness installed — but no Pathbase access, +/// since the host already resolved the doc. +/// +/// Steps: +/// 1. resolve + validate the doc on the host (fail fast on bad input); +/// 2. version preflight (`ssh host 'path --version'`), echoing both +/// versions and aborting if `path`/SSH is unreachable; +/// 3. stage the JSON to a unique `/tmp` file on the remote; +/// 4. `execvp` the interactive `ssh -t … path resume `. +fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Result<()> { + // The remote's interactive picker runs over the `-t` TTY, but the host + // can't infer what's installed there, so pin the harness explicitly. + if args.harness.is_none() { + anyhow::bail!( + "--remote requires --harness : the host can't run the remote's \ + harness picker, so the target must be pinned" + ); + } + + // 1. Resolve + validate locally so a bad document fails on the host, + // not deep inside an SSH session. + let (graph, _source) = resolve_input(args)?; + ensure_path_with_agent(&graph)?; + let json = graph + .to_json() + .map_err(|e| anyhow::anyhow!("serialize resolved document: {e}"))?; + + // 2. Version preflight: confirm SSH is reachable and `path` is + // installed remotely, echoing host + remote versions. + let (ssh_bin, probe_argv) = ssh_invocation(remote, "path --version")?; + let remote_version = exec.capture(&ssh_bin, &probe_argv).with_context(|| { + format!( + "probing remote `path` over {remote} — is `path` installed there and is the host reachable over SSH?" + ) + })?; + eprintln!( + "host path: {} / remote path: {}", + env!("CARGO_PKG_VERSION"), + remote_version.trim() + ); + + // 3. Stage the JSON to a unique remote temp file. Cleanup is + // best-effort: the interactive resume `execvp`s the harness, so a + // trailing `rm` would never run; the OS reaps /tmp. + let remote_path = remote_temp_path(); + let stage_cmd = format!("cat > {}", shell_single_quote(&remote_path)); + let (stage_bin, stage_argv) = ssh_invocation(remote, &stage_cmd)?; + exec.pipe(&stage_bin, &stage_argv, json.as_bytes()) + .with_context(|| format!("staging document to {remote}:{remote_path}"))?; + eprintln!("Staged session to {remote_path}"); + + // 4. Interactive resume against the staged file, with a real TTY. + let remote_cmd = remote_staged_command(args, &remote_path); + let (binary, argv) = ssh_invocation_tty(remote, &remote_cmd, true)?; + let cwd = std::env::current_dir()?; + exec_harness(&binary, &argv, &cwd, exec) +} + +/// A unique remote temp path for the staged document. Random v4 UUID so +/// concurrent resumes to the same host don't collide. +fn remote_temp_path() -> String { + format!("/tmp/toolpath-resume-{}.json", uuid::Uuid::new_v4()) +} + +/// Build the far-side `path resume …` command for a v1 +/// remote resume. The document is already staged on the remote, so this +/// points at the temp file rather than the original input, and drops the +/// resolution-only flags (`--no-cache`/`--force`/`--url`) which are moot +/// once the doc is a local file on the remote. `--cwd` still forwards. +fn remote_staged_command(args: &ResumeArgs, remote_path: &str) -> String { + let mut parts = vec![shell_single_quote(remote_path)]; if let Some(harness) = args.harness { parts.push("--harness".to_string()); parts.push(harness_value(harness)); @@ -713,16 +814,6 @@ fn remote_resume_command(args: &ResumeArgs) -> String { parts.push("--cwd".to_string()); parts.push(shell_single_quote(&cwd.display().to_string())); } - if args.no_cache { - parts.push("--no-cache".to_string()); - } - if args.force { - parts.push("--force".to_string()); - } - if let Some(url) = args.url.as_ref() { - parts.push("--url".to_string()); - parts.push(shell_single_quote(url)); - } format!("path resume {}", parts.join(" ")) } @@ -737,11 +828,19 @@ fn harness_value(harness: HarnessArg) -> String { .to_string() } -/// Build the `ssh` invocation for a v0 remote resume from a full SSH -/// URL (`ssh://[user@]host[:port][/path]`) and an already-built remote -/// command. Returns `("ssh", argv)` where argv is -/// `[-p ,]? <[user@]host> `. +/// Build a non-interactive `ssh` invocation (no `-t`). Used for the +/// version probe and the `cat >` staging step. fn ssh_invocation(remote: &str, remote_cmd: &str) -> Result<(String, Vec)> { + ssh_invocation_tty(remote, remote_cmd, false) +} + +/// Build the `ssh` invocation from a full SSH URL +/// (`ssh://[user@]host[:port][/path]`) and an already-built remote +/// command. Returns `("ssh", argv)` where argv is +/// `[-t]? [-p ]? <[user@]host> `. Pass `tty = true` +/// for the interactive resume so the remote harness (and its picker) get +/// a real terminal. +fn ssh_invocation_tty(remote: &str, remote_cmd: &str, tty: bool) -> Result<(String, Vec)> { let rest = remote .strip_prefix("ssh://") .with_context(|| format!("remote must be a full SSH URL (ssh://…), got `{remote}`"))?; @@ -766,6 +865,9 @@ fn ssh_invocation(remote: &str, remote_cmd: &str) -> Result<(String, Vec } let mut argv = Vec::new(); + if tty { + argv.push("-t".to_string()); + } if let Some(port) = port { argv.push("-p".to_string()); argv.push(port.to_string()); @@ -848,49 +950,59 @@ mod tests { } #[test] - fn remote_resume_command_forwards_input_only_by_default() { - let cmd = remote_resume_command(&remote_args("owner/repo/slug")); - assert_eq!(cmd, "path resume 'owner/repo/slug'"); - } - - #[test] - fn remote_resume_command_single_quotes_input_with_spaces() { - let cmd = remote_resume_command(&remote_args("a b")); - assert_eq!(cmd, "path resume 'a b'"); + 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 remote_resume_command_forwards_harness() { - let mut args = remote_args("x"); + fn remote_staged_command_points_at_staged_file_with_harness() { + let mut args = remote_args("owner/repo/slug"); args.harness = Some(HarnessArg::Claude); + let cmd = remote_staged_command(&args, "/tmp/toolpath-resume-abc.json"); assert_eq!( - remote_resume_command(&args), - "path resume 'x' --harness claude" + cmd, + "path resume '/tmp/toolpath-resume-abc.json' --harness claude" ); } #[test] - fn remote_resume_command_forwards_cache_url_and_cwd_flags() { - let mut args = remote_args("x"); + fn remote_staged_command_forwards_cwd_but_drops_resolution_flags() { + // The doc is already a local file on the remote, so --no-cache / + // --force / --url are moot and must not be forwarded. --cwd still is. + let mut args = remote_args("owner/repo/slug"); + args.harness = Some(HarnessArg::Claude); args.no_cache = true; args.force = true; args.url = Some("https://pb.example".to_string()); args.cwd = Some(std::path::PathBuf::from("/srv/work")); - let cmd = remote_resume_command(&args); - assert!(cmd.contains("--no-cache"), "missing --no-cache: {cmd}"); - assert!(cmd.contains("--force"), "missing --force: {cmd}"); - assert!( - cmd.contains("--url 'https://pb.example'"), - "missing --url: {cmd}" - ); + let cmd = remote_staged_command(&args, "/tmp/staged.json"); assert!(cmd.contains("--cwd '/srv/work'"), "missing --cwd: {cmd}"); + assert!(!cmd.contains("--no-cache"), "leaked --no-cache: {cmd}"); + assert!(!cmd.contains("--force"), "leaked --force: {cmd}"); + assert!(!cmd.contains("--url"), "leaked --url: {cmd}"); } #[test] - fn remote_resume_command_never_forwards_the_remote_flag() { + fn remote_staged_command_never_forwards_the_remote_flag() { let mut args = remote_args("x"); + args.harness = Some(HarnessArg::Claude); args.remote = Some("ssh://elsewhere:22".to_string()); - let cmd = remote_resume_command(&args); + let cmd = remote_staged_command(&args, "/tmp/s.json"); assert!( !cmd.contains("--remote") && !cmd.contains("elsewhere"), "remote flag must stay host-side: {cmd}" @@ -898,50 +1010,123 @@ mod tests { } #[test] - fn remote_resume_probes_remote_version_before_dispatch() { - // A `--remote` resume first probes the remote's `path --version` - // (echoing it back to the host), then dispatches the interactive - // resume. Both go through the ExecStrategy so we can capture them. - let mut args = remote_args("owner/repo/slug"); - args.harness = Some(HarnessArg::Claude); + fn remote_temp_path_is_unique_and_well_formed() { + let a = remote_temp_path(); + let b = remote_temp_path(); + assert_ne!(a, b, "temp paths must be unique per resume"); + assert!(a.starts_with("/tmp/toolpath-resume-"), "path: {a}"); + assert!(a.ends_with(".json"), "path: {a}"); + } + + /// 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(HarnessArg::Claude), + no_cache: false, + force: false, + url: None, + remote: Some("ssh://dev@example.com:2222".to_string()), + } + } + #[test] + fn remote_resume_probes_stages_then_dispatches() { + // v1: host resolves the doc locally, probes `path --version`, stages + // the JSON via `cat >`, then dispatches an interactive `ssh -t` + // resume against the staged file. All three go through the + // ExecStrategy so we can capture them. + let td = tempfile::tempdir().unwrap(); let rec = RecordingExec::default(); - run_with_strategy(args, &rec).unwrap(); + run_with_strategy(remote_args_with_doc(td.path()), &rec).unwrap(); + // 1. version probe let probes = rec.probes(); - assert_eq!(probes.len(), 1, "exactly one version probe expected"); - assert_eq!(probes[0].binary, "ssh"); + assert_eq!(probes.len(), 1, "exactly one version probe"); assert!( probes[0].args.iter().any(|a| a == "path --version"), - "probe should run `path --version` on the remote, got {:?}", + "probe argv: {:?}", probes[0].args ); - // Dispatch still happens after the probe. + // 2. staging: `ssh … 'cat > '` with the JSON on stdin. + let staged = rec.staged(); + assert_eq!(staged.len(), 1, "exactly one stage step"); + let (stage_inv, stdin) = &staged[0]; + assert_eq!(stage_inv.binary, "ssh"); + let cat_arg = stage_inv + .args + .iter() + .find(|a| a.starts_with("cat > ")) + .expect("stage should run `cat > `"); + assert!( + cat_arg.contains("/tmp/toolpath-resume-"), + "stage target: {cat_arg}" + ); + // The staged bytes are the resolved document. + assert!( + stdin.contains("claude-code://remote-v1-test"), + "stdin: {stdin}" + ); + + // 3. dispatch: interactive `ssh -t … path resume --harness`. let cap = rec.captured(); assert_eq!(cap.binary, "ssh"); assert!( - cap.args.iter().any(|a| a.starts_with("path resume")), - "dispatch should run `path resume` on the remote, got {:?}", + cap.args.iter().any(|a| a == "-t"), + "dispatch needs -t: {:?}", cap.args ); + let resume_arg = cap + .args + .iter() + .find(|a| a.starts_with("path resume")) + .expect("dispatch should run `path resume`"); + assert!( + resume_arg.contains("/tmp/toolpath-resume-") && resume_arg.contains("--harness claude"), + "dispatch cmd: {resume_arg}" + ); + // The staged file the dispatch resumes must be the one we cat'd. + assert!( + cat_arg.contains(resume_arg.split('\'').nth(1).unwrap()), + "dispatch must target the staged file" + ); } #[test] fn remote_resume_aborts_when_version_probe_fails() { - // If the remote probe fails (no path, no SSH), abort before the - // interactive dispatch rather than hand off to a doomed session. - let mut args = remote_args("owner/repo/slug"); - args.harness = Some(HarnessArg::Claude); - + // If the remote probe fails (no path, no SSH), abort before staging + // or dispatch rather than hand off to a doomed session. + let td = tempfile::tempdir().unwrap(); let rec = RecordingExec::failing_probe(); - let err = run_with_strategy(args, &rec).unwrap_err(); + let err = run_with_strategy(remote_args_with_doc(td.path()), &rec).unwrap_err(); assert!( err.to_string().contains("remote") || err.to_string().contains("path"), "error should explain the remote probe failure: {err}" ); - // No dispatch was recorded. - assert!(rec.captured().binary.is_empty()); + assert!( + rec.staged().is_empty(), + "must not stage after a failed probe" + ); + assert!(rec.captured().binary.is_empty(), "must not dispatch"); + } + + #[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.probes().is_empty(), "must fail before probing"); } #[test] From 79c0f0b9131edf6ce25bfa1cbddc738562fce4c1 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:21:53 -0400 Subject: [PATCH 05/39] test(resume): pin XDG_DATA_HOME in ScopedHome to sandbox opencode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opencode `PathResolver` prefers `$XDG_DATA_HOME` over `$HOME` when locating `opencode.db`. `ScopedHome` only pinned `$HOME`, so on a machine that sets `$XDG_DATA_HOME` (common on Linux; set on this dev box) the opencode resume test escaped its sandbox: it seeded — and the projector wrote into — the *real* user database. The seed's `CREATE TABLE` then hit the already-populated real DB and failed with "table project already exists" (green in CI where XDG is unset, red locally), and a fresh-DB run would silently mutate the user's live opencode data. Pin `$XDG_DATA_HOME` to `/.local/share` in ScopedHome (restored on drop) so every harness — opencode included — stays sandboxed. Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/tests/support/mod.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index b5d3419e..77061ca6 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -26,11 +26,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 { @@ -38,14 +47,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, } } @@ -65,6 +77,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"), + } } } } From ad6807516393d31f768bf94a7a565fc24b2fc2cf Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:02:22 -0400 Subject: [PATCH 06/39] =?UTF-8?q?feat(resume):=20remote=20resume=20v2=20?= =?UTF-8?q?=E2=80=94=20pipe=20JSON=20into=20remote=20`path=20p=20incept`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the v1 temp-file staging (`ssh 'cat > /tmp/…'` + remote `path resume `) with a single pipe into `path p incept claude` on the remote, then launch `claude -r ` directly over an interactive `ssh -t`. The host computes the session id locally: it's a pure function of the document (the projector takes it verbatim from the conversation view), so host and remote projecting the same bytes agree — new `cmd_export::claude_session_id` pins that. Claude-only for now; other harnesses error clearly before any SSH. `--cwd` does double duty as incept's --project dir and the launch's cd target; absent, both default to the remote ssh cwd ($HOME). Deletes remote_temp_path / remote_staged_command / harness_value. Verified live against a local sshd sandbox: version echo, incept writes the session under the remote ~/.claude/projects keyed on -C, host and remote ids identical, correct `ssh -t … claude -r` launch. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_export.rs | 13 ++ crates/path-cli/src/cmd_resume.rs | 309 ++++++++++++++---------------- crates/path-cli/tests/resume.rs | 31 ++- 3 files changed, 182 insertions(+), 171 deletions(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index dfc82d2b..16f63c00 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -299,6 +299,19 @@ pub(crate) struct PathbaseUploadArgs { // projected session id. They are called by `path resume`; the existing // `run_` functions are untouched. +/// The Claude session id `path` would project to, without writing +/// anything. Deterministic: the projector takes the id verbatim from +/// the document's conversation view, so host and remote projecting the +/// same bytes agree on the id (relied on by `path resume --remote`). +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn claude_session_id(path: &toolpath::v1::Path) -> Result { + let conv = build_claude_conversation(path)?; + if conv.session_id.is_empty() { + anyhow::bail!("document projects to an empty Claude session id"); + } + Ok(conv.session_id) +} + /// Project `path` into a Claude session under `project_dir` and return /// the resulting session id. #[cfg(not(target_os = "emscripten"))] diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 30ceb77f..0fadee2c 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -37,31 +37,35 @@ //! //! ## Remote (`--remote ssh://[user@]host[:port][/path]`) //! -//! v1 (two-phase, host resolves): the **host** resolves the document -//! locally, ships the hydrated JSON to the remote, and the remote only -//! projects + execs — so the remote needs `path` and the target harness -//! installed, but no Pathbase access. Steps ([`run_remote`]): +//! v2 (host resolves, remote incepts): the **host** resolves the +//! document locally, pipes the hydrated JSON into `path p incept` on the +//! remote, and launches the harness directly — so the remote needs +//! `path` (for incept) and the harness installed, but no Pathbase +//! access and no temp files. Steps ([`run_remote`]): //! //! 1. **Resolve + validate 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. +//! document fails fast on the host, not deep inside SSH. The host +//! also computes the session id here: it's a pure function of the +//! document ([`crate::cmd_export::claude_session_id`]), so both +//! sides projecting the same bytes agree on it. //! 2. **Version preflight** — `ssh host 'path --version'`, captured and //! echoed as `host path: / remote path: `. Confirms SSH is //! reachable and `path` is installed; a failed probe aborts here //! rather than dropping the user into a doomed session. -//! 3. **Stage** — `ssh host 'cat > /tmp/toolpath-resume-.json'` -//! with the resolved JSON on stdin. Cleanup is best-effort (the next -//! step `execvp`s the harness, so a trailing `rm` would never run; -//! the OS reaps `/tmp`). -//! 4. **Resume** — `execvp` an interactive `ssh -t host 'path resume -//! --harness X [-C cwd]'`. The `-t` gives the remote -//! harness a real terminal. +//! 3. **Hydrate** — `ssh host 'path p incept claude [--project ]'` +//! with the resolved JSON on stdin; incept writes the session into +//! the remote's Claude project layout. +//! 4. **Launch** — `execvp` an interactive `ssh -t host '[cd && ] +//! claude -r '`. The `-t` gives the remote harness a real +//! terminal. //! -//! `--harness` is required with `--remote`: the host can't run the -//! remote's picker, so the target must be pinned. Only `--cwd` is -//! forwarded alongside the staged file; the resolution-only flags -//! (`--no-cache`/`--force`/`--url`) are moot once the doc is a local -//! file on the remote and are dropped. +//! `--harness` is required with `--remote` (and currently must be +//! `claude` — incept and the id computation are Claude-specific). The +//! resolution-only flags (`--no-cache`/`--force`/`--url`) act on the +//! host and are never forwarded. `--cwd` does double duty as incept's +//! `--project` dir and the launch's `cd` target; absent, both default +//! to the remote's ssh cwd (`$HOME`). //! //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` //! for the full design. @@ -127,9 +131,9 @@ pub fn run(args: ResumeArgs) -> Result<()> { /// 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<()> { - // v0 remote resume: forward `path resume ` to a remote host - // over SSH, where `path` is installed and does the resolve itself. - // No local resolution or harness projection happens. + // Remote resume: resolve here, hydrate the session on the remote via + // `path p incept`, and launch the harness over an interactive SSH. + // See the module docs' "Remote" section and `run_remote`. if let Some(remote) = args.remote.as_deref() { return run_remote(&args, remote, exec); } @@ -555,9 +559,9 @@ pub trait ExecStrategy { fn capture(&self, binary: &str, args: &[String]) -> Result; /// Run a command, feeding `input` to its stdin, and wait for it to - /// finish. Used to stage the resolved document onto the remote - /// (`ssh host 'cat > '`). Errors if the command can't be - /// spawned or exits non-zero. + /// finish. Used to hydrate the resolved document on the remote + /// (`ssh host 'path p incept claude …'`). Errors if the command + /// can't be spawned or exits non-zero. fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> Result<()>; } @@ -732,32 +736,48 @@ pub(crate) fn exec_harness( strategy.exec(binary, args, cwd) } -/// v1 remote resume: the host resolves the document locally, stages the -/// JSON onto the remote via `ssh host 'cat > '`, then hands off -/// to an interactive `ssh -t host 'path resume …'`. The remote -/// needs `path` + the target harness installed — but no Pathbase access, -/// since the host already resolved the doc. +/// v2 remote resume: the host resolves the document locally, pipes the +/// JSON into `ssh host 'path p incept claude …'` (which hydrates the +/// session into the remote's Claude layout), then hands off to an +/// interactive `ssh -t host 'claude -r '`. The remote needs `path` +/// (for incept) + the harness installed — but no Pathbase access, since +/// the host already resolved the doc. +/// +/// The host knows `` without asking the remote: the Claude session +/// id is a pure function of the document (the projector takes it +/// verbatim from the conversation view), so projecting the same bytes on +/// both sides yields the same id — see [`crate::cmd_export::claude_session_id`]. /// /// Steps: -/// 1. resolve + validate the doc on the host (fail fast on bad input); +/// 1. resolve + validate the doc on the host (fail fast on bad input), +/// and compute the session id locally; /// 2. version preflight (`ssh host 'path --version'`), echoing both /// versions and aborting if `path`/SSH is unreachable; -/// 3. stage the JSON to a unique `/tmp` file on the remote; -/// 4. `execvp` the interactive `ssh -t … path resume `. +/// 3. hydrate: pipe the JSON into `path p incept claude [--project …]`; +/// 4. `execvp` the interactive `ssh -t … '[cd … && ]claude -r '`. +/// +/// `--cwd` does double duty: it is incept's `--project` dir AND the `cd` +/// target for the launch (they must match for `claude -r` to find the +/// session). Absent, both default to the remote's ssh cwd (`$HOME`). fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Result<()> { - // The remote's interactive picker runs over the `-t` TTY, but the host - // can't infer what's installed there, so pin the harness explicitly. - if args.harness.is_none() { - anyhow::bail!( + // The remote's interactive picker can't run from here, so pin the + // harness explicitly. Only Claude is wired up so far: incept and the + // host-side session-id computation 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(HarnessArg::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. + // not deep inside an SSH session — and compute the session id the + // remote's projection will deterministically arrive at. let (graph, _source) = resolve_input(args)?; - ensure_path_with_agent(&graph)?; + let path = ensure_path_with_agent(&graph)?; + let session_id = crate::cmd_export::claude_session_id(path)?; let json = graph .to_json() .map_err(|e| anyhow::anyhow!("serialize resolved document: {e}"))?; @@ -776,60 +796,57 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul remote_version.trim() ); - // 3. Stage the JSON to a unique remote temp file. Cleanup is - // best-effort: the interactive resume `execvp`s the harness, so a - // trailing `rm` would never run; the OS reaps /tmp. - let remote_path = remote_temp_path(); - let stage_cmd = format!("cat > {}", shell_single_quote(&remote_path)); - let (stage_bin, stage_argv) = ssh_invocation(remote, &stage_cmd)?; - exec.pipe(&stage_bin, &stage_argv, json.as_bytes()) - .with_context(|| format!("staging document to {remote}:{remote_path}"))?; - eprintln!("Staged session to {remote_path}"); - - // 4. Interactive resume against the staged file, with a real TTY. - let remote_cmd = remote_staged_command(args, &remote_path); - let (binary, argv) = ssh_invocation_tty(remote, &remote_cmd, true)?; + // 3. Hydrate: pipe the JSON into remote incept, which writes the + // session into the remote's Claude project layout. + let incept_cmd = remote_incept_command(args.cwd.as_deref()); + let (incept_bin, incept_argv) = ssh_invocation(remote, &incept_cmd)?; + exec.pipe(&incept_bin, &incept_argv, json.as_bytes()) + .with_context(|| { + format!( + "hydrating session on {remote} via `{incept_cmd}` — does the \ + remote `path` support `p incept`?" + ) + })?; + eprintln!("Incepted session {session_id} on {remote}"); + + // 4. Interactive launch of the harness against the incepted session, + // with a real TTY. + let launch_cmd = remote_launch_command(&session_id, args.cwd.as_deref()); + let (binary, argv) = ssh_invocation_tty(remote, &launch_cmd, true)?; let cwd = std::env::current_dir()?; exec_harness(&binary, &argv, &cwd, exec) } -/// A unique remote temp path for the staged document. Random v4 UUID so -/// concurrent resumes to the same host don't collide. -fn remote_temp_path() -> String { - format!("/tmp/toolpath-resume-{}.json", uuid::Uuid::new_v4()) -} - -/// Build the far-side `path resume …` command for a v1 -/// remote resume. The document is already staged on the remote, so this -/// points at the temp file rather than the original input, and drops the -/// resolution-only flags (`--no-cache`/`--force`/`--url`) which are moot -/// once the doc is a local file on the remote. `--cwd` still forwards. -fn remote_staged_command(args: &ResumeArgs, remote_path: &str) -> String { - let mut parts = vec![shell_single_quote(remote_path)]; - if let Some(harness) = args.harness { - parts.push("--harness".to_string()); - parts.push(harness_value(harness)); - } - if let Some(cwd) = args.cwd.as_ref() { - parts.push("--cwd".to_string()); - parts.push(shell_single_quote(&cwd.display().to_string())); - } - format!("path resume {}", parts.join(" ")) +/// The far-side hydrate command: `path p incept claude`, targeting +/// `--project ` when a cwd was pinned. Without one, incept defaults +/// to the remote process cwd — `$HOME` over ssh, matching where the +/// `cd`-less launch lands. +fn remote_incept_command(cwd: Option<&std::path::Path>) -> String { + match cwd { + Some(dir) => format!( + "path p incept claude --project {}", + shell_single_quote(&dir.display().to_string()) + ), + None => "path p incept claude".to_string(), + } } -/// The lowercase CLI value for a `--harness` choice (e.g. `claude`), -/// taken from clap's own value table so it stays in sync with the enum. -fn harness_value(harness: HarnessArg) -> String { - use clap::ValueEnum; - harness - .to_possible_value() - .expect("HarnessArg has no skipped variants") - .get_name() - .to_string() +/// The far-side launch command: `claude -r `, prefixed with a +/// `cd &&` when a cwd was pinned so the harness starts where the +/// session was incepted. +fn remote_launch_command(session_id: &str, cwd: Option<&std::path::Path>) -> String { + let launch = format!("claude -r {}", shell_single_quote(session_id)); + match cwd { + Some(dir) => format!( + "cd {} && {launch}", + shell_single_quote(&dir.display().to_string()) + ), + None => launch, + } } /// Build a non-interactive `ssh` invocation (no `-t`). Used for the -/// version probe and the `cat >` staging step. +/// version probe and the incept hydration step. fn ssh_invocation(remote: &str, remote_cmd: &str) -> Result<(String, Vec)> { ssh_invocation_tty(remote, remote_cmd, false) } @@ -901,20 +918,6 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { mod tests { use super::*; - /// Minimal `ResumeArgs` with only `input` set — the base for - /// exercising `remote_resume_command` arg forwarding. - fn remote_args(input: &str) -> ResumeArgs { - ResumeArgs { - input: input.to_string(), - cwd: None, - harness: None, - no_cache: false, - force: false, - url: None, - remote: Some("ssh://h".to_string()), - } - } - #[test] fn ssh_invocation_parses_user_host_port_and_path() { let (binary, argv) = ssh_invocation( @@ -970,54 +973,23 @@ mod tests { } #[test] - fn remote_staged_command_points_at_staged_file_with_harness() { - let mut args = remote_args("owner/repo/slug"); - args.harness = Some(HarnessArg::Claude); - let cmd = remote_staged_command(&args, "/tmp/toolpath-resume-abc.json"); + fn remote_incept_command_with_and_without_cwd() { + assert_eq!(remote_incept_command(None), "path p incept claude"); assert_eq!( - cmd, - "path resume '/tmp/toolpath-resume-abc.json' --harness claude" + remote_incept_command(Some(std::path::Path::new("/srv/work"))), + "path p incept claude --project '/srv/work'" ); } #[test] - fn remote_staged_command_forwards_cwd_but_drops_resolution_flags() { - // The doc is already a local file on the remote, so --no-cache / - // --force / --url are moot and must not be forwarded. --cwd still is. - let mut args = remote_args("owner/repo/slug"); - args.harness = Some(HarnessArg::Claude); - args.no_cache = true; - args.force = true; - args.url = Some("https://pb.example".to_string()); - args.cwd = Some(std::path::PathBuf::from("/srv/work")); - let cmd = remote_staged_command(&args, "/tmp/staged.json"); - assert!(cmd.contains("--cwd '/srv/work'"), "missing --cwd: {cmd}"); - assert!(!cmd.contains("--no-cache"), "leaked --no-cache: {cmd}"); - assert!(!cmd.contains("--force"), "leaked --force: {cmd}"); - assert!(!cmd.contains("--url"), "leaked --url: {cmd}"); - } - - #[test] - fn remote_staged_command_never_forwards_the_remote_flag() { - let mut args = remote_args("x"); - args.harness = Some(HarnessArg::Claude); - args.remote = Some("ssh://elsewhere:22".to_string()); - let cmd = remote_staged_command(&args, "/tmp/s.json"); - assert!( - !cmd.contains("--remote") && !cmd.contains("elsewhere"), - "remote flag must stay host-side: {cmd}" + fn remote_launch_command_quotes_id_and_cds() { + assert_eq!(remote_launch_command("sess-1", None), "claude -r 'sess-1'"); + assert_eq!( + remote_launch_command("sess-1", Some(std::path::Path::new("/srv/work"))), + "cd '/srv/work' && claude -r 'sess-1'" ); } - #[test] - fn remote_temp_path_is_unique_and_well_formed() { - let a = remote_temp_path(); - let b = remote_temp_path(); - assert_ne!(a, b, "temp paths must be unique per resume"); - assert!(a.starts_with("/tmp/toolpath-resume-"), "path: {a}"); - assert!(a.ends_with(".json"), "path: {a}"); - } - /// 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 { @@ -1038,11 +1010,11 @@ mod tests { } #[test] - fn remote_resume_probes_stages_then_dispatches() { - // v1: host resolves the doc locally, probes `path --version`, stages - // the JSON via `cat >`, then dispatches an interactive `ssh -t` - // resume against the staged file. All three go through the - // ExecStrategy so we can capture them. + fn remote_resume_probes_incepts_then_launches() { + // v2: host resolves the doc locally, probes `path --version`, pipes + // the JSON into `path p incept claude` on the remote, then launches + // an interactive `ssh -t … claude -r ` where is computed + // host-side from the document (deterministic: same bytes → same id). let td = tempfile::tempdir().unwrap(); let rec = RecordingExec::default(); run_with_strategy(remote_args_with_doc(td.path()), &rec).unwrap(); @@ -1056,48 +1028,53 @@ mod tests { probes[0].args ); - // 2. staging: `ssh … 'cat > '` with the JSON on stdin. + // 2. incept: `ssh … 'path p incept claude'` with the JSON on stdin. let staged = rec.staged(); - assert_eq!(staged.len(), 1, "exactly one stage step"); - let (stage_inv, stdin) = &staged[0]; - assert_eq!(stage_inv.binary, "ssh"); - let cat_arg = stage_inv - .args - .iter() - .find(|a| a.starts_with("cat > ")) - .expect("stage should run `cat > `"); + assert_eq!(staged.len(), 1, "exactly one incept step"); + let (incept_inv, stdin) = &staged[0]; + assert_eq!(incept_inv.binary, "ssh"); assert!( - cat_arg.contains("/tmp/toolpath-resume-"), - "stage target: {cat_arg}" + incept_inv + .args + .iter() + .any(|a| a.starts_with("path p incept claude")), + "incept argv: {:?}", + incept_inv.args ); - // The staged bytes are the resolved document. + // The piped bytes are the resolved document. assert!( stdin.contains("claude-code://remote-v1-test"), "stdin: {stdin}" ); - // 3. dispatch: interactive `ssh -t … path resume --harness`. + // 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"), - "dispatch needs -t: {:?}", + "launch needs -t: {:?}", cap.args ); - let resume_arg = cap - .args - .iter() - .find(|a| a.starts_with("path resume")) - .expect("dispatch should run `path resume`"); assert!( - resume_arg.contains("/tmp/toolpath-resume-") && resume_arg.contains("--harness claude"), - "dispatch cmd: {resume_arg}" + cap.args.iter().any(|a| a == "claude -r 'remote-v1-test'"), + "launch cmd: {:?}", + cap.args ); - // The staged file the dispatch resumes must be the one we cat'd. + } + + #[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(HarnessArg::Codex); + let rec = RecordingExec::default(); + let err = run_with_strategy(args, &rec).unwrap_err(); assert!( - cat_arg.contains(resume_arg.split('\'').nth(1).unwrap()), - "dispatch must target the staged file" + err.to_string().contains("claude"), + "error should name the supported harness: {err}" ); + assert!(rec.probes().is_empty(), "must fail before probing"); } #[test] diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 0868a546..36aea17a 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -358,9 +358,10 @@ fn explicit_harness_not_on_path_errors() { // ── Remote resume over SSH ────────────────────────────────────────── /// With `--remote `, resume should be dispatched to the remote -/// host over SSH rather than exec'ing a local harness: the recorded -/// invocation must be `ssh`, target the remote host, and run `path -/// resume` on the far side. +/// host over SSH rather than exec'ing a local harness: the JSON is piped +/// into `path p incept claude` 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(); @@ -377,6 +378,24 @@ fn remote_flag_dispatches_resume_over_ssh() { let recorder = RecordingExec::default(); run_with_strategy(args, &recorder).unwrap(); + // Hydration: the resolved JSON is piped into remote incept. + let staged = recorder.staged(); + assert_eq!(staged.len(), 1, "exactly one incept pipe"); + let (incept_inv, stdin) = &staged[0]; + assert!( + incept_inv + .args + .iter() + .any(|a| a.starts_with("path p incept claude")), + "remote should hydrate via `path p incept claude`, got {:?}", + incept_inv.args + ); + assert!( + stdin.contains("claude-code://resume-remote-int"), + "incept stdin should carry the resolved doc" + ); + + // Launch: interactive ssh -t running the harness directly. let cap = recorder.captured(); assert_eq!( cap.binary, "ssh", @@ -389,8 +408,10 @@ fn remote_flag_dispatches_resume_over_ssh() { cap.args ); assert!( - cap.args.iter().any(|a| a.contains("path resume")), - "ssh should invoke `path resume` on the remote, got {:?}", + cap.args + .iter() + .any(|a| a.contains("claude -r 'resume-remote-int'")), + "ssh should launch `claude -r ` on the remote, got {:?}", cap.args ); } From e9a186b9571fd43fd808648f7f1a9a757137f8c2 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:12:43 -0400 Subject: [PATCH 07/39] fix(resume): mkdir -p the pinned --cwd before remote incept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming into a fresh directory is the normal case, but incept canonicalizes its --project path and bailed when the dir didn't exist on the remote. Prefix the hydrate command with `mkdir -p`. Verified live: `--remote … -C /tmp/somewhere` with no such dir now incepts and launches. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_resume.rs | 20 ++++++++++++-------- crates/path-cli/tests/resume.rs | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 0fadee2c..8c8729c3 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -818,15 +818,17 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul } /// The far-side hydrate command: `path p incept claude`, targeting -/// `--project ` when a cwd was pinned. Without one, incept defaults -/// to the remote process cwd — `$HOME` over ssh, matching where the -/// `cd`-less launch lands. +/// `--project ` when a cwd was pinned. The dir may not exist on the +/// remote yet (resuming into a fresh directory is the normal case), and +/// incept canonicalizes its project path — so create it first. Without a +/// cwd, incept defaults to the remote process cwd — `$HOME` over ssh, +/// matching where the `cd`-less launch lands. fn remote_incept_command(cwd: Option<&std::path::Path>) -> String { match cwd { - Some(dir) => format!( - "path p incept claude --project {}", - shell_single_quote(&dir.display().to_string()) - ), + Some(dir) => { + let dir = shell_single_quote(&dir.display().to_string()); + format!("mkdir -p {dir} && path p incept claude --project {dir}") + } None => "path p incept claude".to_string(), } } @@ -975,9 +977,11 @@ mod tests { #[test] fn remote_incept_command_with_and_without_cwd() { assert_eq!(remote_incept_command(None), "path p incept claude"); + // The pinned dir may not exist on the remote yet (resuming into a + // fresh directory is the normal case) — incept must create it. assert_eq!( remote_incept_command(Some(std::path::Path::new("/srv/work"))), - "path p incept claude --project '/srv/work'" + "mkdir -p '/srv/work' && path p incept claude --project '/srv/work'" ); } diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 36aea17a..e0e79625 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -386,7 +386,7 @@ fn remote_flag_dispatches_resume_over_ssh() { incept_inv .args .iter() - .any(|a| a.starts_with("path p incept claude")), + .any(|a| a.contains("path p incept claude")), "remote should hydrate via `path p incept claude`, got {:?}", incept_inv.args ); From b42d2f3c54efbd71149b940589fba261b8c267ad Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:18:09 -0400 Subject: [PATCH 08/39] =?UTF-8?q?feat(resume):=20remote=20resume=20v3=20?= =?UTF-8?q?=E2=80=94=20ship=20projected=20files,=20no=20remote=20`path`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The good version from the roadmap: the host resolves AND projects the Claude session fully in memory (`cmd_export::claude_session_jsonl`), probes the remote with plain `pwd` (reachability + default launch dir), ships the finished JSONL straight into `$HOME/.claude/projects//.jsonl` via the existing ssh pipe, and launches `claude -r ` over an interactive `ssh -t`. The remote needs only sshd and the harness — no `path`, no Pathbase, no temp files. The project-dir key mirrors Claude Code's sanitization; a unit test pins it against toolpath-claude's PathResolver so the two can't drift. Replaces the v2 incept flow (claude_session_id → claude_session_jsonl, remote_incept_command → remote_ship_command + claude_project_dir_name). Verified live against the sshd sandbox: probe echo, 150KB JSONL landed under the remote's ~/.claude/projects keyed on -C, correct launch. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_export.rs | 16 +- crates/path-cli/src/cmd_resume.rs | 234 +++++++++++++++++------------- crates/path-cli/tests/resume.rs | 31 ++-- 3 files changed, 159 insertions(+), 122 deletions(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 16f63c00..47ac5a1f 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -299,17 +299,21 @@ pub(crate) struct PathbaseUploadArgs { // projected session id. They are called by `path resume`; the existing // `run_` functions are untouched. -/// The Claude session id `path` would project to, without writing -/// anything. Deterministic: the projector takes the id verbatim from -/// the document's conversation view, so host and remote projecting the -/// same bytes agree on the id (relied on by `path resume --remote`). +/// 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_id(path: &toolpath::v1::Path) -> Result { +pub(crate) fn claude_session_jsonl(path: &toolpath::v1::Path) -> Result<(String, String)> { let conv = build_claude_conversation(path)?; if conv.session_id.is_empty() { anyhow::bail!("document projects to an empty Claude session id"); } - Ok(conv.session_id) + let jsonl = serialize_jsonl(&conv)?; + Ok((conv.session_id, jsonl)) } /// Project `path` into a Claude session under `project_dir` and return diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 8c8729c3..0cd51cf5 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -37,35 +37,36 @@ //! //! ## Remote (`--remote ssh://[user@]host[:port][/path]`) //! -//! v2 (host resolves, remote incepts): the **host** resolves the -//! document locally, pipes the hydrated JSON into `path p incept` on the -//! remote, and launches the harness directly — so the remote needs -//! `path` (for incept) and the harness installed, but no Pathbase -//! access and no temp files. Steps ([`run_remote`]): +//! 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 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. Steps ([`run_remote`]): //! -//! 1. **Resolve + validate on the host** — same `resolve_input` / +//! 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 host -//! also computes the session id here: it's a pure function of the -//! document ([`crate::cmd_export::claude_session_id`]), so both -//! sides projecting the same bytes agree on it. -//! 2. **Version preflight** — `ssh host 'path --version'`, captured and -//! echoed as `host path: / remote path: `. Confirms SSH is -//! reachable and `path` is installed; a failed probe aborts here -//! rather than dropping the user into a doomed session. -//! 3. **Hydrate** — `ssh host 'path p incept claude [--project ]'` -//! with the resolved JSON on stdin; incept writes the session into -//! the remote's Claude project layout. +//! document fails fast on the host, not deep inside SSH. The session +//! id and JSONL come from the same in-memory projection +//! ([`crate::cmd_export::claude_session_jsonl`]). +//! 2. **Reachability probe** — `ssh host 'pwd'` (deliberately not +//! `path`). A failed probe aborts here rather than dropping the user +//! into a doomed session; the probed cwd doubles as the launch dir +//! when no `--cwd` was pinned. +//! 3. **Ship** — `ssh host 'mkdir -p "$HOME/.claude/projects/" && +//! cat > "$HOME/.claude/projects//.jsonl"'` with the +//! projected JSONL on stdin. `` is the launch cwd run through +//! Claude Code's own project-dir sanitization, so `claude -r` +//! started there finds the session. //! 4. **Launch** — `execvp` an interactive `ssh -t host '[cd && ] //! claude -r '`. The `-t` gives the remote harness a real //! terminal. //! //! `--harness` is required with `--remote` (and currently must be -//! `claude` — incept and the id computation are Claude-specific). The -//! resolution-only flags (`--no-cache`/`--force`/`--url`) act on the -//! host and are never forwarded. `--cwd` does double duty as incept's -//! `--project` dir and the launch's `cd` target; absent, both default -//! to the remote's ssh cwd (`$HOME`). +//! `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`). //! //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` //! for the full design. @@ -131,9 +132,9 @@ pub fn run(args: ResumeArgs) -> Result<()> { /// 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 here, hydrate the session on the remote via - // `path p incept`, and launch the harness over an interactive SSH. - // See the module docs' "Remote" section and `run_remote`. + // 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); } @@ -559,8 +560,8 @@ pub trait ExecStrategy { fn capture(&self, binary: &str, args: &[String]) -> Result; /// Run a command, feeding `input` to its stdin, and wait for it to - /// finish. Used to hydrate the resolved document on the remote - /// (`ssh host 'path p incept claude …'`). Errors if the command + /// finish. Used to ship the projected session file to the remote + /// (`ssh host 'mkdir -p … && cat > …'`). Errors if the command /// can't be spawned or exits non-zero. fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> Result<()>; } @@ -761,8 +762,8 @@ pub(crate) fn exec_harness( /// session). Absent, both default to the remote's ssh cwd (`$HOME`). 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: incept and the - // host-side session-id computation are Claude-specific. + // 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 \ @@ -773,43 +774,42 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul } // 1. Resolve + validate locally so a bad document fails on the host, - // not deep inside an SSH session — and compute the session id the - // remote's projection will deterministically arrive at. + // 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 = crate::cmd_export::claude_session_id(path)?; - let json = graph - .to_json() - .map_err(|e| anyhow::anyhow!("serialize resolved document: {e}"))?; - - // 2. Version preflight: confirm SSH is reachable and `path` is - // installed remotely, echoing host + remote versions. - let (ssh_bin, probe_argv) = ssh_invocation(remote, "path --version")?; - let remote_version = exec.capture(&ssh_bin, &probe_argv).with_context(|| { - format!( - "probing remote `path` over {remote} — is `path` installed there and is the host reachable over SSH?" - ) + let (session_id, jsonl) = crate::cmd_export::claude_session_jsonl(path)?; + + // 2. Reachability probe: plain `pwd` — deliberately not `path`, which + // isn't required on the remote. Its output doubles as the remote + // working directory when no --cwd was pinned. + let (ssh_bin, probe_argv) = ssh_invocation(remote, "pwd")?; + let remote_pwd = exec.capture(&ssh_bin, &probe_argv).with_context(|| { + format!("probing remote over {remote} — is the host reachable over SSH?") })?; - eprintln!( - "host path: {} / remote path: {}", - env!("CARGO_PKG_VERSION"), - remote_version.trim() - ); - - // 3. Hydrate: pipe the JSON into remote incept, which writes the - // session into the remote's Claude project layout. - let incept_cmd = remote_incept_command(args.cwd.as_deref()); - let (incept_bin, incept_argv) = ssh_invocation(remote, &incept_cmd)?; - exec.pipe(&incept_bin, &incept_argv, json.as_bytes()) - .with_context(|| { - format!( - "hydrating session on {remote} via `{incept_cmd}` — does the \ - remote `path` support `p incept`?" - ) - })?; - eprintln!("Incepted session {session_id} on {remote}"); - - // 4. Interactive launch of the harness against the incepted session, + let remote_pwd = remote_pwd.trim().to_string(); + if remote_pwd.is_empty() { + anyhow::bail!("remote probe over {remote} returned no working directory"); + } + eprintln!("remote {remote}: reachable (cwd {remote_pwd})"); + + // 3. Ship: pipe the projected JSONL straight into the remote's Claude + // project layout. The project dir is keyed on the launch cwd + // (--cwd, else the probed remote cwd) with Claude Code's own + // sanitization, so `claude -r` started there finds the session. + let project_path = match args.cwd.as_ref() { + Some(dir) => dir.display().to_string(), + None => remote_pwd, + }; + let dir_name = claude_project_dir_name(&project_path); + let ship_cmd = remote_ship_command(&dir_name, &session_id); + let (ship_bin, ship_argv) = ssh_invocation(remote, &ship_cmd)?; + exec.pipe(&ship_bin, &ship_argv, jsonl.as_bytes()) + .with_context(|| format!("shipping session file to {remote}"))?; + eprintln!("Shipped session {session_id} → {remote} ~/.claude/projects/{dir_name}/"); + + // 4. Interactive launch of the harness against the shipped session, // with a real TTY. let launch_cmd = remote_launch_command(&session_id, args.cwd.as_deref()); let (binary, argv) = ssh_invocation_tty(remote, &launch_cmd, true)?; @@ -817,20 +817,20 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul exec_harness(&binary, &argv, &cwd, exec) } -/// The far-side hydrate command: `path p incept claude`, targeting -/// `--project ` when a cwd was pinned. The dir may not exist on the -/// remote yet (resuming into a fresh directory is the normal case), and -/// incept canonicalizes its project path — so create it first. Without a -/// cwd, incept defaults to the remote process cwd — `$HOME` over ssh, -/// matching where the `cd`-less launch lands. -fn remote_incept_command(cwd: Option<&std::path::Path>) -> String { - match cwd { - Some(dir) => { - let dir = shell_single_quote(&dir.display().to_string()); - format!("mkdir -p {dir} && path p incept claude --project {dir}") - } - None => "path p incept claude".to_string(), - } +/// 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(['/', '_', '.'], "-") +} + +/// The far-side ship command: create the Claude project dir and write +/// the piped JSONL as the session file. `$HOME` is left for the remote +/// shell to expand — the host doesn't know the remote home. +fn remote_ship_command(project_dir_name: &str, session_id: &str) -> String { + let dir = format!("$HOME/.claude/projects/{project_dir_name}"); + format!("mkdir -p \"{dir}\" && cat > \"{dir}/{session_id}.jsonl\"") } /// The far-side launch command: `claude -r `, prefixed with a @@ -975,16 +975,34 @@ mod tests { } #[test] - fn remote_incept_command_with_and_without_cwd() { - assert_eq!(remote_incept_command(None), "path p incept claude"); - // The pinned dir may not exist on the remote yet (resuming into a - // fresh directory is the normal case) — incept must create it. + fn remote_ship_command_writes_into_remote_claude_projects() { + // v3 ships the fully-projected JSONL straight into the remote's + // Claude layout — no `path` on the remote. `$HOME` stays for the + // remote shell to expand. assert_eq!( - remote_incept_command(Some(std::path::Path::new("/srv/work"))), - "mkdir -p '/srv/work' && path p incept claude --project '/srv/work'" + remote_ship_command("-tmp-somewhere", "sess-1"), + "mkdir -p \"$HOME/.claude/projects/-tmp-somewhere\" && \ + cat > \"$HOME/.claude/projects/-tmp-somewhere/sess-1.jsonl\"" ); } + #[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); + } + #[test] fn remote_launch_command_quotes_id_and_cds() { assert_eq!(remote_launch_command("sess-1", None), "claude -r 'sess-1'"); @@ -1014,41 +1032,49 @@ mod tests { } #[test] - fn remote_resume_probes_incepts_then_launches() { - // v2: host resolves the doc locally, probes `path --version`, pipes - // the JSON into `path p incept claude` on the remote, then launches - // an interactive `ssh -t … claude -r ` where is computed - // host-side from the document (deterministic: same bytes → same id). + fn remote_resume_probes_ships_then_launches() { + // v3: host resolves AND projects the session locally, probes the + // remote with plain `pwd` (no `path` needed there), ships the + // projected JSONL straight 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(); - // 1. version probe + // 1. reachability probe: `pwd`, never `path` — the remote doesn't + // need `path` installed. let probes = rec.probes(); - assert_eq!(probes.len(), 1, "exactly one version probe"); + assert_eq!(probes.len(), 1, "exactly one probe"); assert!( - probes[0].args.iter().any(|a| a == "path --version"), + probes[0].args.iter().any(|a| a == "pwd"), "probe argv: {:?}", probes[0].args ); + assert!( + !probes[0].args.iter().any(|a| a.contains("path")), + "v3 must not require `path` on the remote: {:?}", + probes[0].args + ); - // 2. incept: `ssh … 'path p incept claude'` with the JSON on stdin. + // 2. ship: `ssh … 'mkdir -p … && cat > ~/.claude/projects/…'` with + // the projected JSONL (not the toolpath doc) on stdin. let staged = rec.staged(); - assert_eq!(staged.len(), 1, "exactly one incept step"); - let (incept_inv, stdin) = &staged[0]; - assert_eq!(incept_inv.binary, "ssh"); + assert_eq!(staged.len(), 1, "exactly one ship step"); + let (ship_inv, stdin) = &staged[0]; + assert_eq!(ship_inv.binary, "ssh"); + let ship_arg = ship_inv + .args + .iter() + .find(|a| a.contains("cat > ")) + .expect("ship should `cat >` the session file"); assert!( - incept_inv - .args - .iter() - .any(|a| a.starts_with("path p incept claude")), - "incept argv: {:?}", - incept_inv.args + ship_arg.contains(".claude/projects/") && ship_arg.contains("remote-v1-test.jsonl"), + "ship target: {ship_arg}" ); - // The piped bytes are the resolved document. + // The piped bytes are Claude JSONL, not the raw toolpath doc. assert!( - stdin.contains("claude-code://remote-v1-test"), - "stdin: {stdin}" + stdin.contains("\"sessionId\":\"remote-v1-test\""), + "stdin should be projected JSONL: {stdin}" ); // 3. launch: interactive `ssh -t … claude -r ''` with the id diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index e0e79625..21737da8 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -358,9 +358,10 @@ fn explicit_harness_not_on_path_errors() { // ── Remote resume over SSH ────────────────────────────────────────── /// With `--remote `, resume should be dispatched to the remote -/// host over SSH rather than exec'ing a local harness: the JSON is piped -/// into `path p incept claude` on the remote, and the final recorded -/// invocation must be `ssh -t` targeting the remote host and launching +/// 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() { @@ -378,21 +379,27 @@ fn remote_flag_dispatches_resume_over_ssh() { let recorder = RecordingExec::default(); run_with_strategy(args, &recorder).unwrap(); - // Hydration: the resolved JSON is piped into remote incept. + // Ship: the locally-projected JSONL is piped into the remote's + // Claude projects layout — no `path` runs on the remote. let staged = recorder.staged(); - assert_eq!(staged.len(), 1, "exactly one incept pipe"); - let (incept_inv, stdin) = &staged[0]; + assert_eq!(staged.len(), 1, "exactly one ship pipe"); + let (ship_inv, stdin) = &staged[0]; assert!( - incept_inv + ship_inv .args .iter() - .any(|a| a.contains("path p incept claude")), - "remote should hydrate via `path p incept claude`, got {:?}", - incept_inv.args + .any(|a| a.contains("cat > ") && a.contains(".claude/projects/")), + "remote should receive the file via `cat >` into ~/.claude/projects, got {:?}", + ship_inv.args ); assert!( - stdin.contains("claude-code://resume-remote-int"), - "incept stdin should carry the resolved doc" + !ship_inv.args.iter().any(|a| a.contains("path p incept")), + "v3 must not run `path` on the remote, got {:?}", + ship_inv.args + ); + assert!( + stdin.contains("\"sessionId\":\"resume-remote-int\""), + "ship stdin should carry the projected JSONL" ); // Launch: interactive ssh -t running the harness directly. From 5080dfa696eb49e32d7dec889df99337600d6f4e Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:19:43 -0400 Subject: [PATCH 09/39] fix(resume): mkdir -p the pinned --cwd before the remote launch cd v2's incept created the cwd as a side effect; v3's ship only creates the Claude projects dir, so `cd ''` failed on a fresh remote directory. Create it in the launch command. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_resume.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 0cd51cf5..cd252ab8 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -834,15 +834,16 @@ fn remote_ship_command(project_dir_name: &str, session_id: &str) -> String { } /// The far-side launch command: `claude -r `, prefixed with a -/// `cd &&` when a cwd was pinned so the harness starts where the -/// session was incepted. +/// `mkdir -p && cd &&` when a cwd was pinned so the harness +/// starts where the shipped session is keyed — creating the directory +/// first, since resuming into a fresh one is the normal case. fn remote_launch_command(session_id: &str, cwd: Option<&std::path::Path>) -> String { let launch = format!("claude -r {}", shell_single_quote(session_id)); match cwd { - Some(dir) => format!( - "cd {} && {launch}", - shell_single_quote(&dir.display().to_string()) - ), + Some(dir) => { + let dir = shell_single_quote(&dir.display().to_string()); + format!("mkdir -p {dir} && cd {dir} && {launch}") + } None => launch, } } @@ -1006,9 +1007,11 @@ mod tests { #[test] fn remote_launch_command_quotes_id_and_cds() { assert_eq!(remote_launch_command("sess-1", None), "claude -r 'sess-1'"); + // The pinned cwd may not exist on the remote (resuming into a + // fresh directory is the normal case) — create it before cd'ing. assert_eq!( remote_launch_command("sess-1", Some(std::path::Path::new("/srv/work"))), - "cd '/srv/work' && claude -r 'sess-1'" + "mkdir -p '/srv/work' && cd '/srv/work' && claude -r 'sess-1'" ); } From 85458feabc6a275f3bf276de910936281e8f158e Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:31:31 -0400 Subject: [PATCH 10/39] =?UTF-8?q?refactor(resume):=20typed=20SFTP=20transp?= =?UTF-8?q?ort=20for=20remote=20resume=20=E2=80=94=20no=20shell=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the composed remote shell commands (`pwd`, `mkdir -p … && cat > …`) with typed libssh2 SFTP calls, matching the repo's git2-over-shelling-out ethos. ExecStrategy loses capture/pipe and gains remote_home / remote_mkdirs / remote_write against a parsed SshTarget (new parse_ssh_url; ssh_invocation_tty rebuilt on it). The preflight is now an SFTP realpath of the remote home (first remote touch → reachability/auth errors surface there, agent auth with a clear ssh-add hint); the session file and a pinned --cwd are created over SFTP. The only remaining remote shell string is the interactive launch (`cd && claude -r `), which stays on the real ssh binary because it needs the user's TTY and ssh config. RecordingExec now records typed values (targets, dirs, path+bytes) instead of shell strings. Adds ssh2 to the workspace. Verified live against the sshd sandbox: agent auth, home resolved, JSONL written and launch dir created over SFTP, correct launch. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 13 + Cargo.toml | 1 + crates/path-cli/Cargo.toml | 1 + crates/path-cli/src/cmd_resume.rs | 515 ++++++++++++++++++------------ crates/path-cli/tests/resume.rs | 28 +- 5 files changed, 334 insertions(+), 224 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54cfdfc2..70413f36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2465,6 +2465,7 @@ dependencies = [ "sha2", "similar", "skim", + "ssh2", "tempfile", "tokio", "toolpath", @@ -3727,6 +3728,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" diff --git a/Cargo.toml b/Cargo.toml index 4f981bc2..eec60368 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ similar = "2" tempfile = "3.15" insta = "1" rusqlite = { version = "0.32", features = ["bundled"] } +ssh2 = "0.9" uuid = { version = "1", features = ["serde"] } [profile.wasm] diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 328767c2..405b2a8c 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -56,6 +56,7 @@ git2 = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } rusqlite = { workspace = true } +ssh2 = { 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_resume.rs b/crates/path-cli/src/cmd_resume.rs index cd252ab8..3543e76e 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -39,27 +39,34 @@ //! //! 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 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. Steps ([`run_remote`]): +//! 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 //! ([`crate::cmd_export::claude_session_jsonl`]). -//! 2. **Reachability probe** — `ssh host 'pwd'` (deliberately not -//! `path`). A failed probe aborts here rather than dropping the user -//! into a doomed session; the probed cwd doubles as the launch dir -//! when no `--cwd` was pinned. -//! 3. **Ship** — `ssh host 'mkdir -p "$HOME/.claude/projects/" && -//! cat > "$HOME/.claude/projects//.jsonl"'` with the -//! projected JSONL on stdin. `` is the launch cwd run through -//! Claude Code's own project-dir sanitization, so `claude -r` -//! started there finds the session. +//! 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. +//! terminal; this is the one step that stays on the real `ssh` +//! binary (it needs the user's TTY and ssh config). libssh2 uses +//! agent auth and does not read `~/.ssh/config`/`known_hosts`; the +//! launch does. //! //! `--harness` is required with `--remote` (and currently must be //! `claude` — the projection and layout knowledge are Claude-specific). @@ -549,21 +556,35 @@ pub struct CapturedExec { pub cwd: std::path::PathBuf, } -/// Pluggable exec backend. Production uses `RealExec` (`execvp` on -/// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`. +/// A parsed `ssh://[user@]host[:port][/path]` remote. +#[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<()>; - /// Run a command and return its trimmed stdout. Used for the remote - /// version preflight (`ssh host 'path --version'`). Errors if the - /// command can't be spawned or exits non-zero. - fn capture(&self, binary: &str, args: &[String]) -> 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<()>; - /// Run a command, feeding `input` to its stdin, and wait for it to - /// finish. Used to ship the projected session file to the remote - /// (`ssh host 'mkdir -p … && cat > …'`). Errors if the command - /// can't be spawned or exits non-zero. - fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> 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<()>; } /// Production implementation. On Unix this never returns on success @@ -609,70 +630,93 @@ impl ExecStrategy for RealExec { } } - fn capture(&self, binary: &str, args: &[String]) -> Result { - let out = std::process::Command::new(binary) - .args(args) - .output() - .with_context(|| format!("run `{}`", binary))?; - if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - anyhow::bail!("`{} {}` failed: {}", binary, args.join(" "), stderr.trim()); + fn remote_home(&self, target: &SshTarget) -> Result { + let sftp = sftp_channel(target)?; + let home = sftp + .realpath(std::path::Path::new(".")) + .context("resolve remote home directory")?; + Ok(home.to_string_lossy().to_string()) + } + + fn remote_mkdirs(&self, target: &SshTarget, dir: &str) -> Result<()> { + let sftp = sftp_channel(target)?; + // 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(String::from_utf8_lossy(&out.stdout).trim().to_string()) + Ok(()) } - fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> Result<()> { + fn remote_write(&self, target: &SshTarget, path: &str, data: &[u8]) -> Result<()> { use std::io::Write; - use std::process::Stdio; - let mut child = std::process::Command::new(binary) - .args(args) - .stdin(Stdio::piped()) - .spawn() - .with_context(|| format!("spawn `{}`", binary))?; - // Take + drop the handle after writing so the child sees EOF. - { - let mut stdin = child - .stdin - .take() - .ok_or_else(|| anyhow::anyhow!("`{}` stdin unavailable", binary))?; - stdin - .write_all(input) - .with_context(|| format!("write to `{}` stdin", binary))?; - } - let status = child - .wait() - .with_context(|| format!("wait for `{}`", binary))?; - if !status.success() { - anyhow::bail!( - "`{} {}` failed (exit {:?})", - binary, - args.join(" "), - status.code() - ); - } + let sftp = sftp_channel(target)?; + 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(()) } } +/// Open an authenticated SFTP channel to `target`: TCP connect, +/// handshake, then SSH-agent auth as the URL's user (or `$USER`). Each +/// call opens a fresh connection — the remote flow makes a handful of +/// calls, so pooling isn't worth the state. +/// +/// Note: libssh2 does not consult `~/.ssh/known_hosts` or +/// `~/.ssh/config`; auth is agent-only. The interactive launch still +/// goes through the real `ssh` binary with the user's full config. +fn sftp_channel(target: &SshTarget) -> Result { + let port = target.port.unwrap_or(22); + let addr = format!("{}:{}", target.host, port); + let tcp = std::net::TcpStream::connect(&addr).with_context(|| format!("connect to {addr}"))?; + let mut sess = ssh2::Session::new().context("create SSH session")?; + sess.set_tcp_stream(tcp); + sess.handshake() + .with_context(|| format!("SSH handshake with {addr}"))?; + let user = match target.user.clone() { + Some(u) => u, + None => { + std::env::var("USER").context("no SSH user: put `user@` in the URL or set $USER")? + } + }; + sess.userauth_agent(&user).with_context(|| { + format!("SSH agent auth as `{user}` on {addr} — is the key loaded (`ssh-add`)?") + })?; + sess.sftp().context("open SFTP channel") +} + /// Recording strategy for tests. `captured()` returns the most recent -/// invocation. +/// 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, - probes: std::sync::Mutex>, - /// Staged documents: (invocation, stdin bytes as UTF-8 string). - staged: std::sync::Mutex>, - /// When true, `capture` returns an error instead of a fake version — - /// simulates a remote where `path` is missing or SSH is unreachable. - probe_fails: bool, + homes: std::sync::Mutex>, + mkdirs: std::sync::Mutex>, + /// Written files: (remote path, contents as UTF-8 string). + writes: std::sync::Mutex>, + /// When true, `remote_home` returns an error — simulates an + /// unreachable or unauthenticated remote. + home_fails: bool, } impl RecordingExec { - /// A recorder whose version probe fails, for exercising the + /// A recorder whose remote preflight fails, for exercising the /// abort-before-dispatch path. - pub fn failing_probe() -> Self { + pub fn failing_remote() -> Self { Self { - probe_fails: true, + home_fails: true, ..Default::default() } } @@ -681,17 +725,26 @@ impl RecordingExec { self.inner.lock().unwrap().clone() } - /// Every `capture` (version-probe) invocation, in call order. - pub fn probes(&self) -> Vec { - self.probes.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 `pipe` (stage) invocation as `(invocation, stdin string)`. - pub fn staged(&self) -> Vec<(CapturedExec, String)> { - self.staged.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(); @@ -703,27 +756,24 @@ impl ExecStrategy for RecordingExec { Ok(()) } - fn capture(&self, binary: &str, args: &[String]) -> Result { - self.probes.lock().unwrap().push(CapturedExec { - binary: binary.to_string(), - args: args.to_vec(), - cwd: std::path::PathBuf::new(), - }); - if self.probe_fails { - anyhow::bail!("`path: command not found`"); + fn remote_home(&self, target: &SshTarget) -> Result { + self.homes.lock().unwrap().push(target.clone()); + if self.home_fails { + anyhow::bail!("connection refused"); } - Ok("path 0.0.0-recording".to_string()) + Ok(RECORDING_REMOTE_HOME.to_string()) } - fn pipe(&self, binary: &str, args: &[String], input: &[u8]) -> Result<()> { - self.staged.lock().unwrap().push(( - CapturedExec { - binary: binary.to_string(), - args: args.to_vec(), - cwd: std::path::PathBuf::new(), - }, - String::from_utf8_lossy(input).to_string(), - )); + fn remote_mkdirs(&self, _target: &SshTarget, dir: &str) -> Result<()> { + self.mkdirs.lock().unwrap().push(dir.to_string()); + Ok(()) + } + + fn remote_write(&self, _target: &SshTarget, path: &str, data: &[u8]) -> Result<()> { + self.writes + .lock() + .unwrap() + .push((path.to_string(), String::from_utf8_lossy(data).to_string())); Ok(()) } } @@ -780,37 +830,49 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul 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. Reachability probe: plain `pwd` — deliberately not `path`, which - // isn't required on the remote. Its output doubles as the remote - // working directory when no --cwd was pinned. - let (ssh_bin, probe_argv) = ssh_invocation(remote, "pwd")?; - let remote_pwd = exec.capture(&ssh_bin, &probe_argv).with_context(|| { + // 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?") })?; - let remote_pwd = remote_pwd.trim().to_string(); - if remote_pwd.is_empty() { - anyhow::bail!("remote probe over {remote} returned no working directory"); + if home.is_empty() { + anyhow::bail!("remote probe over {remote} returned no home directory"); } - eprintln!("remote {remote}: reachable (cwd {remote_pwd})"); + eprintln!("remote {remote}: reachable (home {home})"); - // 3. Ship: pipe the projected JSONL straight into the remote's Claude - // project layout. The project dir is keyed on the launch cwd - // (--cwd, else the probed remote cwd) with Claude Code's own - // sanitization, so `claude -r` started there finds the session. + // 3. 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. let project_path = match args.cwd.as_ref() { Some(dir) => dir.display().to_string(), - None => remote_pwd, + None => home.clone(), }; let dir_name = claude_project_dir_name(&project_path); - let ship_cmd = remote_ship_command(&dir_name, &session_id); - let (ship_bin, ship_argv) = ssh_invocation(remote, &ship_cmd)?; - exec.pipe(&ship_bin, &ship_argv, jsonl.as_bytes()) - .with_context(|| format!("shipping session file to {remote}"))?; - eprintln!("Shipped session {session_id} → {remote} ~/.claude/projects/{dir_name}/"); + let projects_dir = format!("{home}/.claude/projects/{dir_name}"); + exec.remote_mkdirs(&target, &projects_dir) + .with_context(|| format!("creating {projects_dir} on {remote}"))?; + let dest = format!("{projects_dir}/{session_id}.jsonl"); + exec.remote_write(&target, &dest, jsonl.as_bytes()) + .with_context(|| format!("shipping session file to {remote}:{dest}"))?; + eprintln!("Shipped session {session_id} → {remote}:{dest}"); + + // The launch cwd must exist before the interactive `cd` — create it + // over SFTP too, since resuming into a fresh directory is the normal + // case. + if let Some(dir) = args.cwd.as_ref() { + let dir = dir.display().to_string(); + exec.remote_mkdirs(&target, &dir) + .with_context(|| format!("creating launch dir {dir} on {remote}"))?; + } // 4. Interactive launch of the harness against the shipped session, - // with a real TTY. + // with a real TTY — the one step that stays on the real `ssh` + // binary (it needs the user's terminal and ssh config). let launch_cmd = remote_launch_command(&session_id, args.cwd.as_deref()); let (binary, argv) = ssh_invocation_tty(remote, &launch_cmd, true)?; let cwd = std::env::current_dir()?; @@ -825,42 +887,25 @@ fn claude_project_dir_name(project_path: &str) -> String { project_path.replace(['/', '_', '.'], "-") } -/// The far-side ship command: create the Claude project dir and write -/// the piped JSONL as the session file. `$HOME` is left for the remote -/// shell to expand — the host doesn't know the remote home. -fn remote_ship_command(project_dir_name: &str, session_id: &str) -> String { - let dir = format!("$HOME/.claude/projects/{project_dir_name}"); - format!("mkdir -p \"{dir}\" && cat > \"{dir}/{session_id}.jsonl\"") -} - /// The far-side launch command: `claude -r `, prefixed with a -/// `mkdir -p && cd &&` when a cwd was pinned so the harness -/// starts where the shipped session is keyed — creating the directory -/// first, since resuming into a fresh one is the normal case. +/// `cd &&` when a cwd was pinned so the harness starts where the +/// shipped session is keyed. 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. fn remote_launch_command(session_id: &str, cwd: Option<&std::path::Path>) -> String { let launch = format!("claude -r {}", shell_single_quote(session_id)); match cwd { - Some(dir) => { - let dir = shell_single_quote(&dir.display().to_string()); - format!("mkdir -p {dir} && cd {dir} && {launch}") - } + Some(dir) => format!( + "cd {} && {launch}", + shell_single_quote(&dir.display().to_string()) + ), None => launch, } } -/// Build a non-interactive `ssh` invocation (no `-t`). Used for the -/// version probe and the incept hydration step. -fn ssh_invocation(remote: &str, remote_cmd: &str) -> Result<(String, Vec)> { - ssh_invocation_tty(remote, remote_cmd, false) -} - -/// Build the `ssh` invocation from a full SSH URL -/// (`ssh://[user@]host[:port][/path]`) and an already-built remote -/// command. Returns `("ssh", argv)` where argv is -/// `[-t]? [-p ]? <[user@]host> `. Pass `tty = true` -/// for the interactive resume so the remote harness (and its picker) get -/// a real terminal. -fn ssh_invocation_tty(remote: &str, remote_cmd: &str, tty: bool) -> Result<(String, Vec)> { +/// Parse a full SSH URL (`ssh://[user@]host[:port][/path]`) into a +/// typed [`SshTarget`]. The optional `/path` component is ignored. +fn parse_ssh_url(remote: &str) -> Result { let rest = remote .strip_prefix("ssh://") .with_context(|| format!("remote must be a full SSH URL (ssh://…), got `{remote}`"))?; @@ -876,23 +921,47 @@ fn ssh_invocation_tty(remote: &str, remote_cmd: &str, tty: bool) -> Result<(Stri } // 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)), - _ => (authority, None), - }; + 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) = port { + if let Some(port) = target.port { argv.push("-p".to_string()); argv.push(port.to_string()); } - argv.push(userhost.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)) } @@ -923,9 +992,10 @@ mod tests { #[test] fn ssh_invocation_parses_user_host_port_and_path() { - let (binary, argv) = ssh_invocation( + 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"); @@ -942,7 +1012,8 @@ mod tests { #[test] fn ssh_invocation_without_port_omits_p_flag() { - let (_binary, argv) = ssh_invocation("ssh://example.com", "path resume 'abc'").unwrap(); + 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()] @@ -951,7 +1022,7 @@ mod tests { #[test] fn ssh_invocation_rejects_non_ssh_url() { - let err = ssh_invocation("https://example.com/x", "path resume 'abc'").unwrap_err(); + let err = parse_ssh_url("https://example.com/x").unwrap_err(); assert!(err.to_string().contains("full SSH URL"), "actual: {err}"); } @@ -976,17 +1047,31 @@ mod tests { } #[test] - fn remote_ship_command_writes_into_remote_claude_projects() { - // v3 ships the fully-projected JSONL straight into the remote's - // Claude layout — no `path` on the remote. `$HOME` stays for the - // remote shell to expand. + fn parse_ssh_url_extracts_user_host_port_and_ignores_path() { assert_eq!( - remote_ship_command("-tmp-somewhere", "sess-1"), - "mkdir -p \"$HOME/.claude/projects/-tmp-somewhere\" && \ - cat > \"$HOME/.claude/projects/-tmp-somewhere/sess-1.jsonl\"" + 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 claude_project_dir_name_matches_projector_sanitization() { // The host-side mirror of Claude Code's project-dir sanitization @@ -1007,11 +1092,11 @@ mod tests { #[test] fn remote_launch_command_quotes_id_and_cds() { assert_eq!(remote_launch_command("sess-1", None), "claude -r 'sess-1'"); - // The pinned cwd may not exist on the remote (resuming into a - // fresh directory is the normal case) — create it before cd'ing. + // Directory creation happens over SFTP before launch — the shell + // string stays minimal: just the cd and the harness. assert_eq!( remote_launch_command("sess-1", Some(std::path::Path::new("/srv/work"))), - "mkdir -p '/srv/work' && cd '/srv/work' && claude -r 'sess-1'" + "cd '/srv/work' && claude -r 'sess-1'" ); } @@ -1036,48 +1121,41 @@ mod tests { #[test] fn remote_resume_probes_ships_then_launches() { - // v3: host resolves AND projects the session locally, probes the - // remote with plain `pwd` (no `path` needed there), ships the - // projected JSONL straight into the remote's Claude layout, then - // launches an interactive `ssh -t … claude -r `. + // 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(); - // 1. reachability probe: `pwd`, never `path` — the remote doesn't - // need `path` installed. - let probes = rec.probes(); - assert_eq!(probes.len(), 1, "exactly one probe"); - assert!( - probes[0].args.iter().any(|a| a == "pwd"), - "probe argv: {:?}", - probes[0].args - ); - assert!( - !probes[0].args.iter().any(|a| a.contains("path")), - "v3 must not require `path` on the remote: {:?}", - probes[0].args + // 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: `ssh … 'mkdir -p … && cat > ~/.claude/projects/…'` with - // the projected JSONL (not the toolpath doc) on stdin. - let staged = rec.staged(); - assert_eq!(staged.len(), 1, "exactly one ship step"); - let (ship_inv, stdin) = &staged[0]; - assert_eq!(ship_inv.binary, "ssh"); - let ship_arg = ship_inv - .args - .iter() - .find(|a| a.contains("cat > ")) - .expect("ship should `cat >` the session file"); - assert!( - ship_arg.contains(".claude/projects/") && ship_arg.contains("remote-v1-test.jsonl"), - "ship target: {ship_arg}" + // 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) ); - // The piped bytes are Claude JSONL, not the raw toolpath doc. + 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!( - stdin.contains("\"sessionId\":\"remote-v1-test\""), - "stdin should be projected JSONL: {stdin}" + body.contains("\"sessionId\":\"remote-v1-test\""), + "written bytes should be projected JSONL: {body}" ); // 3. launch: interactive `ssh -t … claude -r ''` with the id @@ -1096,6 +1174,31 @@ mod tests { ); } + #[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 + ); + } + #[test] fn remote_resume_rejects_non_claude_harness() { let td = tempfile::tempdir().unwrap(); @@ -1107,23 +1210,23 @@ mod tests { err.to_string().contains("claude"), "error should name the supported harness: {err}" ); - assert!(rec.probes().is_empty(), "must fail before probing"); + assert!(rec.homes().is_empty(), "must fail before any remote touch"); } #[test] - fn remote_resume_aborts_when_version_probe_fails() { - // If the remote probe fails (no path, no SSH), abort before staging - // or dispatch rather than hand off to a doomed session. + 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_probe(); + 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") || err.to_string().contains("path"), - "error should explain the remote probe failure: {err}" + err.to_string().contains("remote"), + "error should explain the preflight failure: {err}" ); assert!( - rec.staged().is_empty(), - "must not stage after a failed probe" + 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"); } @@ -1136,7 +1239,7 @@ mod tests { let rec = RecordingExec::default(); let err = run_with_strategy(args, &rec).unwrap_err(); assert!(err.to_string().contains("--harness"), "actual: {err}"); - assert!(rec.probes().is_empty(), "must fail before probing"); + assert!(rec.homes().is_empty(), "must fail before any remote touch"); } #[test] diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 21737da8..44810ff5 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -379,27 +379,19 @@ fn remote_flag_dispatches_resume_over_ssh() { let recorder = RecordingExec::default(); run_with_strategy(args, &recorder).unwrap(); - // Ship: the locally-projected JSONL is piped into the remote's - // Claude projects layout — no `path` runs on the remote. - let staged = recorder.staged(); - assert_eq!(staged.len(), 1, "exactly one ship pipe"); - let (ship_inv, stdin) = &staged[0]; + // 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!( - ship_inv - .args - .iter() - .any(|a| a.contains("cat > ") && a.contains(".claude/projects/")), - "remote should receive the file via `cat >` into ~/.claude/projects, got {:?}", - ship_inv.args - ); - assert!( - !ship_inv.args.iter().any(|a| a.contains("path p incept")), - "v3 must not run `path` on the remote, got {:?}", - ship_inv.args + dest.contains(".claude/projects/") && dest.ends_with("resume-remote-int.jsonl"), + "file should land in the remote Claude layout, got {dest}" ); assert!( - stdin.contains("\"sessionId\":\"resume-remote-int\""), - "ship stdin should carry the projected JSONL" + body.contains("\"sessionId\":\"resume-remote-int\""), + "written bytes should carry the projected JSONL" ); // Launch: interactive ssh -t running the harness directly. From fee15aa1f963eb91e89828f924226525579fca85 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:46:16 -0400 Subject: [PATCH 11/39] feat(resume): ssh_config-aware auth + SCP/exec fallback for the SFTP transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real-world fixes from testing against an exe.dev VM: 1. libssh2 reads no ssh config, so agent auth authenticated with whatever key the agent listed first — and key-pinned hosts (exe.dev identifies the account BY the key) routed the session to an unregistered identity. The transport now natively parses the HostName/User/Port/IdentityFile subset of ~/.ssh/config (Host blocks, */?/! globs, first-value-wins, ~ expansion — no new deps beyond base64; ssh2-config was rejected for hard-depending on git2 0.20), matches configured identities against the agent by public-key blob, and only falls back to any-key agent auth. 2. Servers whose SFTP channel won't open get an SCP-protocol upload (libssh2 scp_send) plus a minimal exec channel for pwd/mkdir -p — still no external binaries. Connections are now bounded (connect and per-op timeouts) and cached per target instead of re-dialing, and exec exit codes are trusted only when there's no output (some minimal sshds report bogus statuses). Verified live against a real exe.dev VM end-to-end: correct identity picked, home resolved, session shipped, and launched on the VM. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/path-cli/Cargo.toml | 1 + crates/path-cli/src/cmd_resume.rs | 429 ++++++++++++++++++++++++++---- 4 files changed, 386 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70413f36..f9ea5c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2445,6 +2445,7 @@ version = "0.15.0" dependencies = [ "anyhow", "assert_cmd", + "base64", "chrono", "clap", "git2", diff --git a/Cargo.toml b/Cargo.toml index eec60368..e66f15b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ 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 405b2a8c..7137afcc 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -57,6 +57,7 @@ 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_resume.rs b/crates/path-cli/src/cmd_resume.rs index 3543e76e..30cb7cbf 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -64,9 +64,14 @@ //! 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 ssh config). libssh2 uses -//! agent auth and does not read `~/.ssh/config`/`known_hosts`; the -//! launch does. +//! 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). @@ -133,7 +138,7 @@ pub struct ResumeArgs { } 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 @@ -590,7 +595,24 @@ pub trait ExecStrategy { /// 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; +/// +/// The remote-transport methods share one authenticated SSH connection +/// (cached across calls) and prefer SFTP; when the server won't open an +/// SFTP channel (some custom sshds don't, e.g. exe.dev VMs), they fall +/// back to the SCP protocol for writes and a minimal exec channel for +/// `pwd`/`mkdir -p` — all still through libssh2, no external binaries. +#[derive(Default)] +pub struct RealExec { + conn: std::sync::Mutex>, +} + +/// A live authenticated session plus the SFTP channel if the server +/// offers one (`None` ⇒ SCP/exec fallback). +struct RemoteConn { + key: SshTarget, + sess: ssh2::Session, + sftp: Option, +} impl ExecStrategy for RealExec { fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { @@ -631,69 +653,343 @@ impl ExecStrategy for RealExec { } fn remote_home(&self, target: &SshTarget) -> Result { - let sftp = sftp_channel(target)?; - let home = sftp - .realpath(std::path::Path::new(".")) - .context("resolve remote home directory")?; - Ok(home.to_string_lossy().to_string()) + self.with_conn(target, |conn| match &conn.sftp { + Some(sftp) => { + let home = sftp + .realpath(std::path::Path::new(".")) + .context("resolve remote home directory")?; + Ok(home.to_string_lossy().to_string()) + } + None => { + let out = exec_channel_capture(&conn.sess, "pwd")?; + let home = out.trim().to_string(); + if home.is_empty() { + anyhow::bail!("remote `pwd` returned nothing"); + } + Ok(home) + } + }) } fn remote_mkdirs(&self, target: &SshTarget, dir: &str) -> Result<()> { - let sftp = sftp_channel(target)?; - // 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 + self.with_conn(target, |conn| match &conn.sftp { + Some(sftp) => { + // Walk the components, creating as we go — `mkdir -p` + // semantics. + let mut cur = std::path::PathBuf::new(); + for comp in std::path::Path::new(dir).components() { + cur.push(comp); + if cur.parent().is_none() { + continue; // skip the root component + } + if sftp.stat(&cur).is_ok() { + continue; // already exists + } + sftp.mkdir(&cur, 0o755) + .with_context(|| format!("create remote dir {}", cur.display()))?; + } + Ok(()) } - if sftp.stat(&cur).is_ok() { - continue; // already exists + None => { + exec_channel_capture(&conn.sess, &format!("mkdir -p {}", shell_single_quote(dir))) + .with_context(|| format!("create remote dir {dir}"))?; + Ok(()) } - sftp.mkdir(&cur, 0o755) - .with_context(|| format!("create remote dir {}", cur.display()))?; - } - Ok(()) + }) } fn remote_write(&self, target: &SshTarget, path: &str, data: &[u8]) -> Result<()> { use std::io::Write; - let sftp = sftp_channel(target)?; - 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(()) + self.with_conn(target, |conn| match &conn.sftp { + Some(sftp) => { + let mut f = sftp + .create(std::path::Path::new(path)) + .with_context(|| format!("create remote file {path}"))?; + f.write_all(data) + .with_context(|| format!("write remote file {path}"))?; + Ok(()) + } + None => { + // SCP protocol upload — libssh2's scp_send, no external + // binary. + let mut ch = conn + .sess + .scp_send(std::path::Path::new(path), 0o644, data.len() as u64, None) + .with_context(|| format!("open SCP upload to {path}"))?; + ch.write_all(data) + .with_context(|| format!("SCP write to {path}"))?; + ch.send_eof().context("SCP finish (eof)")?; + ch.wait_eof().context("SCP finish (wait eof)")?; + ch.close().context("SCP close")?; + ch.wait_close().context("SCP close (wait)")?; + Ok(()) + } + }) + } +} + +impl RealExec { + /// Run `f` against the cached connection for `target`, dialing (and + /// probing for SFTP support) on first use or when the target + /// changed. + fn with_conn( + &self, + target: &SshTarget, + f: impl FnOnce(&RemoteConn) -> Result, + ) -> Result { + let mut guard = self.conn.lock().unwrap(); + if guard.as_ref().is_none_or(|c| &c.key != target) { + *guard = Some(connect_remote(target)?); + } + f(guard.as_ref().expect("connection just established")) + } +} + +/// Run a one-shot command over a libssh2 exec channel and return its +/// stdout. Used only on servers without an SFTP subsystem, and only for +/// `pwd` / `mkdir -p`. +fn exec_channel_capture(sess: &ssh2::Session, cmd: &str) -> Result { + use std::io::Read; + let mut ch = sess.channel_session().context("open exec channel")?; + ch.exec(cmd).with_context(|| format!("run `{cmd}`"))?; + let mut out = String::new(); + ch.read_to_string(&mut out) + .with_context(|| format!("read `{cmd}` output"))?; + let mut err = String::new(); + ch.stderr().read_to_string(&mut err).ok(); + ch.wait_close().ok(); + let status = ch.exit_status().unwrap_or(-1); + // Some minimal sshds report a bogus nonzero exit even on success — + // trust actual output over the status code when there is any. + if status != 0 && out.trim().is_empty() { + anyhow::bail!( + "`{cmd}` exited {status}{}", + if err.trim().is_empty() { + String::new() + } else { + format!(": {}", err.trim()) + } + ); } + Ok(out) +} + +/// 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, } -/// Open an authenticated SFTP channel to `target`: TCP connect, -/// handshake, then SSH-agent auth as the URL's user (or `$USER`). Each -/// call opens a fresh connection — the remote flow makes a handful of -/// calls, so pooling isn't worth the state. +/// 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', '=']) { + Some((k, v)) => (k.to_ascii_lowercase(), v.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); + } + } + _ => {} + } + } + 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() +} + +/// 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; fall back to plain agent auth (any key) +/// only when no identity is configured or none worked. +fn authenticate( + sess: &ssh2::Session, + user: &str, + identity_files: &[std::path::PathBuf], + addr: &str, +) -> Result<()> { + for key_path in 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(()); + } + } + 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. /// -/// Note: libssh2 does not consult `~/.ssh/known_hosts` or -/// `~/.ssh/config`; auth is agent-only. The interactive launch still -/// goes through the real `ssh` binary with the user's full config. -fn sftp_channel(target: &SshTarget) -> Result { - let port = target.port.unwrap_or(22); - let addr = format!("{}:{}", target.host, port); - let tcp = std::net::TcpStream::connect(&addr).with_context(|| format!("connect to {addr}"))?; +/// `~/.ssh/config` fills the gaps the URL leaves open (HostName, User, +/// Port, IdentityFile); URL values win. known_hosts is still not +/// checked — the interactive launch goes through the real `ssh` binary +/// with the user's full config. +fn connect_remote(target: &SshTarget) -> Result { + use std::net::ToSocketAddrs; + let cfg = ssh_host_config(&target.host); + let host = cfg.host_name.clone().unwrap_or_else(|| target.host.clone()); + let port = target.port.or(cfg.port).unwrap_or(22); + let addr = format!("{host}:{port}"); + // Bounded connect + per-operation timeouts: a wedged remote should + // fail with context, never hang the resume silently. + let sock = addr + .to_socket_addrs() + .with_context(|| format!("resolve {addr}"))? + .next() + .with_context(|| format!("no address for {addr}"))?; + let tcp = std::net::TcpStream::connect_timeout(&sock, std::time::Duration::from_secs(10)) + .with_context(|| format!("connect to {addr}"))?; let mut sess = ssh2::Session::new().context("create SSH session")?; sess.set_tcp_stream(tcp); + sess.set_timeout(30_000); // ms; applies to handshake/auth/channel ops sess.handshake() .with_context(|| format!("SSH handshake with {addr}"))?; - let user = match target.user.clone() { + 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")? } }; - sess.userauth_agent(&user).with_context(|| { - format!("SSH agent auth as `{user}` on {addr} — is the key loaded (`ssh-add`)?") - })?; - sess.sftp().context("open SFTP channel") + authenticate(&sess, &user, &cfg.identity_files, &addr)?; + + // SFTP probe: keep it brief — a server without the subsystem may + // just sit on the channel request until a timeout. + sess.set_timeout(5_000); + let sftp = sess.sftp().ok(); + sess.set_timeout(30_000); + if sftp.is_none() { + eprintln!("note: remote has no SFTP subsystem — using SCP fallback"); + } + + Ok(RemoteConn { + key: target.clone(), + sess, + sftp, + }) } /// Recording strategy for tests. `captured()` returns the most recent @@ -1072,6 +1368,47 @@ mod tests { 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 claude_project_dir_name_matches_projector_sanitization() { // The host-side mirror of Claude Code's project-dir sanitization From e62ccacea18f85129d4e3b4c76646011917124a1 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:05:02 -0400 Subject: [PATCH 12/39] feat(resume): --tmux for detachable remote sessions + derive launch from harness table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `--tmux` (requires --remote) wraps the remote launch in `tmux new-session -A -s path- '…'` so the session survives SSH disconnects and can be detached; `-A` re-attaches on a re-run. - `remote_launch_command` now derives binary+argv from the same per-harness invocation table the local resume uses (name() + argv_for) instead of hardcoding `claude -r`, so the two can't drift when more harnesses go remote. - New `shell_quote` quotes only when needed, keeping the echoed recipe human-readable. Verified live against the exe.dev VM: correct tmux launch line issued. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_resume.rs | 129 +++++++++++++++++++++++---- crates/path-cli/tests/resume.rs | 3 +- crates/path-cli/tests/support/mod.rs | 1 + 3 files changed, 113 insertions(+), 20 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 30cb7cbf..713e56a8 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -135,6 +135,13 @@ pub struct ResumeArgs { /// 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, } pub fn run(args: ResumeArgs) -> Result<()> { @@ -1169,7 +1176,12 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // 4. 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 launch_cmd = remote_launch_command(&session_id, args.cwd.as_deref()); + let launch_cmd = remote_launch_command( + crate::cmd_share::Harness::Claude, + &session_id, + args.cwd.as_deref(), + args.tmux, + ); let (binary, argv) = ssh_invocation_tty(remote, &launch_cmd, true)?; let cwd = std::env::current_dir()?; exec_harness(&binary, &argv, &cwd, exec) @@ -1183,19 +1195,53 @@ fn claude_project_dir_name(project_path: &str) -> String { project_path.replace(['/', '_', '.'], "-") } -/// The far-side launch command: `claude -r `, prefixed with a -/// `cd &&` when a cwd was pinned so the harness starts where the -/// shipped session is keyed. 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. -fn remote_launch_command(session_id: &str, cwd: Option<&std::path::Path>) -> String { - let launch = format!("claude -r {}", shell_single_quote(session_id)); - match cwd { - Some(dir) => format!( - "cd {} && {launch}", - shell_single_quote(&dir.display().to_string()) - ), +/// 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. +/// 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. +fn remote_launch_command( + harness: crate::cmd_share::Harness, + session_id: &str, + cwd: Option<&std::path::Path>, + tmux: bool, +) -> 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 launch = launch.join(" "); + let launch = match cwd { + Some(dir) => format!("cd {} && {launch}", shell_quote(&dir.display().to_string())), None => launch, + }; + if tmux { + // Detachable session: `-A` attaches when the named session + // already exists, so re-running the same resume re-attaches + // instead of erroring. Session names can't contain `.`/`:`. + let name = format!("path-{}", session_id.replace(['.', ':'], "-")); + format!( + "tmux new-session -A -s {} {}", + shell_quote(&name), + shell_single_quote(&launch) + ) + } else { + launch + } +} + +/// 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) } } @@ -1427,14 +1473,53 @@ mod tests { } #[test] - fn remote_launch_command_quotes_id_and_cds() { - assert_eq!(remote_launch_command("sess-1", None), "claude -r 'sess-1'"); + fn remote_launch_command_derives_from_harness_table() { + use crate::cmd_share::Harness; + // Binary + argv come from the same per-harness table the local + // resume uses; safe args stay bare so the recipe reads cleanly. + assert_eq!( + remote_launch_command(Harness::Claude, "sess-1", None, false), + "claude -r sess-1" + ); // Directory creation happens over SFTP before launch — the shell // string stays minimal: just the cd and the harness. assert_eq!( - remote_launch_command("sess-1", Some(std::path::Path::new("/srv/work"))), - "cd '/srv/work' && claude -r 'sess-1'" + remote_launch_command( + Harness::Claude, + "sess-1", + Some(std::path::Path::new("/srv/work")), + false + ), + "cd /srv/work && claude -r sess-1" + ); + } + + #[test] + fn remote_launch_command_tmux_wraps_for_detachable_sessions() { + use crate::cmd_share::Harness; + // --tmux: the whole launch runs inside a named tmux session so + // it survives SSH disconnects; -A re-attaches on a second run. + assert_eq!( + remote_launch_command(Harness::Claude, "sess-1", None, true), + "tmux new-session -A -s path-sess-1 'claude -r sess-1'" ); + assert_eq!( + remote_launch_command( + Harness::Claude, + "sess-1", + Some(std::path::Path::new("/srv/work")), + true + ), + "tmux new-session -A -s path-sess-1 'cd /srv/work && claude -r sess-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 @@ -1453,6 +1538,7 @@ mod tests { force: false, url: None, remote: Some("ssh://dev@example.com:2222".to_string()), + tmux: false, } } @@ -1505,7 +1591,7 @@ mod tests { cap.args ); assert!( - cap.args.iter().any(|a| a == "claude -r 'remote-v1-test'"), + cap.args.iter().any(|a| a == "claude -r remote-v1-test"), "launch cmd: {:?}", cap.args ); @@ -1530,7 +1616,7 @@ mod tests { assert!( cap.args .iter() - .any(|a| a == "cd '/srv/fresh' && claude -r 'remote-v1-test'"), + .any(|a| a == "cd /srv/fresh && claude -r remote-v1-test"), "launch should cd only (no shell mkdir): {:?}", cap.args ); @@ -1607,6 +1693,7 @@ mod tests { force: false, url: None, remote: None, + tmux: false, }; let recorder = RecordingExec::default(); @@ -1745,6 +1832,7 @@ mod tests { force: false, url: None, remote: None, + tmux: false, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -1780,6 +1868,7 @@ mod tests { force: false, url: None, remote: None, + tmux: false, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -1842,6 +1931,7 @@ mod tests { force: false, url: None, remote: None, + tmux: false, }; let result = resolve_input(&args); @@ -1871,6 +1961,7 @@ mod tests { force: false, url: None, remote: None, + tmux: false, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 44810ff5..69662985 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -269,6 +269,7 @@ fn cache_id_input_loads_and_projects() { force: false, url: None, remote: None, + tmux: false, }; let recorder = RecordingExec::default(); @@ -409,7 +410,7 @@ fn remote_flag_dispatches_resume_over_ssh() { assert!( cap.args .iter() - .any(|a| a.contains("claude -r 'resume-remote-int'")), + .any(|a| a.contains("claude -r resume-remote-int")), "ssh should launch `claude -r ` on the remote, got {:?}", cap.args ); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 77061ca6..46f0d656 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -203,6 +203,7 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: HarnessArg) -> ResumeA force: false, url: None, remote: None, + tmux: false, } } From 27a1cd3fa225adc987c043bb6b6366e98dc1c299 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:25:19 -0400 Subject: [PATCH 13/39] =?UTF-8?q?chore(release):=20bump=20path-cli=20to=20?= =?UTF-8?q?0.16.0=20=E2=80=94=20remote=20resume=20over=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the release checklist: crate version, workspace dep entry, site/_data/crates.json, and a CHANGELOG section covering --remote (host-side projection + libssh2 SFTP/SCP transport, ssh_config-aware auth) and --tmux (detachable remote sessions). CLAUDE.md's resume bullet now documents both flags. Site builds 11 pages. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ CLAUDE.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/Cargo.toml | 2 +- site/_data/crates.json | 2 +- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f729ec..eabb4c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to the Toolpath workspace are documented here. +## Resume: remote resume over SSH — 2026-07-23 + +- **`path-cli`** (0.16.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.1): the Claude projector no longer emits diff --git a/CLAUDE.md b/CLAUDE.md index b6c63e53..d3ef605c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,5 +274,5 @@ 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. -- `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`. - `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. diff --git a/Cargo.lock b/Cargo.lock index f9ea5c80..97810f2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index e66f15b4..b2cdf7d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.15.0", path = "crates/path-cli" } +path-cli = { version = "0.16.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"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 7137afcc..04117c12 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.15.0" +version = "0.16.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/site/_data/crates.json b/site/_data/crates.json index f95edbfc..ffc4a243 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.15.0", + "version": "0.16.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", From 1499558f84f0550ff1584b9eda59df79762218a5 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Thu, 23 Jul 2026 22:33:05 -0400 Subject: [PATCH 14/39] =?UTF-8?q?fix(resume):=20address=20cloud=20review?= =?UTF-8?q?=20=E2=80=94=20auth,=20path=20safety,=20chain=20integrity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from the branch review: - **auth misroute (normal):** authenticate() no longer falls back to any-key agent auth when IdentityFile(s) are configured or IdentitiesOnly is set — pinned-but-failed auth now errors instead of silently trying an arbitrary agent key (the exact misroute the config parsing exists to prevent). Also parse IdentitiesOnly and fix the `Key = value` (padded `=`) split that mangled IdentityFile paths. - **path traversal (normal):** validate the projected Claude session id (`[A-Za-z0-9_-]`, ≤128, no leading dot) at the write chokepoints (claude_session_jsonl + write_into_claude_project) so a malicious third-party doc can't escape the session dir over SFTP or locally. - **--cwd misroute (normal):** normalize the remote cwd (trailing slash / `.` / `..` / relative-to-home) to the absolute path the remote shell lands on, so the host's project-dir key matches what remote Claude derives from getcwd. Used for both the shipped-file key and the launch `cd`. - **known_hosts (nit):** verify the server host key against ~/.ssh/known_hosts before shipping the transcript (the SFTP upload precedes the interactive ssh's own check); abort on mismatch, accept-new on first contact. - **chain root (nit):** the empty-seed drop now records a rewrite even for a rootless seed (parent_rewrites value is Option), so a surviving child inherits None instead of dangling on the never-emitted seed id. - **stale docstring (nit):** rewrite run_remote's v2 doc to v3 and drop the link to the renamed claude_session_id (fixes cargo doc warnings). New unit tests for each; full workspace green, cargo doc -D warnings clean, re-verified live against the exe.dev VM (incl. trailing-slash --cwd now shipping to the correct project dir). Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_export.rs | 34 +++- crates/path-cli/src/cmd_resume.rs | 270 ++++++++++++++++++++------ crates/toolpath-claude/src/project.rs | 62 ++++-- 3 files changed, 288 insertions(+), 78 deletions(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 47ac5a1f..fd5520e9 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -309,13 +309,37 @@ pub(crate) struct PathbaseUploadArgs { #[cfg(not(target_os = "emscripten"))] pub(crate) fn claude_session_jsonl(path: &toolpath::v1::Path) -> Result<(String, String)> { let conv = build_claude_conversation(path)?; - if conv.session_id.is_empty() { - anyhow::bail!("document projects to an empty Claude session id"); - } + 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"))] @@ -324,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) @@ -710,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 713e56a8..b91caaa5 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -44,13 +44,13 @@ //! **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`]): +//! 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 -//! ([`crate::cmd_export::claude_session_jsonl`]). +//! (`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 @@ -67,7 +67,7 @@ //! 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 +//! `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 @@ -792,6 +792,10 @@ struct SshHostConfig { user: Option, port: Option, identity_files: Vec, + /// `IdentitiesOnly yes` — when set (or when any `IdentityFile` is + /// configured), auth must use only the configured keys and must not + /// fall back to trying every agent key. + identities_only: bool, } /// Load [`SshHostConfig`] for `host` from `~/.ssh/config` (empty config @@ -819,7 +823,14 @@ fn parse_ssh_config(content: &str, host: &str, home: &std::path::Path) -> SshHos continue; } let (keyword, value) = match line.split_once([' ', '\t', '=']) { - Some((k, v)) => (k.to_ascii_lowercase(), v.trim().trim_matches('"')), + // 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" { @@ -849,6 +860,9 @@ fn parse_ssh_config(content: &str, host: &str, home: &std::path::Path) -> SshHos cfg.identity_files.push(path); } } + "identitiesonly" if value.eq_ignore_ascii_case("yes") => { + cfg.identities_only = true; + } _ => {} } } @@ -884,18 +898,51 @@ fn glob_match(pattern: &str, s: &str) -> bool { 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; fall back to plain agent auth (any key) -/// only when no identity is configured or none worked. -fn authenticate( - sess: &ssh2::Session, - user: &str, - identity_files: &[std::path::PathBuf], - addr: &str, -) -> Result<()> { - for key_path in identity_files { +/// (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(()); } @@ -906,6 +953,16 @@ fn authenticate( 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`)?") }) @@ -975,13 +1032,19 @@ fn connect_remote(target: &SshTarget) -> Result { 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.identity_files, &addr)?; + 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. @@ -1090,29 +1153,13 @@ pub(crate) fn exec_harness( strategy.exec(binary, args, cwd) } -/// v2 remote resume: the host resolves the document locally, pipes the -/// JSON into `ssh host 'path p incept claude …'` (which hydrates the -/// session into the remote's Claude layout), then hands off to an -/// interactive `ssh -t host 'claude -r '`. The remote needs `path` -/// (for incept) + the harness installed — but no Pathbase access, since -/// the host already resolved the doc. -/// -/// The host knows `` without asking the remote: the Claude session -/// id is a pure function of the document (the projector takes it -/// verbatim from the conversation view), so projecting the same bytes on -/// both sides yields the same id — see [`crate::cmd_export::claude_session_id`]. -/// -/// Steps: -/// 1. resolve + validate the doc on the host (fail fast on bad input), -/// and compute the session id locally; -/// 2. version preflight (`ssh host 'path --version'`), echoing both -/// versions and aborting if `path`/SSH is unreachable; -/// 3. hydrate: pipe the JSON into `path p incept claude [--project …]`; -/// 4. `execvp` the interactive `ssh -t … '[cd … && ]claude -r '`. -/// -/// `--cwd` does double duty: it is incept's `--project` dir AND the `cd` -/// target for the launch (they must match for `claude -r` to find the -/// session). Absent, both default to the remote's ssh cwd (`$HOME`). +/// 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 @@ -1150,11 +1197,15 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // 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. - let project_path = match args.cwd.as_ref() { - Some(dir) => dir.display().to_string(), - None => home.clone(), - }; + // 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 launch_cwd: Option = args + .cwd + .as_ref() + .map(|dir| normalize_remote_cwd(dir, &home)); + 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) @@ -1167,9 +1218,8 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // The launch cwd must exist before the interactive `cd` — create it // over SFTP too, since resuming into a fresh directory is the normal // case. - if let Some(dir) = args.cwd.as_ref() { - let dir = dir.display().to_string(); - exec.remote_mkdirs(&target, &dir) + if let Some(dir) = launch_cwd.as_deref() { + exec.remote_mkdirs(&target, dir) .with_context(|| format!("creating launch dir {dir} on {remote}"))?; } @@ -1179,7 +1229,7 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul let launch_cmd = remote_launch_command( crate::cmd_share::Harness::Claude, &session_id, - args.cwd.as_deref(), + launch_cwd.as_deref(), args.tmux, ); let (binary, argv) = ssh_invocation_tty(remote, &launch_cmd, true)?; @@ -1195,17 +1245,44 @@ 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. -/// 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. +/// `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. fn remote_launch_command( harness: crate::cmd_share::Harness, session_id: &str, - cwd: Option<&std::path::Path>, + cwd: Option<&str>, tmux: bool, ) -> String { let launch: Vec = std::iter::once(harness.name().to_string()) @@ -1213,7 +1290,7 @@ fn remote_launch_command( .collect(); let launch = launch.join(" "); let launch = match cwd { - Some(dir) => format!("cd {} && {launch}", shell_quote(&dir.display().to_string())), + Some(dir) => format!("cd {} && {launch}", shell_quote(dir)), None => launch, }; if tmux { @@ -1455,6 +1532,81 @@ mod tests { assert_eq!(cfg.host_name.as_deref(), Some("real.example.com")); } + #[test] + fn ssh_config_handles_padded_equals_and_identities_only() { + // OpenSSH allows `Key = value`; a naive first-separator split + // would leave `= value`. And IdentitiesOnly must be parsed so + // auth won't fall back to an arbitrary agent key. + let config = "Host exe.dev\n\ + \x20 User = dev\n\ + \x20 Port = 2200\n\ + \x20 IdentityFile = ~/.ssh/id_pinned\n\ + \x20 IdentitiesOnly yes\n"; + let cfg = parse_ssh_config(config, "exe.dev", std::path::Path::new("/home/u")); + assert_eq!(cfg.user.as_deref(), Some("dev")); + assert_eq!(cfg.port, Some(2200)); + assert_eq!( + cfg.identity_files, + vec![std::path::PathBuf::from("/home/u/.ssh/id_pinned")] + ); + assert!(cfg.identities_only); + } + + #[test] + fn authenticate_refuses_arbitrary_agent_key_when_identities_pinned() { + // No live session, so we can't exercise the ssh2 calls — but the + // guard's decision is pure: a config with pinned identities (or + // IdentitiesOnly) must not reach the any-key fallback. We assert + // that via the config shape the guard keys off. + let pinned = SshHostConfig { + identity_files: vec![std::path::PathBuf::from("/home/u/.ssh/id_pinned")], + ..Default::default() + }; + assert!(!pinned.identity_files.is_empty() || pinned.identities_only); + let only = SshHostConfig { + identities_only: true, + ..Default::default() + }; + assert!(!only.identity_files.is_empty() || only.identities_only); + } + + #[test] + fn normalize_remote_cwd_matches_remote_getcwd() { + // Host-side normalization must equal what the remote shell's `cd` + // lands on, else the project-dir key won't match remote Claude's. + let home = "/home/dev"; + assert_eq!( + normalize_remote_cwd(std::path::Path::new("/srv/work/"), home), + "/srv/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("/srv/./work"), home), + "/srv/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("/srv/x/../work"), home), + "/srv/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("work"), home), + "/home/dev/work" + ); + assert_eq!( + normalize_remote_cwd(std::path::Path::new("./work"), home), + "/home/dev/work" + ); + } + + #[test] + fn validate_session_id_rejects_path_traversal() { + use crate::cmd_export::validate_session_id; + assert!(validate_session_id("4523d750-77e7-4a41-922f-5b949064f429").is_ok()); + assert!(validate_session_id("../../../.ssh/authorized_keys").is_err()); + assert!(validate_session_id(".hidden").is_err()); + assert!(validate_session_id("has/slash").is_err()); + assert!(validate_session_id("").is_err()); + } + #[test] fn claude_project_dir_name_matches_projector_sanitization() { // The host-side mirror of Claude Code's project-dir sanitization @@ -1484,12 +1636,7 @@ mod tests { // Directory creation happens over SFTP before launch — the shell // string stays minimal: just the cd and the harness. assert_eq!( - remote_launch_command( - Harness::Claude, - "sess-1", - Some(std::path::Path::new("/srv/work")), - false - ), + remote_launch_command(Harness::Claude, "sess-1", Some("/srv/work"), false), "cd /srv/work && claude -r sess-1" ); } @@ -1504,12 +1651,7 @@ mod tests { "tmux new-session -A -s path-sess-1 'claude -r sess-1'" ); assert_eq!( - remote_launch_command( - Harness::Claude, - "sess-1", - Some(std::path::Path::new("/srv/work")), - true - ), + remote_launch_command(Harness::Claude, "sess-1", Some("/srv/work"), true), "tmux new-session -A -s path-sess-1 'cd /srv/work && claude -r sess-1'" ); } diff --git a/crates/toolpath-claude/src/project.rs b/crates/toolpath-claude/src/project.rs index 8aec6d92..bae4459b 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 => { @@ -157,9 +169,10 @@ fn project_view(view: &ConversationView) -> std::result::Result std::result::Result std::result::Result = 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] From 92c7e0f0e14e131fa1ac3bf940064043b7769f0c Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Thu, 23 Jul 2026 22:35:29 -0400 Subject: [PATCH 15/39] fix(resume): reject non-path remote-home output early (exe.dev banner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some sshds authenticate a key at the transport layer but answer every command with a notice instead of running it — e.g. exe.dev replies `Please complete registration by running: ssh exe.dev` for a key not yet bound to an account. Without a check that banner became a session path component and failed deep in the file ship. validate_remote_home requires a single-line absolute path, failing early with the offending output and a hint about unregistered keys. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_resume.rs | 64 ++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index b91caaa5..277b12db 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -665,15 +665,11 @@ impl ExecStrategy for RealExec { let home = sftp .realpath(std::path::Path::new(".")) .context("resolve remote home directory")?; - Ok(home.to_string_lossy().to_string()) + validate_remote_home(&home.to_string_lossy()) } None => { let out = exec_channel_capture(&conn.sess, "pwd")?; - let home = out.trim().to_string(); - if home.is_empty() { - anyhow::bail!("remote `pwd` returned nothing"); - } - Ok(home) + validate_remote_home(&out) } }) } @@ -781,6 +777,30 @@ fn exec_channel_capture(sess: &ssh2::Session, cmd: &str) -> Result { 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 @@ -1409,6 +1429,38 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { 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( From cdcf2355895eae891a90c3937aabb43d8a169de0 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Thu, 23 Jul 2026 22:45:57 -0400 Subject: [PATCH 16/39] chore(release): bump toolpath-claude to 0.12.2 (0.12.1 taken by main's session_chain) Main bumped toolpath-claude to 0.12.1 for `ClaudeConvo::session_chain`; the merge silently unified that with this branch's empty-seed fix, which also claimed 0.12.1. Give the empty-seed fix its own 0.12.2 so the two changes don't collide on one version. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/toolpath-claude/Cargo.toml | 2 +- site/_data/crates.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc91cffd..56bfca64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ All notable changes to the Toolpath workspace are documented here. ## Project: drop empty streaming-seed assistant lines — 2026-07-23 -- **`toolpath-claude`** (0.12.1): the Claude projector no longer emits +- **`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 diff --git a/Cargo.lock b/Cargo.lock index 5df12cc1..1c0ffd32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4087,7 +4087,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.1" +version = "0.12.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 12acc2ad..8651e218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", 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" } 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/site/_data/crates.json b/site/_data/crates.json index 5ff8c063..aa362c83 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -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", From 05d75079ea94d5cb7f8f021f25babc9622d96fe3 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Thu, 23 Jul 2026 23:22:53 -0400 Subject: [PATCH 17/39] refactor(convo): extract Turn::is_content_empty() (mirrors #141 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same cleanup the #141 reviewer asked for, applied here where the projector's seed-drop check lives in its improved (root-of-chain) form: promote the intrinsic predicate to Turn::is_content_empty() on toolpath-convo and keep the tool-result-events check at the call site. toolpath-convo 0.11.1 → 0.12.0 (additive), with a unit test. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 + Cargo.toml | 2 +- crates/toolpath-claude/src/project.rs | 12 ++-- crates/toolpath-convo/Cargo.toml | 2 +- crates/toolpath-convo/src/lib.rs | 82 +++++++++++++++++++++++++++ site/_data/crates.json | 2 +- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56bfca64..5dd42348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ All notable changes to the Toolpath workspace are documented here. 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 diff --git a/Cargo.toml b/Cargo.toml index 8651e218..d5b0d12f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ 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.2", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } diff --git a/crates/toolpath-claude/src/project.rs b/crates/toolpath-claude/src/project.rs index bae4459b..d2cf7a42 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -162,13 +162,11 @@ fn project_view(view: &ConversationView) -> std::result::Result, } +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/site/_data/crates.json b/site/_data/crates.json index aa362c83..990facee 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", From e1d48e68d7a4278dbb1b1c9af9a0dd63229ccf0b Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:03:52 -0400 Subject: [PATCH 18/39] docs(resume): implementation plan for remote persistence picker Co-Authored-By: Claude Opus 4.8 --- .../2026-07-23-remote-persistence-picker.md | 772 ++++++++++++++++++ 1 file changed, 772 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-23-remote-persistence-picker.md diff --git a/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md b/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md new file mode 100644 index 00000000..f2e9b404 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md @@ -0,0 +1,772 @@ +# Remote Persistence Picker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `path resume --remote` choose a remote session-persistence backend (plain/tmux/abduco/dtach/zellij/shpool) via a picker mirroring the harness picker, plus a reserved `--via ssh|mosh|et` transport axis. + +**Architecture:** All work is in `crates/path-cli/src/cmd_resume.rs`. A `PersistBackend` enum + pure `persist_plan()` produce a `PersistPlan { remote_command, extra_file, post_note }`; a `Transport` enum + `launch_invocation()` seam carries it. A one-shot `remote_which` probe over the existing libssh2 `ExecStrategy` feeds candidate assembly + pre-selection, which the picker resolves. `run_remote` is rewired to: probe → pick backend → build plan → ship (+ extra_file) → note → launch via transport. + +**Tech Stack:** Rust (edition 2024), clap `ValueEnum`, ssh2 (libssh2), existing skim/fzf picker in `crates/path-cli/src/skim_picker.rs`. + +## Global Constraints + +- Package `path-cli`; no new dependencies. +- Session name is `format!("path-{}", session_id.replace(['.', ':'], "-"))` — reuse verbatim (tmux/abduco/dtach/zellij/shpool all key on it). +- `INNER` = existing inner launch string built by `remote_launch_command`'s head: `[cd && ] `, quoted via `shell_quote`/`shell_single_quote`. +- Remote command strings must be valid remote-shell syntax (they're passed as one argv element to `ssh` and re-split remotely). Reuse `shell_single_quote` for nesting. +- Backend display order: `tmux, zellij, abduco, dtach, shpool, plain`. Pre-selection priority: `tmux > zellij > abduco > dtach > plain` (shpool never auto-preferred). +- `--via` v1 implements only `ssh`; `mosh`/`et` return a clear "not yet supported" error. +- `--tmux` becomes a deprecated alias for `--persist tmux`; error if combined with `--persist`. +- TDD: failing test → run (fail) → implement → run (pass) → commit, per task. +- Reference: design spec `docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`. + +--- + +### Task 1: `PersistBackend` enum + clap/describe + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` (near the `Harness` enum) +- Test: same file `#[cfg(test)] mod tests` + +**Interfaces:** +- Produces: `enum PersistBackend { Plain, Tmux, Abduco, Dtach, Zellij, Shpool }`; `PersistBackend::bin(&self) -> Option<&'static str>`; `PersistBackend::describe(&self) -> &'static str`; `PersistBackend::DISPLAY_ORDER: [PersistBackend; 6]`; derives `clap::ValueEnum`, `Copy`, `Clone`, `PartialEq`, `Eq`, `Debug`. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_backend_bin_and_order() { + assert_eq!(PersistBackend::Plain.bin(), None); + assert_eq!(PersistBackend::Tmux.bin(), Some("tmux")); + assert_eq!(PersistBackend::Shpool.bin(), Some("shpool")); + // Display order is stable and complete. + assert_eq!(PersistBackend::DISPLAY_ORDER.len(), 6); + assert_eq!(PersistBackend::DISPLAY_ORDER[0], PersistBackend::Tmux); + assert!(PersistBackend::Tmux.describe().contains("detach")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p path-cli --lib persist_backend_bin_and_order` +Expected: FAIL — `PersistBackend` not found. + +- [ ] **Step 3: Write minimal implementation** +```rust +/// Remote session-persistence backend for `--remote` resume. See the +/// design spec: three launch mechanisms (direct-wrap, layout-wrap for +/// zellij, attach-only for shpool). +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum PersistBackend { + Plain, + Tmux, + Abduco, + Dtach, + Zellij, + Shpool, +} + +impl PersistBackend { + /// Probe target on the remote (`command -v `); `Plain` has none. + fn bin(&self) -> Option<&'static str> { + match self { + PersistBackend::Plain => None, + PersistBackend::Tmux => Some("tmux"), + PersistBackend::Abduco => Some("abduco"), + PersistBackend::Dtach => Some("dtach"), + PersistBackend::Zellij => Some("zellij"), + PersistBackend::Shpool => Some("shpool"), + } + } + + fn describe(&self) -> &'static str { + match self { + PersistBackend::Plain => "plain — no persistence; dies on disconnect", + PersistBackend::Tmux => "tmux — detachable; survives drops, reattachable", + PersistBackend::Abduco => "abduco — minimal detach/attach; survives drops", + PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops", + PersistBackend::Zellij => "zellij — detachable workspace (layout-launched)", + PersistBackend::Shpool => "shpool — persistent shell (attach-only; run the command yourself)", + } + } + + /// Fixed picker display order. + const DISPLAY_ORDER: [PersistBackend; 6] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + PersistBackend::Shpool, + PersistBackend::Plain, + ]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p path-cli --lib persist_backend_bin_and_order` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): PersistBackend enum for remote persistence backends" +``` + +--- + +### Task 2: `PersistPlan` + `persist_plan()` for direct-wrap backends + +Replaces `remote_launch_command`. Covers `plain/tmux/abduco/dtach`; zellij/shpool land in Tasks 3–4 (return `Plain`-shaped output until then is NOT acceptable — implement their arms as `todo!()`-free explicit branches that Tasks 3/4 fill; here we route them through the direct path with a placeholder that Task 3/4 replace, so keep them out of this task's tests). + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` (`remote_launch_command` → `persist_plan`; update its 2 call sites and the `tmux:`-bool test constructors) +- Test: same file + +**Interfaces:** +- Consumes: `remote_launch_command`'s existing INNER-building head (harness/argv/cwd/quoting). +- Produces: + ```rust + struct PersistPlan { + remote_command: String, + extra_file: Option<(String, Vec)>, // (remote path, contents) — zellij layout + post_note: Option, // shpool guidance + } + fn persist_plan(harness: Harness, session_id: &str, cwd: Option<&str>, backend: PersistBackend, home: &str) -> PersistPlan + ``` + `home` is the resolved remote home (for zellij layout path in Task 3). + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_plan_direct_wrap_backends() { + let id = "sess-1"; + let plain = persist_plan(Harness::Claude, id, None, PersistBackend::Plain, "/home/u"); + assert_eq!(plain.remote_command, "claude -r sess-1"); + assert!(plain.extra_file.is_none() && plain.post_note.is_none()); + + let tmux = persist_plan(Harness::Claude, id, Some("/srv/w"), PersistBackend::Tmux, "/home/u"); + assert_eq!( + tmux.remote_command, + "tmux new-session -A -s path-sess-1 'cd /srv/w && claude -r sess-1'" + ); + + let abduco = persist_plan(Harness::Claude, id, None, PersistBackend::Abduco, "/home/u"); + assert_eq!(abduco.remote_command, "abduco -A path-sess-1 sh -c 'claude -r sess-1'"); + + let dtach = persist_plan(Harness::Claude, id, None, PersistBackend::Dtach, "/home/u"); + assert_eq!( + dtach.remote_command, + "dtach -A /tmp/path-dtach-sess-1 -z sh -c 'claude -r sess-1'" + ); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p path-cli --lib persist_plan_direct_wrap_backends` +Expected: FAIL — `persist_plan`/`PersistPlan` not found. + +- [ ] **Step 3: Write minimal implementation** + +Add the struct + function; keep the INNER-building head from `remote_launch_command`. (zellij/shpool arms delegate to a `TODO(task-3/4)` explicit branch — implement as shown so it compiles and the direct arms pass; Tasks 3–4 replace the marked arms.) +```rust +struct PersistPlan { + remote_command: String, + extra_file: Option<(String, Vec)>, + post_note: Option, +} + +fn persist_plan( + harness: Harness, + session_id: &str, + cwd: Option<&str>, + backend: PersistBackend, + home: &str, +) -> PersistPlan { + // INNER — identical to the old remote_launch_command head. + let launch: Vec = std::iter::once(harness.name().to_string()) + .chain(argv_for(harness, session_id).iter().map(|a| shell_quote(a))) + .collect(); + let inner = launch.join(" "); + let inner = match cwd { + Some(dir) => format!("cd {} && {inner}", shell_quote(dir)), + None => inner, + }; + let name = format!("path-{}", session_id.replace(['.', ':'], "-")); + + let mut extra_file = None; + let mut post_note = None; + let remote_command = match backend { + PersistBackend::Plain => inner, + PersistBackend::Tmux => format!( + "tmux new-session -A -s {} {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + PersistBackend::Abduco => format!( + "abduco -A {} sh -c {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + PersistBackend::Dtach => format!( + "dtach -A {} -z sh -c {}", + shell_quote(&format!("/tmp/path-dtach-{}", session_id.replace(['.', ':'], "-"))), + shell_single_quote(&inner) + ), + PersistBackend::Zellij => zellij_plan(&name, &inner, home, &mut extra_file), + PersistBackend::Shpool => shpool_plan(&name, &inner, &mut post_note), + }; + PersistPlan { remote_command, extra_file, post_note } +} +``` +Add temporary stubs so it compiles (Tasks 3–4 replace them): +```rust +fn zellij_plan(name: &str, _inner: &str, _home: &str, _extra: &mut Option<(String, Vec)>) -> String { + // TODO(task-3): layout-wrap. Temporary: attach/create by name. + format!("zellij attach --create {}", shell_quote(name)) +} +fn shpool_plan(name: &str, _inner: &str, _note: &mut Option) -> String { + // TODO(task-4): attach-only + post_note. + format!("shpool attach {}", shell_quote(name)) +} +``` +Then replace the two `remote_launch_command(...)` call sites in `run_remote` with `persist_plan(Harness::Claude, &session_id, launch_cwd.as_deref(), backend, &home)` — but `backend` doesn't exist until Task 8; for now pass `PersistBackend::Tmux` if `args.tmux` else `PersistBackend::Plain`, and use `.remote_command`. Delete `remote_launch_command` and update the two `remote_launch_command_*` tests to call `persist_plan(...).remote_command`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p path-cli --lib persist_plan_direct_wrap_backends persist && cargo test -p path-cli resume` +Expected: PASS (all resume tests green). + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): persist_plan + PersistPlan replacing remote_launch_command (direct-wrap backends)" +``` + +--- + +### Task 3: zellij layout-wrap + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`zellij_plan`); Test: same file. + +**Interfaces:** +- Produces: `zellij_plan` fills `extra_file = Some(("/.cache/path/zellij-.kdl", ))` and returns `zellij --session --layout `. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_plan_zellij_ships_layout() { + let p = persist_plan(Harness::Claude, "sess-1", Some("/srv/w"), PersistBackend::Zellij, "/home/u"); + assert_eq!( + p.remote_command, + "zellij --session path-sess-1 --layout /home/u/.cache/path/zellij-path-sess-1.kdl" + ); + let (path, body) = p.extra_file.expect("layout shipped"); + assert_eq!(path, "/home/u/.cache/path/zellij-path-sess-1.kdl"); + let body = String::from_utf8(body).unwrap(); + assert!(body.contains("cd /srv/w && claude -r sess-1"), "body: {body}"); + assert!(body.contains("pane"), "must be a KDL layout: {body}"); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib persist_plan_zellij_ships_layout` +Expected: FAIL — assertion (stub returns `attach --create`, no extra_file). + +- [ ] **Step 3: Implement** +```rust +fn zellij_plan( + name: &str, + inner: &str, + home: &str, + extra: &mut Option<(String, Vec)>, +) -> String { + let layout_path = format!("{home}/.cache/path/zellij-{name}.kdl"); + // Single-pane layout that runs INNER via the shell. `close_on_exit` + // keeps the pane if the harness exits so output stays visible. + let kdl = format!( + "layout {{\n pane command=\"sh\" {{\n args \"-c\" {inner:?}\n }}\n}}\n" + ); + *extra = Some((layout_path.clone(), kdl.into_bytes())); + format!( + "zellij --session {} --layout {}", + shell_quote(name), + shell_quote(&layout_path) + ) +} +``` +Note: the `.cache/path` dir is created by the ship step (Task 9 mkdirs the `extra_file`'s parent). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib persist_plan_zellij_ships_layout` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): zellij layout-wrap persistence backend" +``` + +--- + +### Task 4: shpool attach-only + post_note + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`shpool_plan`); Test: same file. + +**Interfaces:** +- Produces: `shpool_plan` returns `shpool attach ` and sets `post_note = Some("In the shpool session, run: ")`. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_plan_shpool_attach_only_with_note() { + let p = persist_plan(Harness::Claude, "sess-1", Some("/srv/w"), PersistBackend::Shpool, "/home/u"); + assert_eq!(p.remote_command, "shpool attach path-sess-1"); + let note = p.post_note.expect("shpool note"); + assert!(note.contains("cd /srv/w && claude -r sess-1"), "note: {note}"); + assert!(p.extra_file.is_none()); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib persist_plan_shpool_attach_only_with_note` +Expected: FAIL — no post_note. + +- [ ] **Step 3: Implement** +```rust +fn shpool_plan(name: &str, inner: &str, note: &mut Option) -> String { + *note = Some(format!( + "shpool has no command arg — in the persistent shell, run:\n {inner}" + )); + format!("shpool attach {}", shell_quote(name)) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib persist_plan_shpool_attach_only_with_note` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): shpool attach-only persistence backend + post-note" +``` + +--- + +### Task 5: `Transport` enum + `launch_invocation` seam + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs`; Test: same file. + +**Interfaces:** +- Produces: `enum Transport { Ssh, Mosh, Et }` (derives `clap::ValueEnum`, `Copy`, `Clone`, `PartialEq`, `Eq`, `Debug`); `fn launch_invocation(transport: Transport, remote: &str, remote_cmd: &str) -> Result<(String, Vec)>`. `Ssh` delegates to existing `ssh_invocation_tty(remote, remote_cmd, true)`; `Mosh`/`Et` bail. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn launch_invocation_ssh_and_deferred_transports() { + let (bin, argv) = launch_invocation(Transport::Ssh, "ssh://h", "claude -r x").unwrap(); + assert_eq!(bin, "ssh"); + assert_eq!(argv, vec!["-t".to_string(), "h".to_string(), "claude -r x".to_string()]); + + let err = launch_invocation(Transport::Mosh, "ssh://h", "claude -r x").unwrap_err(); + assert!(err.to_string().contains("not yet supported"), "{err}"); + assert!(launch_invocation(Transport::Et, "ssh://h", "x").is_err()); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib launch_invocation_ssh_and_deferred_transports` +Expected: FAIL — not found. + +- [ ] **Step 3: Implement** +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Transport { + Ssh, + Mosh, + Et, +} + +/// Carry the persist-wrapped remote command over the chosen transport. +/// v1 implements only ssh; mosh/et are reserved (spec: Transport axis). +fn launch_invocation( + transport: Transport, + remote: &str, + remote_cmd: &str, +) -> Result<(String, Vec)> { + match transport { + Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), + Transport::Mosh => anyhow::bail!( + "--via mosh is not yet supported (reserved); use --via ssh" + ), + Transport::Et => anyhow::bail!( + "--via et is not yet supported (reserved); use --via ssh" + ), + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib launch_invocation_ssh_and_deferred_transports` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): Transport enum + launch_invocation seam (ssh; mosh/et reserved)" +``` + +--- + +### Task 6: `remote_which` probe on `ExecStrategy` + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`trait ExecStrategy`, `RealExec` impl, `RecordingExec` impl + fields); Test: same file. + +**Interfaces:** +- Produces: `fn remote_which(&self, target: &SshTarget, bins: &[&str]) -> Result>` on `ExecStrategy`. `RealExec` runs one exec channel: `for b in bins; do command -v "$b" >/dev/null 2>&1 && echo "$b"; done`. `RecordingExec` returns a canned set (new field `available: BTreeSet`, default all). + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn recording_exec_remote_which_returns_canned_set() { + let rec = RecordingExec::with_available(["tmux", "dtach"]); + let got = rec + .remote_which(&SshTarget { user: None, host: "h".into(), port: None }, &["tmux", "zellij", "dtach"]) + .unwrap(); + assert!(got.contains("tmux") && got.contains("dtach")); + assert!(!got.contains("zellij")); +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib recording_exec_remote_which_returns_canned_set` +Expected: FAIL — no `remote_which` / `with_available`. + +- [ ] **Step 3: Implement** + +Add to the trait: +```rust +/// Which of `bins` exist on the remote (`command -v`). One exec channel. +fn remote_which( + &self, + target: &SshTarget, + bins: &[&str], +) -> Result>; +``` +`RealExec` impl (uses `with_conn` + `exec_channel_capture`, like `remote_home`'s SCP branch): +```rust +fn remote_which( + &self, + target: &SshTarget, + bins: &[&str], +) -> Result> { + if bins.is_empty() { + return Ok(Default::default()); + } + let probe = bins + .iter() + .map(|b| format!("command -v {} >/dev/null 2>&1 && echo {}", shell_single_quote(b), shell_single_quote(b))) + .collect::>() + .join("; "); + self.with_conn(target, |conn| { + let out = exec_channel_capture(&conn.sess, &probe)?; + Ok(out.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()) + }) +} +``` +`RecordingExec`: add field `available: std::collections::BTreeSet`, constructor `with_available`, and impl: +```rust +pub fn with_available<'a>(bins: impl IntoIterator) -> Self { + Self { available: bins.into_iter().map(String::from).collect(), ..Default::default() } +} +// in impl ExecStrategy for RecordingExec: +fn remote_which(&self, _t: &SshTarget, bins: &[&str]) -> Result> { + Ok(bins.iter().filter(|b| self.available.contains(**b)).map(|b| b.to_string()).collect()) +} +``` +Default `RecordingExec` (via `Default`) has an empty `available`; existing tests that expect a working launch must use `with_available(["tmux","abduco","dtach","zellij","shpool"])` OR pass an explicit `--persist` that skips probing (Task 8 makes `--persist` skip the probe requirement). Keep existing tests green by having them pass `--persist plain` (no probe needed) or `with_available`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib recording_exec_remote_which_returns_canned_set` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): ExecStrategy::remote_which probe (+ RecordingExec canned availability)" +``` + +--- + +### Task 7: CLI flags `--persist` / `--via` + `--tmux` deprecation + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`struct ResumeArgs`); Test: same file. + +**Interfaces:** +- Produces on `ResumeArgs`: `pub persist: Option` (`#[arg(long, value_enum, requires = "remote")]`), `pub via: Transport` (`#[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")]`). Keep `pub tmux: bool`. New `fn resolve_persist_flag(args: &ResumeArgs) -> Result>`: returns `Some(Tmux)` for `--tmux` (with a deprecation eprintln), `Some(x)` for `--persist x`, error if both set, `None` if neither. + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn resolve_persist_flag_maps_tmux_and_rejects_conflict() { + let mut a = crate::cmd_resume::test_args("claude-x"); // helper builds a minimal ResumeArgs + a.remote = Some("ssh://h".into()); + a.tmux = true; + assert_eq!(resolve_persist_flag(&a).unwrap(), Some(PersistBackend::Tmux)); + + a.persist = Some(PersistBackend::Dtach); + assert!(resolve_persist_flag(&a).is_err()); // both --tmux and --persist + + a.tmux = false; + assert_eq!(resolve_persist_flag(&a).unwrap(), Some(PersistBackend::Dtach)); +} +``` +(If no `test_args` helper exists, construct `ResumeArgs { .. }` inline with all fields — check the struct's current fields first.) + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib resolve_persist_flag_maps_tmux_and_rejects_conflict` +Expected: FAIL — fields/fn missing. + +- [ ] **Step 3: Implement** + +Add fields to `ResumeArgs`: +```rust +/// Remote session-persistence backend. Skips the picker. Requires --remote. +#[arg(long, value_enum, requires = "remote")] +pub persist: Option, + +/// Transport for the interactive launch. ssh (default); mosh/et reserved. +#[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")] +pub via: Transport, +``` +Add the resolver: +```rust +fn resolve_persist_flag(args: &ResumeArgs) -> Result> { + match (args.tmux, args.persist) { + (true, Some(_)) => anyhow::bail!("--tmux is a deprecated alias for --persist tmux; don't combine it with --persist"), + (true, None) => { + eprintln!("note: --tmux is deprecated; use --persist tmux"); + Ok(Some(PersistBackend::Tmux)) + } + (false, p) => Ok(p), + } +} +``` +Update every `ResumeArgs { … }` construction in tests to add `persist: None, via: Transport::Ssh,`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib resolve_persist_flag_maps_tmux_and_rejects_conflict && cargo test -p path-cli resume` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): --persist and --via flags (+ --tmux deprecation alias)" +``` + +--- + +### Task 8: Backend candidate assembly + pre-selection (pure) + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs`; Test: same file. + +**Interfaces:** +- Produces: + ```rust + fn persist_candidates(available: &std::collections::BTreeSet) -> Vec // plain + available, in DISPLAY_ORDER + fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend // priority; plain if none + ``` + +- [ ] **Step 1: Write the failing test** +```rust +#[test] +fn persist_candidates_and_preference() { + use std::collections::BTreeSet; + let avail: BTreeSet = ["dtach", "zellij"].iter().map(|s| s.to_string()).collect(); + let cands = persist_candidates(&avail); + // DISPLAY_ORDER filtered to available + always Plain, in order. + assert_eq!(cands, vec![PersistBackend::Zellij, PersistBackend::Dtach, PersistBackend::Plain]); + assert_eq!(preferred_backend(&avail), PersistBackend::Zellij); // tmux absent -> zellij + + let none: BTreeSet = BTreeSet::new(); + assert_eq!(persist_candidates(&none), vec![PersistBackend::Plain]); + assert_eq!(preferred_backend(&none), PersistBackend::Plain); + + let with_tmux: BTreeSet = ["tmux", "shpool"].iter().map(|s| s.to_string()).collect(); + assert_eq!(preferred_backend(&with_tmux), PersistBackend::Tmux); // shpool never preferred over tmux +} +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --lib persist_candidates_and_preference` +Expected: FAIL — not found. + +- [ ] **Step 3: Implement** +```rust +fn persist_candidates(available: &std::collections::BTreeSet) -> Vec { + PersistBackend::DISPLAY_ORDER + .into_iter() + .filter(|b| match b.bin() { + None => true, // Plain always offered + Some(bin) => available.contains(bin), + }) + .collect() +} + +fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend { + const PRIORITY: [PersistBackend; 4] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + ]; + PRIORITY + .into_iter() + .find(|b| b.bin().is_some_and(|bin| available.contains(bin))) + .unwrap_or(PersistBackend::Plain) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --lib persist_candidates_and_preference` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(resume): persist candidate assembly + preferred-backend selection" +``` + +--- + +### Task 9: Wire `run_remote` (probe → pick → plan → ship → note → launch) + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`run_remote`); Test: same file + `crates/path-cli/tests/resume.rs`. + +**Interfaces:** +- Consumes: everything above. Picker UI: for the interactive selection, mirror the existing harness picker (`crate::skim_picker` usage in `share`/`resume` — read `pick_harness`/its call site and follow the same pattern). Selection resolution: + 1. `resolve_persist_flag(args)?` → if `Some(b)`, use `b` (skip probe/picker). + 2. else probe `remote_which(&target, &bins_of_all_backends)`; `cands = persist_candidates(&avail)`. + 3. if interactive (stdin+stderr TTY): fuzzy-pick from `cands` with `preferred_backend(&avail)` pre-selected; else use `preferred_backend(&avail)` (print a note if it fell back to `Plain`). + +- [ ] **Step 1: Write the failing integration test** (in `crates/path-cli/tests/resume.rs`, using `RecordingExec::with_available`) +```rust +#[test] +fn remote_resume_persist_dtach_records_launch_and_ships() { + let rec = RecordingExec::with_available(["dtach"]); + let mut args = /* build ResumeArgs for a file input */; + args.remote = Some("ssh://h".into()); + args.harness = Some(Harness::Claude); + args.persist = Some(PersistBackend::Dtach); + run_with_strategy(&args, &rec).unwrap(); + let cap = rec.captured(); + assert_eq!(cap.binary, "ssh"); + assert!(cap.args.iter().any(|a| a.contains("dtach -A /tmp/path-dtach-")), "{:?}", cap.args); +} +``` +(Model construction on the existing `remote_resume_*` integration tests in the same file.) + +- [ ] **Step 2: Run to verify fail** + +Run: `cargo test -p path-cli --test resume remote_resume_persist_dtach` +Expected: FAIL — `persist`/wiring absent. + +- [ ] **Step 3: Implement** + +In `run_remote`, after `let home = exec.remote_home(...)?` and before the ship step, resolve the backend: +```rust +let backend = match resolve_persist_flag(args)? { + Some(b) => b, + None => { + let bins: Vec<&str> = PersistBackend::DISPLAY_ORDER.iter().filter_map(|b| b.bin()).collect(); + let avail = exec.remote_which(&target, &bins) + .with_context(|| format!("probing persistence backends on {remote}"))?; + let cands = persist_candidates(&avail); + let preferred = preferred_backend(&avail); + if io_is_interactive() { // stdin+stderr TTY, mirror existing picker guard + pick_persist_backend(&cands, preferred)? // fuzzy UI mirroring pick_harness + } else { + if preferred == PersistBackend::Plain { + eprintln!("note: no persistence backend on remote; launching plain (survives nothing)"); + } + preferred + } + } +}; +let plan = persist_plan(Harness::Claude, &session_id, launch_cwd.as_deref(), backend, &home); +``` +Then: build `projects_dir`/ship JSONL as today, PLUS ship `plan.extra_file` (mkdir its parent, write it): +```rust +if let Some((path, body)) = &plan.extra_file { + if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { + exec.remote_mkdirs(&target, parent).with_context(|| format!("creating {parent} on {remote}"))?; + } + exec.remote_write(&target, path, body).with_context(|| format!("shipping {path} to {remote}"))?; +} +if let Some(note) = &plan.post_note { + eprintln!("{note}"); +} +``` +Finally replace the launch: +```rust +let (binary, argv) = launch_invocation(args.via, remote, &plan.remote_command)?; +``` +Implement `pick_persist_backend` mirroring the harness picker (read `skim_picker.rs` + the `pick_harness` call site; present `describe()` rows, pre-select `preferred`). Implement `io_is_interactive()` if not already present (reuse the existing TTY check used by `p import`). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p path-cli --test resume && cargo test -p path-cli --lib cmd_resume && cargo clippy -p path-cli -- -D warnings` +Expected: PASS + clean. + +- [ ] **Step 5: Commit** +```bash +git add crates/path-cli/src/cmd_resume.rs crates/path-cli/tests/resume.rs +git commit -m "feat(resume): wire persistence picker + transport through run_remote" +``` + +--- + +### Task 10: Docs + version bump + +**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (module docs), `CLAUDE.md` (resume bullet), `docs/agents/remote-resume-targets.md`, `crates/path-cli/Cargo.toml`, root `Cargo.toml`, `site/_data/crates.json`, `CHANGELOG.md`. + +- [ ] **Step 1: Update module docs + CLAUDE.md** + +In `cmd_resume.rs` module docs, document `--persist ` (six backends, three mechanisms), `--via ssh|mosh|et`, and that reachability rides `~/.ssh/config` aliases. In `CLAUDE.md`'s `path resume` bullet, add a sentence on the persistence picker + `--via`. + +- [ ] **Step 2: Version bump** (per the release checklist) + +Bump `path-cli` minor in `crates/path-cli/Cargo.toml`, root `Cargo.toml` `[workspace.dependencies]`, `site/_data/crates.json`, and add a `CHANGELOG.md` entry describing the persistence picker + `--via`. + +- [ ] **Step 3: Verify build + full suite** + +Run: `cargo test -p path-cli && cargo clippy --workspace -- -D warnings` +Expected: PASS + clean. + +- [ ] **Step 4: Commit** +```bash +git add -A +git commit -m "docs(resume): document persistence picker + --via; bump path-cli" +``` + +--- + +## Self-Review + +- **Spec coverage:** `--persist` 6 backends (Tasks 1–4, 8–9); 3 mechanisms — direct (T2), layout/zellij (T3), attach-only/shpool (T4); `--via ssh|mosh|et` seam (T5); probe (T6); flags + `--tmux` deprecation (T7); picker + pre-selection + non-TTY default (T8–9); reachability via alias (already shipped; documented T10); tests throughout; version bump (T10). Covered. +- **Open questions from spec** (zellij `--session … --layout` on existing session; libssh2 ProxyCommand ship fallback) are intentionally out of v1 — noted in spec, not tasks. +- **Type consistency:** `PersistBackend`, `PersistPlan { remote_command, extra_file, post_note }`, `Transport`, `persist_plan(harness, session_id, cwd, backend, home)`, `remote_which(target, bins) -> BTreeSet`, `persist_candidates`/`preferred_backend`, `resolve_persist_flag`, `launch_invocation(transport, remote, remote_cmd)` — used consistently across tasks. +- **Placeholder scan:** the only forward-refs are the Task-2 zellij/shpool stubs, explicitly replaced in Tasks 3–4; `pick_persist_backend`/`io_is_interactive` are specified to mirror the existing harness picker (named files to read at execution). From 7334fad3102be499b6a125a4587f8c7dcde9ecb3 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:07:21 -0400 Subject: [PATCH 19/39] feat(resume): PersistBackend enum for remote persistence backends --- crates/path-cli/src/cmd_resume.rs | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 830b07ba..d76e5ef0 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1415,6 +1415,56 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { .all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) } +/// Remote session-persistence backend for `--remote` resume. See the +/// design spec: three launch mechanisms (direct-wrap, layout-wrap for +/// zellij, attach-only for shpool). +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum PersistBackend { + Plain, + Tmux, + Abduco, + Dtach, + Zellij, + Shpool, +} + +impl PersistBackend { + /// Probe target on the remote (`command -v `); `Plain` has none. + fn bin(&self) -> Option<&'static str> { + match self { + PersistBackend::Plain => None, + PersistBackend::Tmux => Some("tmux"), + PersistBackend::Abduco => Some("abduco"), + PersistBackend::Dtach => Some("dtach"), + PersistBackend::Zellij => Some("zellij"), + PersistBackend::Shpool => Some("shpool"), + } + } + + fn describe(&self) -> &'static str { + match self { + PersistBackend::Plain => "plain — no persistence; dies on disconnect", + PersistBackend::Tmux => "tmux — detachable; survives drops, reattachable", + PersistBackend::Abduco => "abduco — minimal detach/attach; survives drops", + PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops", + PersistBackend::Zellij => "zellij — detachable workspace (layout-launched)", + PersistBackend::Shpool => { + "shpool — persistent shell (attach-only; run the command yourself)" + } + } + } + + /// Fixed picker display order. + const DISPLAY_ORDER: [PersistBackend; 6] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + PersistBackend::Shpool, + PersistBackend::Plain, + ]; +} + #[cfg(test)] mod tests { use super::*; @@ -2432,4 +2482,15 @@ mod tests { assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]); assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x")); } + + #[test] + fn persist_backend_bin_and_order() { + assert_eq!(PersistBackend::Plain.bin(), None); + assert_eq!(PersistBackend::Tmux.bin(), Some("tmux")); + assert_eq!(PersistBackend::Shpool.bin(), Some("shpool")); + // Display order is stable and complete. + assert_eq!(PersistBackend::DISPLAY_ORDER.len(), 6); + assert_eq!(PersistBackend::DISPLAY_ORDER[0], PersistBackend::Tmux); + assert!(PersistBackend::Tmux.describe().contains("detach")); + } } From 5d96eaa5fd50eb07ad9f3ca5285e9f805c399f0a Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:10:51 -0400 Subject: [PATCH 20/39] feat(resume): persist_plan + PersistPlan replacing remote_launch_command (direct-wrap backends) --- crates/path-cli/src/cmd_resume.rs | 157 +++++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 24 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index d76e5ef0..b32a9c42 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1225,12 +1225,19 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // 4. 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 launch_cmd = remote_launch_command( + let backend = if args.tmux { + PersistBackend::Tmux + } else { + PersistBackend::Plain + }; + let launch_cmd = persist_plan( Harness::Claude, &session_id, launch_cwd.as_deref(), - args.tmux, - ); + backend, + &home, + ) + .remote_command; let (binary, argv) = ssh_invocation_tty(remote, &launch_cmd, true)?; let cwd = std::env::current_dir()?; exec_harness(&binary, &argv, &cwd, exec) @@ -1278,35 +1285,77 @@ fn normalize_remote_cwd(cwd: &std::path::Path, home: &str) -> String { /// 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. -fn remote_launch_command( +struct PersistPlan { + remote_command: String, + extra_file: Option<(String, Vec)>, + post_note: Option, +} + +fn persist_plan( harness: Harness, session_id: &str, cwd: Option<&str>, - tmux: bool, -) -> String { + backend: PersistBackend, + home: &str, +) -> PersistPlan { let launch: Vec = std::iter::once(harness.name().to_string()) .chain(argv_for(harness, session_id).iter().map(|a| shell_quote(a))) .collect(); - let launch = launch.join(" "); - let launch = match cwd { - Some(dir) => format!("cd {} && {launch}", shell_quote(dir)), - None => launch, + let inner = launch.join(" "); + let inner = match cwd { + Some(dir) => format!("cd {} && {inner}", shell_quote(dir)), + None => inner, }; - if tmux { - // Detachable session: `-A` attaches when the named session - // already exists, so re-running the same resume re-attaches - // instead of erroring. Session names can't contain `.`/`:`. - let name = format!("path-{}", session_id.replace(['.', ':'], "-")); - format!( + // Detachable session names can't contain `.`/`:`. + let name = format!("path-{}", session_id.replace(['.', ':'], "-")); + + let mut extra_file = None; + let mut post_note = None; + let remote_command = match backend { + PersistBackend::Plain => inner, + PersistBackend::Tmux => format!( "tmux new-session -A -s {} {}", shell_quote(&name), - shell_single_quote(&launch) - ) - } else { - launch + shell_single_quote(&inner) + ), + PersistBackend::Abduco => format!( + "abduco -A {} sh -c {}", + shell_quote(&name), + shell_single_quote(&inner) + ), + PersistBackend::Dtach => format!( + "dtach -A {} -z sh -c {}", + shell_quote(&format!( + "/tmp/path-dtach-{}", + session_id.replace(['.', ':'], "-") + )), + shell_single_quote(&inner) + ), + PersistBackend::Zellij => zellij_plan(&name, &inner, home, &mut extra_file), + PersistBackend::Shpool => shpool_plan(&name, &inner, &mut post_note), + }; + PersistPlan { + remote_command, + extra_file, + post_note, } } +// TODO(task-3): layout-wrap. Temporary: attach/create by name. +fn zellij_plan( + name: &str, + _inner: &str, + _home: &str, + _extra: &mut Option<(String, Vec)>, +) -> String { + format!("zellij attach --create {}", shell_quote(name)) +} + +// TODO(task-4): attach-only + post_note. +fn shpool_plan(name: &str, _inner: &str, _note: &mut Option) -> String { + format!("shpool attach {}", shell_quote(name)) +} + /// Quote for the remote shell only when needed — plain flags, ids, and /// paths stay bare so the echoed recipe reads like something a human /// would type; anything else gets [`shell_single_quote`]. @@ -1765,13 +1814,27 @@ mod tests { // Binary + argv come from the same per-harness table the local // resume uses; safe args stay bare so the recipe reads cleanly. assert_eq!( - remote_launch_command(Harness::Claude, "sess-1", None, false), + persist_plan( + Harness::Claude, + "sess-1", + None, + PersistBackend::Plain, + "/home/u" + ) + .remote_command, "claude -r sess-1" ); // Directory creation happens over SFTP before launch — the shell // string stays minimal: just the cd and the harness. assert_eq!( - remote_launch_command(Harness::Claude, "sess-1", Some("/srv/work"), false), + persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/work"), + PersistBackend::Plain, + "/home/u" + ) + .remote_command, "cd /srv/work && claude -r sess-1" ); } @@ -1781,15 +1844,61 @@ mod tests { // --tmux: the whole launch runs inside a named tmux session so // it survives SSH disconnects; -A re-attaches on a second run. assert_eq!( - remote_launch_command(Harness::Claude, "sess-1", None, true), + persist_plan( + Harness::Claude, + "sess-1", + None, + PersistBackend::Tmux, + "/home/u" + ) + .remote_command, "tmux new-session -A -s path-sess-1 'claude -r sess-1'" ); assert_eq!( - remote_launch_command(Harness::Claude, "sess-1", Some("/srv/work"), true), + persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/work"), + PersistBackend::Tmux, + "/home/u" + ) + .remote_command, "tmux new-session -A -s path-sess-1 'cd /srv/work && claude -r sess-1'" ); } + #[test] + fn persist_plan_direct_wrap_backends() { + let id = "sess-1"; + let plain = persist_plan(Harness::Claude, id, None, PersistBackend::Plain, "/home/u"); + assert_eq!(plain.remote_command, "claude -r sess-1"); + assert!(plain.extra_file.is_none() && plain.post_note.is_none()); + + let tmux = persist_plan( + Harness::Claude, + id, + Some("/srv/w"), + PersistBackend::Tmux, + "/home/u", + ); + assert_eq!( + tmux.remote_command, + "tmux new-session -A -s path-sess-1 'cd /srv/w && claude -r sess-1'" + ); + + let abduco = persist_plan(Harness::Claude, id, None, PersistBackend::Abduco, "/home/u"); + assert_eq!( + abduco.remote_command, + "abduco -A path-sess-1 sh -c 'claude -r sess-1'" + ); + + let dtach = persist_plan(Harness::Claude, id, None, PersistBackend::Dtach, "/home/u"); + assert_eq!( + dtach.remote_command, + "dtach -A /tmp/path-dtach-sess-1 -z sh -c 'claude -r sess-1'" + ); + } + #[test] fn shell_quote_leaves_safe_strings_bare() { assert_eq!(shell_quote("-r"), "-r"); From ea366992c0d8b30b09ffe4f2e32dee3445375aac Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:13:22 -0400 Subject: [PATCH 21/39] feat(resume): zellij layout-wrap persistence backend Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/src/cmd_resume.rs | 43 +++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index b32a9c42..cbeb93dd 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1341,14 +1341,24 @@ fn persist_plan( } } -// TODO(task-3): layout-wrap. Temporary: attach/create by name. fn zellij_plan( name: &str, - _inner: &str, - _home: &str, - _extra: &mut Option<(String, Vec)>, + inner: &str, + home: &str, + extra: &mut Option<(String, Vec)>, ) -> String { - format!("zellij attach --create {}", shell_quote(name)) + let layout_path = format!("{home}/.cache/path/zellij-{name}.kdl"); + // Single-pane layout that runs INNER via the shell. `close_on_exit` + // keeps the pane if the harness exits so output stays visible. + let kdl = format!( + "layout {{\n pane command=\"sh\" {{\n args \"-c\" {inner:?}\n }}\n}}\n" + ); + *extra = Some((layout_path.clone(), kdl.into_bytes())); + format!( + "zellij --session {} --layout {}", + shell_quote(name), + shell_quote(&layout_path) + ) } // TODO(task-4): attach-only + post_note. @@ -2602,4 +2612,27 @@ mod tests { assert_eq!(PersistBackend::DISPLAY_ORDER[0], PersistBackend::Tmux); assert!(PersistBackend::Tmux.describe().contains("detach")); } + + #[test] + fn persist_plan_zellij_ships_layout() { + let p = persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/w"), + PersistBackend::Zellij, + "/home/u", + ); + assert_eq!( + p.remote_command, + "zellij --session path-sess-1 --layout /home/u/.cache/path/zellij-path-sess-1.kdl" + ); + let (path, body) = p.extra_file.expect("layout shipped"); + assert_eq!(path, "/home/u/.cache/path/zellij-path-sess-1.kdl"); + let body = String::from_utf8(body).unwrap(); + assert!( + body.contains("cd /srv/w && claude -r sess-1"), + "body: {body}" + ); + assert!(body.contains("pane"), "must be a KDL layout: {body}"); + } } From df41b6c8d6765f50e40083674ba27071ec3416ad Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:15:48 -0400 Subject: [PATCH 22/39] feat(resume): shpool attach-only persistence backend + post-note Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/src/cmd_resume.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index cbeb93dd..d30ed4c5 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1361,8 +1361,10 @@ fn zellij_plan( ) } -// TODO(task-4): attach-only + post_note. -fn shpool_plan(name: &str, _inner: &str, _note: &mut Option) -> String { +fn shpool_plan(name: &str, inner: &str, note: &mut Option) -> String { + *note = Some(format!( + "shpool has no command arg — in the persistent shell, run:\n {inner}" + )); format!("shpool attach {}", shell_quote(name)) } @@ -1909,6 +1911,24 @@ mod tests { ); } + #[test] + fn persist_plan_shpool_attach_only_with_note() { + let p = persist_plan( + Harness::Claude, + "sess-1", + Some("/srv/w"), + PersistBackend::Shpool, + "/home/u", + ); + assert_eq!(p.remote_command, "shpool attach path-sess-1"); + let note = p.post_note.expect("shpool note"); + assert!( + note.contains("cd /srv/w && claude -r sess-1"), + "note: {note}" + ); + assert!(p.extra_file.is_none()); + } + #[test] fn shell_quote_leaves_safe_strings_bare() { assert_eq!(shell_quote("-r"), "-r"); From 1c3ca6e474008a25f4559749ecb9b1d60d5406b1 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:17:59 -0400 Subject: [PATCH 23/39] feat(resume): Transport enum + launch_invocation seam (ssh; mosh/et reserved) Adds Transport enum with Ssh, Mosh, Et variants (derives Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum) and launch_invocation function that: - Ssh: delegates to existing ssh_invocation_tty(remote, remote_cmd, true) - Mosh/Et: return anyhow::bail with "not yet supported" message Includes test_launch_invocation_ssh_and_deferred_transports validating the behavior. Task 9 will wire launch_invocation into the remote resume flow. Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/src/cmd_resume.rs | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index d30ed4c5..1e4a1598 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1476,6 +1476,31 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { .all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) } +/// Transport protocol for remote resume. v1 implements only SSH; mosh and ET +/// are reserved for future use. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Transport { + Ssh, + Mosh, + Et, +} + +/// Carry the persist-wrapped remote command over the chosen transport. +/// v1 implements only ssh; mosh/et are reserved (spec: Transport axis). +fn launch_invocation( + transport: Transport, + remote: &str, + remote_cmd: &str, +) -> Result<(String, Vec)> { + match transport { + Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), + Transport::Mosh => { + anyhow::bail!("--via mosh is not yet supported (reserved); use --via ssh") + } + Transport::Et => anyhow::bail!("--via et is not yet supported (reserved); use --via ssh"), + } +} + /// Remote session-persistence backend for `--remote` resume. See the /// design spec: three launch mechanisms (direct-wrap, layout-wrap for /// zellij, attach-only for shpool). @@ -2604,6 +2629,20 @@ mod tests { } } + #[test] + fn launch_invocation_ssh_and_deferred_transports() { + let (bin, argv) = launch_invocation(Transport::Ssh, "ssh://h", "claude -r x").unwrap(); + assert_eq!(bin, "ssh"); + assert_eq!( + argv, + vec!["-t".to_string(), "h".to_string(), "claude -r x".to_string()] + ); + + let err = launch_invocation(Transport::Mosh, "ssh://h", "claude -r x").unwrap_err(); + assert!(err.to_string().contains("not yet supported"), "{err}"); + assert!(launch_invocation(Transport::Et, "ssh://h", "x").is_err()); + } + #[test] fn exec_strategy_recording_captures_invocation() { let recorder = RecordingExec::default(); From 20ba351a2c94e824e354f779d9b64f55b00991ff Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:20:36 -0400 Subject: [PATCH 24/39] feat(resume): ExecStrategy::remote_which probe (+ RecordingExec canned availability) --- crates/path-cli/src/cmd_resume.rs | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 1e4a1598..10dd16f5 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -576,6 +576,13 @@ pub trait ExecStrategy { /// 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 @@ -708,6 +715,35 @@ impl ExecStrategy for RealExec { } }) } + + fn remote_which( + &self, + target: &SshTarget, + bins: &[&str], + ) -> Result> { + if bins.is_empty() { + return Ok(Default::default()); + } + let probe = bins + .iter() + .map(|b| { + format!( + "command -v {} >/dev/null 2>&1 && echo {}", + shell_single_quote(b), + shell_single_quote(b) + ) + }) + .collect::>() + .join("; "); + self.with_conn(target, |conn| { + let out = exec_channel_capture(&conn.sess, &probe)?; + Ok(out + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) + }) + } } impl RealExec { @@ -1074,6 +1110,8 @@ pub struct RecordingExec { /// When true, `remote_home` returns an error — simulates an /// unreachable or unauthenticated remote. home_fails: bool, + /// Canned set of binaries `remote_which` reports as present. + available: std::collections::BTreeSet, } impl RecordingExec { @@ -1086,6 +1124,14 @@ impl RecordingExec { } } + /// A recorder whose `remote_which` reports exactly `bins` as present. + pub fn with_available<'a>(bins: impl IntoIterator) -> Self { + Self { + available: bins.into_iter().map(String::from).collect(), + ..Default::default() + } + } + pub fn captured(&self) -> CapturedExec { self.inner.lock().unwrap().clone() } @@ -1141,6 +1187,18 @@ impl ExecStrategy for RecordingExec { .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( @@ -2694,4 +2752,21 @@ mod tests { ); assert!(body.contains("pane"), "must be a KDL layout: {body}"); } + + #[test] + fn recording_exec_remote_which_returns_canned_set() { + let rec = RecordingExec::with_available(["tmux", "dtach"]); + let got = rec + .remote_which( + &SshTarget { + user: None, + host: "h".into(), + port: None, + }, + &["tmux", "zellij", "dtach"], + ) + .unwrap(); + assert!(got.contains("tmux") && got.contains("dtach")); + assert!(!got.contains("zellij")); + } } From a2eb51d0b686fe6dd8d714735548278c3c7bba83 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:23:28 -0400 Subject: [PATCH 25/39] feat(resume): --persist and --via flags (+ --tmux deprecation alias) --- crates/path-cli/src/cmd_resume.rs | 67 ++++++++++++++++++++++++++++ crates/path-cli/tests/resume.rs | 4 +- crates/path-cli/tests/support/mod.rs | 4 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 10dd16f5..b1ea735c 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -141,6 +141,30 @@ pub struct ResumeArgs { /// resume re-attaches. Requires --remote and tmux on the remote. #[arg(long, requires = "remote")] pub tmux: bool, + + /// Remote session-persistence backend. Skips the picker. Requires --remote. + #[arg(long, value_enum, requires = "remote")] + pub persist: Option, + + /// Transport for the interactive launch. ssh (default); mosh/et reserved. + #[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")] + pub via: Transport, +} + +/// Resolve the effective persist backend from `--tmux`/`--persist`, +/// treating `--tmux` as a deprecated alias for `--persist tmux`. +/// Errors if both are set. +fn resolve_persist_flag(args: &ResumeArgs) -> Result> { + match (args.tmux, args.persist) { + (true, Some(_)) => anyhow::bail!( + "--tmux is a deprecated alias for --persist tmux; don't combine it with --persist" + ), + (true, None) => { + eprintln!("note: --tmux is deprecated; use --persist tmux"); + Ok(Some(PersistBackend::Tmux)) + } + (false, p) => Ok(p), + } } pub fn run(args: ResumeArgs) -> Result<()> { @@ -2037,6 +2061,8 @@ mod tests { url: None, remote: Some("ssh://dev@example.com:2222".to_string()), tmux: false, + persist: None, + via: Transport::Ssh, } } @@ -2192,6 +2218,8 @@ mod tests { url: None, remote: None, tmux: false, + persist: None, + via: Transport::Ssh, }; let recorder = RecordingExec::default(); @@ -2314,6 +2342,37 @@ mod tests { assert!(err.to_string().contains("inline `Path`"), "actual: {}", err); } + #[test] + fn resolve_persist_flag_maps_tmux_and_rejects_conflict() { + let mut a = ResumeArgs { + input: "claude-x".to_string(), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + remote: None, + tmux: false, + persist: None, + via: Transport::Ssh, + }; + a.remote = Some("ssh://h".into()); + a.tmux = true; + assert_eq!( + resolve_persist_flag(&a).unwrap(), + Some(PersistBackend::Tmux) + ); + + a.persist = Some(PersistBackend::Dtach); + assert!(resolve_persist_flag(&a).is_err()); // both --tmux and --persist + + a.tmux = false; + assert_eq!( + resolve_persist_flag(&a).unwrap(), + Some(PersistBackend::Dtach) + ); + } + #[test] fn resolve_input_file_path() { let tmp = tempfile::tempdir().unwrap(); @@ -2330,6 +2389,8 @@ mod tests { url: None, remote: None, tmux: false, + persist: None, + via: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -2366,6 +2427,8 @@ mod tests { url: None, remote: None, tmux: false, + persist: None, + via: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -2429,6 +2492,8 @@ mod tests { url: None, remote: None, tmux: false, + persist: None, + via: Transport::Ssh, }; let result = resolve_input(&args); @@ -2459,6 +2524,8 @@ mod tests { url: None, remote: None, tmux: false, + persist: None, + via: Transport::Ssh, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index d0cf3a68..be09188d 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,7 +8,7 @@ #![cfg(not(target_os = "emscripten"))] -use path_cli::cmd_resume::{RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::cmd_resume::{RecordingExec, ResumeArgs, Transport, run_with_strategy}; use path_cli::harness::Harness; mod support; @@ -267,6 +267,8 @@ fn cache_id_input_loads_and_projects() { url: None, remote: None, tmux: false, + persist: None, + via: Transport::Ssh, }; let recorder = RecordingExec::default(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 65b9ce41..3a7f13ed 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -11,7 +11,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use path_cli::cmd_resume::ResumeArgs; +use path_cli::cmd_resume::{ResumeArgs, Transport}; use path_cli::harness::Harness; /// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or @@ -205,6 +205,8 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs url: None, remote: None, tmux: false, + persist: None, + via: Transport::Ssh, } } From a036106c9f46e269679c6defff3591435e245cd9 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:26:48 -0400 Subject: [PATCH 26/39] feat(resume): persist candidate assembly + preferred-backend selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pure functions for Task 9: - persist_candidates: assembles DISPLAY_ORDER filtered to available + Plain - preferred_backend: picks first of [Tmux, Zellij, Abduco, Dtach] or Plain Test: persist_candidates_and_preference passes (available {dtach,zellij} → candidates [Zellij, Dtach, Plain], preferred Zellij; empty → [Plain], preferred Plain; {tmux,shpool} → preferred Tmux). Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/src/cmd_resume.rs | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index b1ea735c..98b9e8a3 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1544,6 +1544,34 @@ fn shell_single_quote(s: &str) -> String { format!("'{}'", s.replace('\'', r"'\''")) } +/// Assemble candidate persist backends for the remote persistence picker. +/// Returns backends from DISPLAY_ORDER filtered to those whose bin() is +/// in the available set, plus Plain which is always offered. +fn persist_candidates(available: &std::collections::BTreeSet) -> Vec { + PersistBackend::DISPLAY_ORDER + .into_iter() + .filter(|b| match b.bin() { + None => true, // Plain always offered + Some(bin) => available.contains(bin), + }) + .collect() +} + +/// Pick the preferred backend from the available set. Priority: +/// [Tmux, Zellij, Abduco, Dtach], falling back to Plain if none are available. +fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend { + const PRIORITY: [PersistBackend; 4] = [ + PersistBackend::Tmux, + PersistBackend::Zellij, + PersistBackend::Abduco, + PersistBackend::Dtach, + ]; + PRIORITY + .into_iter() + .find(|b| b.bin().is_some_and(|bin| available.contains(bin))) + .unwrap_or(PersistBackend::Plain) +} + fn looks_like_pathbase_shorthand(s: &str) -> bool { // Three non-empty slash-separated segments, none containing whitespace // or starting with a dot/slash (which would indicate a relative or @@ -2836,4 +2864,29 @@ mod tests { assert!(got.contains("tmux") && got.contains("dtach")); assert!(!got.contains("zellij")); } + + #[test] + fn persist_candidates_and_preference() { + use std::collections::BTreeSet; + let avail: BTreeSet = ["dtach", "zellij"].iter().map(|s| s.to_string()).collect(); + let cands = persist_candidates(&avail); + // DISPLAY_ORDER filtered to available + always Plain, in order. + assert_eq!( + cands, + vec![ + PersistBackend::Zellij, + PersistBackend::Dtach, + PersistBackend::Plain + ] + ); + assert_eq!(preferred_backend(&avail), PersistBackend::Zellij); // tmux absent -> zellij + + let none: BTreeSet = BTreeSet::new(); + assert_eq!(persist_candidates(&none), vec![PersistBackend::Plain]); + assert_eq!(preferred_backend(&none), PersistBackend::Plain); + + let with_tmux: BTreeSet = + ["tmux", "shpool"].iter().map(|s| s.to_string()).collect(); + assert_eq!(preferred_backend(&with_tmux), PersistBackend::Tmux); // shpool never preferred over tmux + } } From 0250d0a69deec57936829abbd9a4107a7440dcc1 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:40:59 -0400 Subject: [PATCH 27/39] docs(resume): remote session-persistence & transport landscape reference Captures the four-layer model (reachability/connection-survival/session-survival/ workspace) and the tool landscape behind --persist and --via: tmux/zellij/shpool/ abduco/dtach, mosh/et/ssh3, tailscale/wireguard/cloudflare/teleport, and the libssh2 ProxyCommand ship limitation. Co-Authored-By: Claude Opus 4.8 --- .../remote-session-persistence-landscape.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/agents/remote-session-persistence-landscape.md diff --git a/docs/agents/remote-session-persistence-landscape.md b/docs/agents/remote-session-persistence-landscape.md new file mode 100644 index 00000000..e1874ac3 --- /dev/null +++ b/docs/agents/remote-session-persistence-landscape.md @@ -0,0 +1,144 @@ +# Remote session persistence & transport: the landscape + +Reference for `path resume --remote`. Captures the tool landscape behind the +`--persist` (session-survival) and `--via` (connection-survival) axes, and why +reachability is delegated to `~/.ssh/config`. Companion to the design spec +(`docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`) and the +targets runbook (`docs/agents/remote-resume-targets.md`). + +## The four independent layers + +Remote resume touches four *separate* concerns that **stack rather than compete**. +The classic mistake is asking one tool to cover two layers (expecting tmux to +solve roaming, or mosh to solve NAT). Keep them separate: + +| Layer | Concern | In `path` | +|---|---|---| +| **Reachability** | Can I connect at all? (NAT, mesh, broker) | `~/.ssh/config` — not a flag | +| **Connection-survival** | Does the link survive roaming/sleep/IP change? | `--via ssh\|mosh\|et` | +| **Session-survival** | Does work survive a client death / reattach elsewhere? | `--persist ` | +| **Workspace** | Panes / tabs / layout | a `--persist` backend (zellij) | + +--- + +## Layer 2 — Session survival (`--persist`) + +Anchors the session **server-side** so the harness keeps running under a daemon +even if the client dies, the laptop reboots, or you reattach from another machine. + +### Command-wrapping (clean fit — hand them the command) +- **tmux** — the incumbent. `tmux new-session -A -s ''`; `-A` + attaches-or-creates so re-running reattaches. Full multiplexer (panes, tabs, + status bar, copy-mode) that happens to persist. +- **abduco** — minimal detach/attach only (from the dvtm author; splitting is + left to dvtm). `abduco -A `. Mature, tiny; no output-replay on + reattach, rougher resize handling. +- **dtach** — the ~20-year-old ancestor; does even less (no session listing, you + manage socket paths). `dtach -A `. Works, but little reason over + abduco/shpool today. + +### Layout-wrapping +- **zellij** — the modern Rust "tmux designed this decade": discoverable + keybindings (status bar shows modes), floating/stacked panes, **resurrection** + (serializes sessions to disk → survives a reboot, re-runs pane commands on + attach after confirmation), WASM plugins with a capability/permission model, + KDL layouts as declarative workspace-as-data. No "new named session running + command X" one-liner — the supported path is a **KDL layout** with the command + in a pane, launched `zellij --session --layout `. + +### Attach-only (persistence, no multiplexer) +- **shpool** (Google) — "shell pool": a per-user daemon holds PTYs; `shpool + attach ` reconnects or creates. Persistence *only* — no panes/tabs/status + bar/prefix; your terminal emulator's native scrollback keeps working (a real + ergonomic win). One client per session (reattaching steals from a dead + connection). **No command arg** — attach always starts the configured shell, + so it can't be handed a one-shot command; needs `loginctl enable-linger` to + survive full logout. Best paired with a terminal/WM that owns splitting. + +### Restore-after-reboot +Only **zellij** reconstructs layout + pane commands declaratively. tmux needs +`resurrect`/`continuum` bolted on. Nothing truly restores process state — it's +all "re-run the commands" reconstruction. + +--- + +## Layer 1.5 — Connection survival (`--via`) + +Keeps one client↔host link alive; does **not** anchor the session server-side. + +- **mosh** — state-sync protocol (SSP) over UDP: client and server sync a + terminal-screen state object, so it survives IP changes / sleep / roaming. + No server-side session daemon beyond its own process, no attach-from-elsewhere, + **no scrollback** (its biggest wart). SSH for the initial handshake, then its + own UDP port. `mosh host -- ` (mosh owns the TTY, so no `ssh -t`). +- **Eternal Terminal (et)** — mosh's idea over **TCP** with native scrollback + intact and transparent auto-reconnect. SSH handshake, then its own port. + Knocks: needs its own daemon + open port; development has slowed. +- **SSH3** (misnomer, not an official successor) — SSH semantics over HTTP/3/QUIC; + QUIC gives connection migration natively (mosh's property, standardized at the + transport). Researchy; not dependable yet. + +In `path`: `--via` swaps the launch client binary; the persistence wrapping is +identical. v1 implements only `ssh`; `mosh`/`et` are reserved (seam in place). + +--- + +## Layer 1 — Reachability (delegated to `~/.ssh/config`) + +These dissolve the *reachability* problem, not the reconnect problem. **None are +a `path` flag** — they're how the host resolves/connects, expressed as a `Host` +block (`ProxyCommand`, `ProxyJump`, or a mesh hostname). Because `--remote` +accepts a `~/.ssh/config` alias, resume rides all of them for free. + +**Mesh / overlay (no public inbound ports):** +- **Tailscale SSH** — WireGuard mesh where `tailscaled` terminates SSH; auth via + your IdP, no keys to manage, no open ports. WireGuard endpoint migration keeps + the *tunnel* alive across IP changes (a plain TCP session inside can still + notice long outages — run mosh/shpool *over* tailscale, not instead). +- **WireGuard / Nebula / NetBird** — same idea, less product around it. + +**Rendezvous / brokered (outbound-only from the host):** +- **Cloudflare Tunnel + `cloudflared access ssh`** — outbound tunnel, SSH brokered + through Cloudflare's edge, optional access policies. +- **upterm / tmate** — reverse-tunnel a session through a relay for sharing / + reaching a NATed box (tmate is literally a tmux fork with a rendezvous server). +- **Teleport / Boundary** — enterprise: certificate-based, audited, + session-recorded access brokers. Heavy, but the only family where the transport + itself has an attribution story (every session tied to a signed identity cert, + recorded, policy-checked before the PTY is granted). + +**Browser-as-client:** +- **ttyd / gotty / sshx** — expose a terminal over WebSocket/HTTP (sshx adds + multiplayer + E2E encryption). "Any device, zero client install"; run behind one + of the mesh/broker layers, not raw. + +### Known limitation +libssh2 (the probe/ship half of `--remote`) **does not honor `ProxyCommand`/ +`ProxyJump`** — it dials a raw TCP socket. A host reachable *only* through a +broker/jump (Cloudflare Tunnel, bastion-only) will launch fine (the `ssh` CLI +honors the config) but **fail at the ship step**. Candidate mitigation: fall back +to shipping over the `ssh` CLI (`ssh host 'cat > dest'` / scp) when the libssh2 +dial fails and a `ProxyCommand` is configured. Tracked as an open question. + +--- + +## Composition guidance + +The layers stack. Pick per concern: + +- **Reachability:** ssh-config alias over Tailscale (identity-attributed) or a + broker; plain host if directly routable. +- **Connection survival:** mosh/et if you roam; skip if the link is stable. +- **Session survival:** tmux (ubiquitous), zellij (workspace + resurrection), + shpool (minimal, terminal-native scrollback), abduco/dtach (tiny). + +For agent runs in a claude-box-style setup, a strong pairing is **Tailscale SSH + +shpool**: identity-attributed transport (auth logged against a signed identity, +not a static `authorized_keys`) with server-side session persistence — versus +mosh, which adds a second auth path and a long-lived UDP daemon to the attack +surface. When you want the workspace layer too, that's **zellij** instead of +shpool. + +The niche bifurcates cleanly and there's no middle tool: transport persistence +(mosh, et), session persistence (shpool, abduco), workspace persistence (zellij, +tmux). Reachability is its own axis on top. From 49ff8d20bd8ae1c6e206bf9047e158d29c6f9679 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:42:42 -0400 Subject: [PATCH 28/39] feat(resume): wire persistence picker + transport through run_remote --- Cargo.lock | 2 +- crates/path-cli/src/cmd_resume.rs | 100 +++++++++++++++++++++++++++--- crates/path-cli/tests/resume.rs | 35 ++++++++++- 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c0ffd32..638b9e55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4118,7 +4118,7 @@ dependencies = [ [[package]] name = "toolpath-convo" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "jsonschema", diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 98b9e8a3..599383de 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1307,24 +1307,108 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // 4. 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 backend = if args.tmux { - PersistBackend::Tmux - } else { - PersistBackend::Plain + let backend = match resolve_persist_flag(args)? { + Some(b) => b, + None => { + let bins: Vec<&str> = PersistBackend::DISPLAY_ORDER + .iter() + .filter_map(|b| b.bin()) + .collect(); + let avail = exec + .remote_which(&target, &bins) + .with_context(|| format!("probing persistence backends on {remote}"))?; + let cands = persist_candidates(&avail); + let preferred = preferred_backend(&avail); + if 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 launch_cmd = persist_plan( + let plan = persist_plan( Harness::Claude, &session_id, launch_cwd.as_deref(), backend, &home, - ) - .remote_command; - let (binary, argv) = ssh_invocation_tty(remote, &launch_cmd, true)?; + ); + if let Some((path, body)) = &plan.extra_file { + if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { + exec.remote_mkdirs(&target, parent) + .with_context(|| format!("creating {parent} on {remote}"))?; + } + exec.remote_write(&target, path, body) + .with_context(|| format!("shipping {path} to {remote}"))?; + } + if let Some(note) = &plan.post_note { + eprintln!("{note}"); + } + let (binary, argv) = launch_invocation(args.via, remote, &plan.remote_command)?; let cwd = std::env::current_dir()?; exec_harness(&binary, &argv, &cwd, exec) } +/// Interactive persistence-backend picker, mirroring [`interactive_pick`] +/// (the harness picker): present `describe()` rows via the fuzzy UI, with +/// the probed-preferred backend flagged `(recommended)`, and map the +/// picked row back to its [`PersistBackend`] by leading word. +fn pick_persist_backend( + cands: &[PersistBackend], + preferred: PersistBackend, +) -> Result { + if !crate::fuzzy::available() { + let hint = if crate::fuzzy::embedded_picker_available() { + "rerun in a terminal" + } else { + "install `fzf` (or build with the default `embedded-picker` feature) and rerun in a terminal" + }; + anyhow::bail!("interactive picker requires a TTY; pass `--persist ` or {hint}"); + } + let mut lines: Vec = Vec::with_capacity(cands.len()); + for b in cands { + let suffix = if *b == preferred { + " (recommended)" + } else { + "" + }; + lines.push(format!("{}{}", b.describe(), suffix)); + } + + let header = format!( + "pick a persistence backend (recommended: {})", + preferred.describe() + ); + let opts = crate::fuzzy::PickOptions { + with_nth: "1..", + header: Some(&header), + ..Default::default() + }; + let selected = match crate::fuzzy::pick(&lines, &opts) + .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))? + { + crate::fuzzy::PickResult::Selected(rows) => rows.into_iter().next().unwrap_or_default(), + crate::fuzzy::PickResult::Cancelled => std::process::exit(130), + crate::fuzzy::PickResult::NoMatch => { + anyhow::bail!("fzf returned no match — picker UI was empty?"); + } + }; + + let picked_word = selected.split_whitespace().next().unwrap_or_default(); + for b in cands { + let word = b.describe().split_whitespace().next().unwrap_or_default(); + if picked_word == word { + return Ok(*b); + } + } + anyhow::bail!("picker returned an unrecognized row: {selected}") +} + /// The name Claude Code gives a project's directory under /// `~/.claude/projects/`. Host-side mirror of `toolpath-claude`'s /// (private) `sanitize_project_path` — kept in sync by a unit test that diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index be09188d..39eeeceb 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,7 +8,9 @@ #![cfg(not(target_os = "emscripten"))] -use path_cli::cmd_resume::{RecordingExec, ResumeArgs, Transport, run_with_strategy}; +use path_cli::cmd_resume::{ + PersistBackend, RecordingExec, ResumeArgs, Transport, run_with_strategy, +}; use path_cli::harness::Harness; mod support; @@ -415,6 +417,37 @@ fn remote_flag_dispatches_resume_over_ssh() { ); } +/// With `--persist dtach` pinned explicitly (skipping the probe/picker), +/// the launch command must be wrapped in `dtach -A /tmp/path-dtach-` +/// so the remote session survives an SSH disconnect. +#[test] +fn remote_resume_persist_dtach_records_launch_and_ships() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binaries(&["ssh", "claude"]); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:claude-code", "claude-code://resume-persist-dtach"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let mut args = args_explicit(doc_file, cwd.path(), Harness::Claude); + args.remote = Some("ssh://h".to_string()); + args.persist = Some(PersistBackend::Dtach); + + let rec = RecordingExec::with_available(["dtach"]); + run_with_strategy(args, &rec).unwrap(); + + let cap = rec.captured(); + assert_eq!(cap.binary, "ssh"); + assert!( + cap.args + .iter() + .any(|a| a.contains("dtach -A /tmp/path-dtach-")), + "{:?}", + cap.args + ); +} + /// `--remote` without `--harness` must fail fast on the host with a /// clear message: the remote resume runs over a non-interactive SSH /// session where the harness picker has no TTY, and the host can't run From 21931f1fe4149f70aee4848296628378d61b49f0 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:47:07 -0400 Subject: [PATCH 29/39] docs(resume): document persistence picker + --via; bump path-cli --- CHANGELOG.md | 21 ++++++++++++++++ CLAUDE.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/Cargo.toml | 2 +- crates/path-cli/src/cmd_resume.rs | 42 +++++++++++++++++++++++++++++++ site/_data/crates.json | 2 +- 7 files changed, 68 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd42348..6ad4d1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to the Toolpath workspace are documented here. +## Resume: remote session-persistence picker + transport seam — 2026-07-24 + +- **`path-cli`** (0.18.0): `path resume --remote` gains `--persist + `, generalizing the previous `--tmux`-only detach story into a + picker over six backends: `plain`, `tmux`, `abduco`, `dtach`, `zellij`, + `shpool`. Three launch mechanisms: direct-wrap (`plain`/`tmux`/`abduco`/ + `dtach` — the harness command is wrapped inline), layout-wrap (`zellij` + — a generated KDL layout drives the launch), and attach-only (`shpool` + — a persistent remote shell; resume prints the attach command instead + of launching it directly). The remote is probed for installed backends + (mirroring the harness picker); `--persist X` skips the picker; a + non-TTY invocation auto-picks the best available backend (tmux > + zellij > abduco > dtach > plain). `--tmux` is now a **deprecated + alias** for `--persist tmux` — combining both flags is an error. + - New `--via ssh|mosh|et` transport axis carries the persist-wrapped + launch command; `ssh` is implemented (the prior default behavior), + `mosh`/`et` are reserved seams that error "not yet supported". + - Reachability over Tailscale, Cloudflare Tunnel, or a bastion hop + needs no new flag — it rides the existing `~/.ssh/config` `Host` + alias resolution already documented for `--remote`. + ## Resume: remote resume over SSH — 2026-07-23 - **`path-cli`** (0.17.0): `path resume` gains `--remote diff --git a/CLAUDE.md b/CLAUDE.md index dff8fde6..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. 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`. +- `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 638b9e55..573206d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index d5b0d12f..288509f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.17.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"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 0278456b..7d931b6f 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.17.0" +version = "0.18.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 599383de..e8da8d1d 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -80,6 +80,48 @@ //! project-dir key for the shipped file and the launch's `cd` target; //! absent, both default to the remote's ssh cwd (`$HOME`). //! +//! ## Session persistence (`--persist `) +//! +//! `--remote` resumes can survive an SSH disconnect by wrapping the +//! remote launch in a session multiplexer, chosen from six backends +//! ([`PersistBackend`]): `plain` (no wrapping — dies on disconnect, +//! always offered), `tmux`, `abduco`, `dtach`, `zellij`, `shpool`. Each +//! backend maps to one of three launch mechanisms: +//! 1. **Direct-wrap** (`plain`, `tmux`, `abduco`, `dtach`) — the harness +//! command is wrapped inline (e.g. `tmux new-session -A -s path- +//! …`); detach/re-run to re-attach. +//! 2. **Layout-wrap** (`zellij`) — a generated KDL layout drives the +//! launch so the harness starts inside a named zellij session/pane. +//! 3. **Attach-only** (`shpool`) — shpool keeps a persistent remote shell +//! running the harness; resume prints the `shpool attach` command for +//! the user to run rather than launching it directly. +//! +//! Without `--persist`, a non-TTY invocation picks the best available +//! backend automatically ([`preferred_backend`]: tmux > zellij > abduco > +//! dtach > plain); an interactive TTY shows a picker built from +//! [`persist_candidates`] — the remote is probed for installed backends +//! ([`ExecStrategy::remote_which`]) and only those (plus `plain`) are +//! offered. `--persist X` skips the picker. `--tmux` is a **deprecated +//! alias** for `--persist tmux` ([`resolve_persist_flag`]); combining +//! both flags is an error. +//! +//! ## Transport (`--via ssh|mosh|et`) +//! +//! `--via` selects the transport that carries the persist-wrapped +//! remote command for the interactive launch ([`launch_invocation`]). +//! `ssh` (the default) is implemented; `mosh` and `et` are reserved +//! seams that currently error with "not yet supported" — the type is +//! wired through so a future transport only needs a new match arm. +//! +//! ## Reachability +//! +//! Tailscale, Cloudflare Tunnel, and bastion-hop targets need no +//! dedicated flag: point `--remote` at a `~/.ssh/config` `Host` alias +//! (e.g. `path resume … --remote my-tailnet-box`) and the existing +//! `HostName`/`User`/`Port`/`IdentityFile` resolution (see "Remote" +//! above) does the rest, since those tools all work by rewriting what +//! `ssh ` resolves to. +//! //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` //! for the full design. diff --git a/site/_data/crates.json b/site/_data/crates.json index 990facee..b6e8d9e4 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.17.0", + "version": "0.18.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", From 54a7b5ee1bcc19db4e840c9487919b613f09ffdf Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:48:51 -0400 Subject: [PATCH 30/39] docs(resume): restore design spec + targets runbook dropped in concurrent rebase The design spec (2026-07-23-remote-persistence-picker-design.md) and the exe.dev/Sprite targets runbook were committed earlier on this branch but dropped when the branch was rebased by concurrent work. Restored from the dangling commits (5a796a6, 42086c4). Co-Authored-By: Claude Opus 4.8 --- docs/agents/remote-resume-targets.md | 119 ++++++++++ ...-07-23-remote-persistence-picker-design.md | 210 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 docs/agents/remote-resume-targets.md create mode 100644 docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md diff --git a/docs/agents/remote-resume-targets.md b/docs/agents/remote-resume-targets.md new file mode 100644 index 00000000..7df81843 --- /dev/null +++ b/docs/agents/remote-resume-targets.md @@ -0,0 +1,119 @@ +# Remote resume targets: exe.dev and Sprite + +Runbook for standing up an SSH-reachable box that `path resume --remote` can +target. `path resume --remote ` shells out to the real `ssh` +binary for the interactive launch, and uses libssh2 (raw TCP dial) for the +preflight/ship half — so a target must be reachable as a plain `ssh` host with +`path` **and** `claude` installed. + +See also: `crates/path-cli/src/cmd_resume.rs` module docs (the remote protocol) +and the design spec `docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`. + +--- + +## exe.dev — ✅ verified working + +exe.dev is SSH-native (VMs at `.exe.xyz` support full SSH), so it drops +straight into the remote-resume contract. This is the reference setup. + +**Account / key** +- Account `me@robertdelanghe.dev`. Auth is by SSH key; registration is a one-time + interactive `ssh exe.dev` in a **real terminal** (the CLI `!` shell has no TTY + and can't complete it). +- `~/.ssh/config` pins the key: + ``` + Host exe.dev *.exe.xyz + IdentitiesOnly yes + IdentityFile ~/.ssh/id_ed25519_signing + StrictHostKeyChecking accept-new + ``` + +**Provision + prepare a VM** +```bash +ssh exe.dev new --name=pathremote --json # -> pathremote.exe.xyz (Ubuntu 24.04 x86_64, user exedev) +# claude ships pre-installed at /usr/local/bin/claude + +# install path on the box: +ssh pathremote.exe.xyz 'curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal' +ssh pathremote.exe.xyz 'sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential pkg-config libssl-dev cmake' +tar czf - --exclude=target --exclude=.git . | ssh pathremote.exe.xyz 'rm -rf ~/toolpath && mkdir -p ~/toolpath && tar xzf - -C ~/toolpath' +ssh pathremote.exe.xyz 'cd ~/toolpath && source ~/.cargo/env && cargo build --release -p path-cli' # ~3m on 2 CPUs +ssh pathremote.exe.xyz 'sudo cp ~/toolpath/target/release/path /usr/local/bin/path' +``` + +**Authenticate claude** (real terminal — OAuth device flow): +```bash +ssh -t pathremote.exe.xyz claude # complete the login URL in your browser +``` + +**Verify** +```bash +path resume --remote ssh://pathremote.exe.xyz --harness claude +# also works via a config alias: --remote pathbox +``` + +**Housekeeping:** persistent VM = billing. `ssh exe.dev rm pathremote` to delete; +`ssh exe.dev ls --json` to list. Rebuild + recopy `path` if the branch's +remote-resume code changes materially. + +--- + +## Sprite (Fly.io) — 📋 documented, not yet stood up + +Sprite is authed (org `robert-delanghe`, card on file, token minted) but **does +not fit the SSH contract out of the box**: it exposes `sprite console` / +`sprite exec` / `sprite proxy`, not a raw `ssh` endpoint. The lift is to give it +one. + +### The endpoint decision (do option A) + +- **A — sshd inside + `sprite proxy` → `localhost:`** *(recommended)*. + Dialing `localhost:` is a **raw TCP socket**, which libssh2 (the + preflight/ship half) handles — so both ship and launch work. +- **B — `ProxyCommand` shim over `sprite exec`**. Hits a known limitation: + **libssh2 does not honor `ProxyCommand`**, so the interactive launch would work + but the file-ship step fails. Do not use until the ssh-CLI ship fallback exists + (tracked as an open question in the design spec). + +### Steps (option A) + +1. **Create a sprite** + ```bash + sprite create -o robert-delanghe pathremote + ``` +2. **Install + start sshd inside it** (via `sprite console` / `sprite exec`) + ```bash + sprite exec -- sudo apt-get update -qq + sprite exec -- sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server + sprite exec -- sudo sed -i 's/#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config + sprite exec -- 'mkdir -p ~/.ssh && printf "%s\n" "$(cat ~/.ssh/id_ed25519_signing.pub)" >> ~/.ssh/authorized_keys' # push your pubkey + sprite exec -- sudo service ssh start + ``` +3. **Forward the SSH port to localhost** + ```bash + sprite proxy 2222 # maps a local port to the sprite's :22 (confirm exact mapping syntax with `sprite proxy --help`) + ``` +4. **Install `path` + `claude`** — same recipe as exe.dev, but over the tunnel + (`ssh -p 2222 user@localhost …`). Then OAuth `claude` in a real terminal: + ```bash + ssh -t -p 2222 user@localhost claude + ``` +5. **Verify** + ```bash + path resume --remote ssh://user@localhost:2222 --harness claude + ``` + +### Open items for Sprite +- Confirm `sprite proxy` direction/port-mapping syntax (`sprite proxy --help`). +- The proxy tunnel must stay up for the duration of the resume (ship + launch). +- Alternative to sshd: if a future `path` gains a `sprite exec`-based transport + (a `--via sprite` seam), Sprite could skip sshd entirely — not planned. + +--- + +## Quick reference + +| Target | SSH fit | `path` | `claude` | Status | +|---|---|---|---|---| +| exe.dev `pathremote.exe.xyz` | native | ✅ built (this branch) | ✅ OAuth'd | verified working | +| Sprite | needs sshd + `sprite proxy` | ⬜ | ⬜ | documented, not stood up | diff --git a/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md b/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md new file mode 100644 index 00000000..4e523451 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md @@ -0,0 +1,210 @@ +# Remote resume: session-persistence backend picker + +**Status:** design +**Date:** 2026-07-23 +**Area:** `crates/path-cli/src/cmd_resume.rs` (`--remote` path) + +## Problem + +`path resume --remote ` launches an interactive +`ssh -t host 'claude -r '`. When the SSH link drops, the harness dies and +the resumed work is lost. The branch already added an opt-in `--tmux` flag that +wraps the launch in `tmux new-session -A -s path-` so it survives drops and +is re-attachable — but it is (a) opt-in, (b) hardcoded to tmux, and (c) blind to +whether the remote even has tmux. + +We want the persistence layer to be a **first-class, selectable choice**, modeled +on the existing harness picker (`share`, `resume`, `p import`): probe what the +remote supports, present the options in the fuzzy picker, pre-select a sane +default, and let a flag skip the picker entirely. + +## Goals + +- Choose a remote session-persistence backend the same way we choose a harness: + interactive fuzzy picker by default, `--persist ` to skip it. +- Only offer backends actually installed on the remote (probe once, up front). +- Default to a working persistence backend when one is available, falling back + to a plain launch when none is — never fail *because* a backend is missing. +- Support six backends spanning three launch mechanisms (below). +- Preserve `--tmux` as a deprecated alias so existing usage keeps working. + +## Non-goals + +- **Implementing** transport-layer persistence (mosh, Eternal Terminal) in v1. + These wrap the SSH *connection*, not the remote command — a separate axis from + `--persist`. v1 reserves the flag shape (`--via ssh|mosh`, below) but only + implements `ssh`; mosh lands later. +- Restore-after-reboot / resurrection semantics beyond what each backend already + does on its own. +- Local (non-`--remote`) resume is unchanged. + +## The four independent layers + +Remote resume touches four *separate* concerns that stack rather than compete. +Conflating any two is the classic mistake (expecting tmux to solve roaming, or +mosh to solve NAT). This design keeps them separate: + +| Layer | Concern | How we express it | +|---|---|---| +| **Reachability** | Can I even connect? (NAT, mesh, broker) | **`~/.ssh/config`** — not a flag | +| **Connection-survival** | Does the link survive roaming/sleep? | **`--via ssh\|mosh\|et`** | +| **Session-survival** | Does work survive a client death? | **`--persist `** | +| **Workspace** | Panes/tabs/layout | a `--persist` backend (zellij) | + +### Reachability is delegated to ssh config (no flag) + +Tailscale SSH, WireGuard, Cloudflare Tunnel (`cloudflared access ssh`), +Teleport, Nebula, NetBird — all of these are expressed as a `Host` block +(`ProxyCommand`, `ProxyJump`, or just a mesh hostname). Because `--remote` +accepts a `~/.ssh/config` alias (see the parsing change on this branch), +resume rides every one of them for free — the CLI launch honors the full config. + +**Known limitation:** the **libssh2 probe/ship half does not honor +`ProxyCommand`/`ProxyJump`** — libssh2 dials a raw TCP socket. So a host reachable +*only* through a broker/jump (Cloudflare Tunnel, a bastion) will launch fine but +**fail at the ship step**. Mitigation (candidate, not v1): when the libssh2 dial +fails and the config has a `ProxyCommand`, fall back to shipping over the `ssh` +CLI (`ssh host 'cat > dest'` / scp), which honors the config. Recorded as an open +question; v1 documents the limitation and errors clearly. + +## Transport axis (`--via`) — forward-looking + +Connection-survival only. Orthogonal to `--persist`; the belt-and-suspenders +combo is `--via mosh` + a `--persist` backend. + +- `--via ssh` — **default, the only v1 implementation.** Interactive + `ssh -t host ''`. +- `--via mosh` — **deferred.** `mosh host -- ''` (mosh owns + the TTY, so no `-t`; needs `mosh-server` on the remote → joins the up-front + probe). +- `--via et` — **deferred.** Eternal Terminal: `et host -c ''` + (TCP auto-reconnect, native scrollback; needs `etserver` + its port → probe). + +The persistence wrapping is identical across transports — `--via` only swaps how +the wrapped command is carried. Designing the flag in now keeps the launch code +factored around a `Transport` seam so mosh/et are additive drop-ins, not a +refactor. The libssh2 probe/ship half is unaffected by `--via`. + +## Backends and launch mechanisms + +Let `INNER` be the existing inner launch string: `[cd && ]claude -r `. +Session name is `path-` (already used by the tmux path). + +Backends fall into **three mechanisms**: + +### 1. Direct command-wrap (hand them INNER) +| Backend | Remote command | +|---|---| +| `plain` | `INNER` | +| `tmux` | `tmux new-session -A -s path- 'INNER'` | +| `abduco` | `abduco -A path- sh -c 'INNER'` | +| `dtach` | `dtach -A /tmp/path-dtach- -z sh -c 'INNER'` | + +Reattach on a later resume is automatic: tmux `-A`, abduco `-A`, dtach `-A` all +attach-or-create by name. + +### 2. Layout-wrap (zellij) +zellij has no "new named session running command X" one-liner; the supported +path is a KDL layout file with the command in a pane. + +- **Ship** an extra file `~/.cache/path/zellij-.kdl` alongside the session + JSONL, containing a single pane that runs `INNER`. +- **Launch**: `zellij --session path- --layout ~/.cache/path/zellij-.kdl` + — creates with the layout when new, attaches when the session already exists. +- Reattach later: same command (zellij attaches an existing session). + +### 3. Attach-only (shpool) +`shpool attach ` only ever starts a **shell** — there is no supported way +to run a one-shot command in it. So shpool does **not** auto-run the harness. + +- **Ship** the session JSONL as normal. +- **Launch**: `ssh -t host 'shpool attach path-'` — drops the user into a + persistent shell. +- **Guidance**: before handing off, print the exact command to run: + `run in the shpool session: cd && claude -r `. + +This is honest about shpool's model rather than faking a launch it can't do. + +## User-facing surface + +### Flags +- `--persist ` — `plain|tmux|abduco|dtach|zellij|shpool`. Skips the + picker. Requires `--remote`. +- `--tmux` — **deprecated alias** for `--persist tmux`. Kept working; emits a + one-line deprecation note pointing at `--persist tmux`. Errors if combined + with an explicit `--persist`. +- `--via ` — `ssh` (default) | `mosh` | `et` (both deferred; error + with a "not yet supported" message in v1). Requires `--remote`. See the + Transport axis section. + +### Picker behavior (mirrors the harness picker) +1. After the remote is reachable and home is resolved, **probe** the remote once: + `command -v tmux zellij abduco dtach shpool` in a single exec channel → + the set of available backends. +2. Candidate list = `plain` + every available backend (in a fixed display order: + `tmux, zellij, abduco, dtach, shpool, plain`), each with a short description + of what it buys (`tmux — detachable, survives drops`, `shpool — persistent + shell (attach-only)`, `plain — no persistence`, …). +3. **Pre-select** the highest-priority available backend by the order + `tmux > zellij > abduco > dtach > plain` (shpool is not auto-preferred because + it can't auto-launch). If none installed, pre-select `plain`. +4. **Skip the picker** when: `--persist` given; or `--tmux` given; or not + interactive (no TTY on stdin+stderr) — in which case use the pre-selected + default. Emit a note when falling back to `plain` because nothing was + installed. + +## Internal design + +- New `enum PersistBackend { Plain, Tmux, Abduco, Dtach, Zellij, Shpool }` with: + - `bin() -> Option<&str>` (probe target; `Plain` → `None`). + - `describe() -> &str` (picker row text). + - display/parse for clap (`ValueEnum`) and the picker. +- New `struct PersistPlan { remote_command: String, extra_file: Option<(String, Vec)>, post_note: Option }` + built from `(backend, session_id, launch_cwd)`. `extra_file` is the zellij + layout; `post_note` is the shpool guidance. +- `ExecStrategy` gains `fn remote_which(&self, target, bins: &[&str]) -> Result>` + (single exec channel running `command -v …`), so probing is mockable in tests. +- `run_remote` sequence becomes: resolve/project → connect/home → **probe** → + **resolve backend** (flag or picker) → **build PersistPlan** → ship JSONL + (+ `extra_file`) → print `post_note` → interactive launch of + `plan.remote_command` **via the selected transport**. +- New `enum Transport { Ssh, Mosh, Et }`. A `fn launch_invocation(transport, + remote, remote_cmd) -> (binary, argv)` seam replaces the direct + `ssh_invocation_tty` call: `Ssh` → today's `ssh -t …`; `Mosh`/`Et` → v1 returns + a "not yet supported" error (shape reserved). The probe/ship half never sees + `Transport`. +- `remote_launch_command(harness, id, cwd, tmux: bool)` is replaced by + `persist_plan(harness, id, cwd, backend) -> PersistPlan`. Existing + `tmux: bool` call sites and tests migrate to `PersistBackend`. + +### Quoting +The nested quoting (`ssh 'sh -c '\''cd … && claude …'\'''`) is the sharp edge. +Reuse `shell_single_quote` and add a focused unit test per backend asserting the +exact remote command string for a representative `INNER` (with and without +`--cwd`), so the escaping is pinned. + +## Testing + +- Unit: `persist_plan` output string per backend (× cwd / no-cwd); `PersistBackend` + clap parse + display; picker candidate assembly + pre-selection given a probed + availability set; `--tmux` → tmux mapping + conflict-with-`--persist` error. +- `remote_which` exercised via `RecordingExec` (records the probe, returns a + canned availability set). +- Integration (`tests/resume.rs`, `RecordingExec`): `--persist tmux/abduco/dtach` + record the expected `remote_command`; zellij records the shipped layout file; + shpool records the attach command + post-note; non-TTY defaults to the + pre-selected backend. +- Keep the existing `remote_resume_*` tests green (migrated to the new enum). + +## Rollout / compat + +- Pre-1.0; `--tmux` stays as a deprecated alias (no hard break). +- Bump `path-cli` per the release checklist (minor: additive CLI surface). + +## Open questions + +- zellij `--session X --layout Y` when the session already exists: confirm it + attaches (ignoring the layout) rather than erroring. If it errors, launch logic + branches on "session exists" (probe with `zellij list-sessions`). +- dtach socket dir: `/tmp/path-dtach-` is world-readable-parent; acceptable + for a single-user box, but consider `${XDG_RUNTIME_DIR:-/tmp}`. From f8a859dc18b0c8ae6fe072872c26372da6cb540f Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:56:20 -0400 Subject: [PATCH 31/39] fix(resume): probe before ship + non-fatal probe; validate --via early; sanitize session name Reorders run_remote so the persistence backend is resolved before the session file ships, and makes a remote_which probe failure fall back to Plain instead of aborting a resume that previously never needed an exec channel. Validates --via up front so mosh/et bail before any remote side effects. Sanitizes the multiplexer/session name (and the dtach socket path) to [A-Za-z0-9_-] so a crafted session id can't steer the zellij layout file write. Drops the misleading close_on_exit mention from the zellij layout comment. Co-Authored-By: Claude Opus 4.8 --- crates/path-cli/src/cmd_resume.rs | 184 ++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 37 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index e8da8d1d..2a50bdfb 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1296,6 +1296,16 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul Some(_) => anyhow::bail!("remote resume currently supports only --harness claude"), } + // Validate --via before any remote side effects — mosh/et are reserved + // seams that always error; fail here rather than after shipping files + // and printing persistence notes. + match args.via { + Transport::Ssh => {} + other => { + let _ = launch_invocation(other, remote, "")?; + } + } + // 1. Resolve + validate locally so a bad document fails on the host, // not deep inside an SSH session — and project the session fully // in memory: the remote never sees the toolpath document, only the @@ -1316,39 +1326,15 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul } eprintln!("remote {remote}: reachable (home {home})"); - // 3. 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. + // 3. Resolve the persistence backend BEFORE shipping anything — a + // probe failure here must fall back gracefully (never abort a + // resume that previously never needed a libssh2 exec channel), and + // resolving first avoids shipping the session file only to abort + // partway through on a probe error. let launch_cwd: Option = args .cwd .as_ref() .map(|dir| normalize_remote_cwd(dir, &home)); - let project_path = launch_cwd.clone().unwrap_or_else(|| home.clone()); - let dir_name = claude_project_dir_name(&project_path); - let projects_dir = format!("{home}/.claude/projects/{dir_name}"); - exec.remote_mkdirs(&target, &projects_dir) - .with_context(|| format!("creating {projects_dir} on {remote}"))?; - let dest = format!("{projects_dir}/{session_id}.jsonl"); - exec.remote_write(&target, &dest, jsonl.as_bytes()) - .with_context(|| format!("shipping session file to {remote}:{dest}"))?; - eprintln!("Shipped session {session_id} → {remote}:{dest}"); - - // The launch cwd must exist before the interactive `cd` — create it - // over SFTP too, since resuming into a fresh directory is the normal - // case. - if let Some(dir) = launch_cwd.as_deref() { - exec.remote_mkdirs(&target, dir) - .with_context(|| format!("creating launch dir {dir} on {remote}"))?; - } - - // 4. 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 backend = match resolve_persist_flag(args)? { Some(b) => b, None => { @@ -1356,9 +1342,15 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul .iter() .filter_map(|b| b.bin()) .collect(); - let avail = exec - .remote_which(&target, &bins) - .with_context(|| format!("probing persistence backends on {remote}"))?; + // 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() { @@ -1380,6 +1372,33 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul backend, &home, ); + + // 4. Ship: create the Claude project dir and write the projected + // JSONL over SFTP — typed file operations, no remote shell. The + // project dir is keyed on the launch cwd (--cwd, else the remote + // home) with Claude Code's own sanitization, so `claude -r` + // started there finds the session. The cwd is normalized to the + // absolute form the remote shell's `cd` will land on (trailing + // slashes / `.` / `..` / relative-to-home resolved) so the host's + // dir-name key matches what remote Claude computes from its cwd. + let project_path = launch_cwd.clone().unwrap_or_else(|| home.clone()); + let dir_name = claude_project_dir_name(&project_path); + let projects_dir = format!("{home}/.claude/projects/{dir_name}"); + exec.remote_mkdirs(&target, &projects_dir) + .with_context(|| format!("creating {projects_dir} on {remote}"))?; + let dest = format!("{projects_dir}/{session_id}.jsonl"); + exec.remote_write(&target, &dest, jsonl.as_bytes()) + .with_context(|| format!("shipping session file to {remote}:{dest}"))?; + eprintln!("Shipped session {session_id} → {remote}:{dest}"); + + // The launch cwd must exist before the interactive `cd` — create it + // over SFTP too, since resuming into a fresh directory is the normal + // case. + if let Some(dir) = launch_cwd.as_deref() { + exec.remote_mkdirs(&target, dir) + .with_context(|| format!("creating launch dir {dir} on {remote}"))?; + } + if let Some((path, body)) = &plan.extra_file { if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { exec.remote_mkdirs(&target, parent) @@ -1391,6 +1410,10 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul if let Some(note) = &plan.post_note { eprintln!("{note}"); } + + // 5. Interactive launch of the harness against the shipped session, + // with a real TTY — the one step that stays on the real `ssh` + // binary (it needs the user's terminal and ssh config). let (binary, argv) = launch_invocation(args.via, remote, &plan.remote_command)?; let cwd = std::env::current_dir()?; exec_harness(&binary, &argv, &cwd, exec) @@ -1499,6 +1522,24 @@ struct PersistPlan { post_note: Option, } +/// Reduce a session id to `[A-Za-z0-9_-]` so it's safe to embed both in a +/// multiplexer session name and in a remote file path (the zellij layout +/// file lives at `/.cache/path/zellij-.kdl`). Any character +/// outside that set — including `/` and `.` (so `..` can't traverse) — +/// becomes `-`. +fn sanitize_session_name(session_id: &str) -> String { + session_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect() +} + fn persist_plan( harness: Harness, session_id: &str, @@ -1514,8 +1555,11 @@ fn persist_plan( Some(dir) => format!("cd {} && {inner}", shell_quote(dir)), None => inner, }; - // Detachable session names can't contain `.`/`:`. - let name = format!("path-{}", session_id.replace(['.', ':'], "-")); + // Detachable session names can't contain `.`/`:`, and this name also + // feeds a remote file path (the zellij layout file), so sanitize down + // to a safe charset rather than just stripping `.`/`:` — a crafted + // session_id with `/` or `..` must not be able to steer the write. + let name = format!("path-{}", sanitize_session_name(session_id)); let mut extra_file = None; let mut post_note = None; @@ -1535,7 +1579,7 @@ fn persist_plan( "dtach -A {} -z sh -c {}", shell_quote(&format!( "/tmp/path-dtach-{}", - session_id.replace(['.', ':'], "-") + sanitize_session_name(session_id) )), shell_single_quote(&inner) ), @@ -1556,8 +1600,7 @@ fn zellij_plan( extra: &mut Option<(String, Vec)>, ) -> String { let layout_path = format!("{home}/.cache/path/zellij-{name}.kdl"); - // Single-pane layout that runs INNER via the shell. `close_on_exit` - // keeps the pane if the harness exits so output stays visible. + // Single-pane layout that runs INNER via the shell. let kdl = format!( "layout {{\n pane command=\"sh\" {{\n args \"-c\" {inner:?}\n }}\n}}\n" ); @@ -2190,6 +2233,73 @@ mod tests { assert!(p.extra_file.is_none()); } + #[test] + fn persist_plan_sanitizes_session_name() { + // A hostile session id must not leak `/` or `..` into the + // multiplexer session name or the zellij layout file path. + let hostile = "../evil"; + let tmux = persist_plan( + Harness::Claude, + hostile, + None, + PersistBackend::Tmux, + "/home/u", + ); + // The session-name slot (the `-s ` argument) must contain no + // `/` or `..` — unlike the inner harness argv, which legitimately + // carries the raw session id as a CLI argument to `claude -r`. + let name_arg = tmux + .remote_command + .split_whitespace() + .nth(4) + .unwrap_or_default(); + assert!( + !name_arg.contains('/') && !name_arg.contains(".."), + "tmux session name leaks traversal/slash: {name_arg}" + ); + + let dtach = persist_plan( + Harness::Claude, + hostile, + None, + PersistBackend::Dtach, + "/home/u", + ); + // The socket path is the `-A ` argument, third whitespace + // token; check just that token, not the whole command (whose + // trailing inner argv legitimately carries the raw hostile id). + let socket_arg = dtach + .remote_command + .split_whitespace() + .nth(2) + .unwrap_or_default(); + assert!( + !socket_arg + .trim_start_matches("/tmp/path-dtach-") + .contains('/'), + "dtach socket path leaks a slash beyond the fixed /tmp/ prefix: {socket_arg}" + ); + + let zellij = persist_plan( + Harness::Claude, + hostile, + None, + PersistBackend::Zellij, + "/home/u", + ); + let (layout_path, _) = zellij.extra_file.expect("zellij ships a layout file"); + assert!( + !layout_path + .trim_start_matches("/home/u/.cache/path/zellij-") + .contains('/'), + "zellij layout path leaks a slash beyond the fixed prefix: {layout_path}" + ); + assert!( + !layout_path.contains(".."), + "zellij layout path leaks traversal: {layout_path}" + ); + } + #[test] fn shell_quote_leaves_safe_strings_bare() { assert_eq!(shell_quote("-r"), "-r"); From 0a7c9bf19f3e255fe18778916b74bc321c82e0a1 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Fri, 24 Jul 2026 11:49:42 -0400 Subject: [PATCH 32/39] test(resume): exhaustive PersistBackend::describe() coverage Assert describe() for every variant (Plain/Tmux/Abduco/Dtach/Zellij/ Shpool) and that each blurb leads with the backend's name; length-check against DISPLAY_ORDER so a new backend can't be added without a test. Co-Authored-By: Claude Fable 5 --- crates/path-cli/src/cmd_resume.rs | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 2a50bdfb..817a2dee 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -3125,4 +3125,36 @@ mod tests { ["tmux", "shpool"].iter().map(|s| s.to_string()).collect(); assert_eq!(preferred_backend(&with_tmux), PersistBackend::Tmux); // shpool never preferred over 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"), + (Abduco, "abduco — minimal detach/attach; survives drops"), + (Dtach, "dtach — tiny detach/attach; survives drops"), + (Zellij, "zellij — detachable workspace (layout-launched)"), + ( + Shpool, + "shpool — persistent shell (attach-only; run the command yourself)", + ), + ]; + // 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:?}"); + // Each blurb leads with the backend's own name, lowercased. + let name = format!("{backend:?}").to_lowercase(); + assert!( + backend.describe().starts_with(&name), + "{backend:?} blurb should start with its name" + ); + } + } } From 18a848c587c4537007c4c4e318b4e182fe4cf191 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 25 Jul 2026 14:46:02 -0400 Subject: [PATCH 33/39] test(resume): cover run_remote persist auto-select, plain fallback, and --via et gating Three end-to-end run_remote tests that the per-method unit tests didn't reach: (1) --via et fails before any remote touch (no probe/ship/launch); (2) with no --persist, the remote_which probe auto-selects the preferred backend (tmux over zellij) and wraps the launch; (3) with no backend available, it ships + launches plain (claude -r, no wrapper). Co-Authored-By: Claude Fable 5 --- crates/path-cli/tests/resume.rs | 98 +++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 39eeeceb..aa369a56 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -448,6 +448,104 @@ fn remote_resume_persist_dtach_records_launch_and_ships() { ); } +/// `--via et` (a reserved transport) must fail on the host BEFORE any +/// remote side effect — nothing probed, nothing shipped, nothing +/// launched — so a doomed transport never leaves a half-staged session. +#[test] +fn remote_resume_via_et_errors_before_any_remote_touch() { + 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-via-et"); + 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.via = Transport::Et; + + let rec = RecordingExec::with_available(["tmux"]); + let err = run_with_strategy(args, &rec).unwrap_err(); + + assert!( + err.to_string().contains("et is not yet supported"), + "actual: {err}" + ); + assert!(rec.homes().is_empty(), "must not probe the remote"); + assert!(rec.writes().is_empty(), "must not ship anything"); + assert!(rec.captured().binary.is_empty(), "must not launch"); +} + +/// 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 + ); +} + /// `--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 From 1544201e6b90449caea3ccb5d7acbe915dbac73b Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 25 Jul 2026 15:03:03 -0400 Subject: [PATCH 34/39] fix(resume): canonicalize remote --cwd (symlink-safe project dir) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing against a macOS sshd surfaced a real bug: with `--cwd /tmp/x`, the session shipped to `~/.claude/projects/-tmp-x/` but `claude -r` (which keys on the *physical* cwd) looked in `-private-tmp-x/` (macOS `/tmp` → `/private/tmp`) and reported "No conversation found" — a silent resume failure. Fix: new `ExecStrategy::remote_realpath` (SFTP realpath, with a `cd && pwd -P` fallback for SFTP-less hosts) canonicalizes the launch cwd after creating it; the shipped project dir and the launch `cd` now use that physical path. No --cwd → the already-canonical SFTP home. Verified end-to-end: `path resume --remote --persist tmux -C /tmp/…` now ships to the `-private-tmp-…` dir and `claude -r` resumes the shipped session (replied to a print-mode prompt). Adds a RecordingExec `with_realpath` seam + regression test. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++ crates/path-cli/src/cmd_resume.rs | 93 +++++++++++++++++++++++++++---- crates/path-cli/tests/resume.rs | 42 ++++++++++++++ 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad4d1a8..17c4e395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ All notable changes to the Toolpath workspace are documented here. - 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`. + - **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 diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 817a2dee..52b68bfb 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -639,6 +639,14 @@ pub trait ExecStrategy { /// `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<()>; @@ -753,6 +761,33 @@ impl ExecStrategy for RealExec { }) } + fn remote_realpath(&self, target: &SshTarget, path: &str) -> Result { + self.with_conn(target, |conn| match &conn.sftp { + // SFTP realpath resolves symlinks + relative components to the + // canonical absolute path. + Some(sftp) => { + let real = sftp + .realpath(std::path::Path::new(path)) + .with_context(|| format!("realpath {path} on remote"))?; + Ok(real.to_string_lossy().to_string()) + } + // No SFTP subsystem: `cd && pwd -P` gives the physical + // (symlink-resolved) directory, matching Claude's getcwd. + None => { + let out = exec_channel_capture( + &conn.sess, + &format!("cd {} && pwd -P", shell_single_quote(path)), + ) + .with_context(|| format!("realpath {path} on remote"))?; + let real = out.trim().to_string(); + if real.is_empty() { + anyhow::bail!("remote `pwd -P` 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.sftp { @@ -1171,6 +1206,8 @@ 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 @@ -1178,6 +1215,9 @@ pub struct RecordingExec { 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 { @@ -1198,6 +1238,15 @@ impl RecordingExec { } } + /// 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() } @@ -1212,6 +1261,11 @@ impl RecordingExec { 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() @@ -1246,6 +1300,13 @@ impl ExecStrategy for RecordingExec { 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() @@ -1331,10 +1392,26 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // resume that previously never needed a libssh2 exec channel), and // resolving first avoids shipping the session file only to abort // partway through on a probe error. - let launch_cwd: Option = args - .cwd - .as_ref() - .map(|dir| normalize_remote_cwd(dir, &home)); + // 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 => { @@ -1391,13 +1468,7 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul .with_context(|| format!("shipping session file to {remote}:{dest}"))?; eprintln!("Shipped session {session_id} → {remote}:{dest}"); - // The launch cwd must exist before the interactive `cd` — create it - // over SFTP too, since resuming into a fresh directory is the normal - // case. - if let Some(dir) = launch_cwd.as_deref() { - exec.remote_mkdirs(&target, dir) - .with_context(|| format!("creating launch dir {dir} on {remote}"))?; - } + // (launch cwd was created + canonicalized before shipping, above.) if let Some((path, body)) = &plan.extra_file { if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index aa369a56..10b54e6c 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -546,6 +546,48 @@ fn remote_resume_falls_back_to_plain_when_no_backend_available() { ); } +/// 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 From 8f5e160a877473a23139787af1da32a524a0f17c Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 25 Jul 2026 15:22:42 -0400 Subject: [PATCH 35/39] feat(resume): implement --via mosh transport mosh was a reserved seam; wire it up. launch_invocation gains mosh_invocation: mosh owns the TTY (no ssh -t), a custom SSH port rides mosh's `--ssh` handshake override (its own -p is the UDP range), and the persist-wrapped remote command runs via `sh -c` after `--`. Ship/probe still go over SFTP; only the interactive launch changes client. Verified live against a local mosh-server: the client connects over the box's custom SSH port and runs the remote command (claude on PATH). Unit test pins the argv shape; integration test asserts run_remote launches the mosh client + ships. Docs/CHANGELOG updated (mosh no longer reserved; et still is). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- crates/path-cli/src/cmd_resume.rs | 80 ++++++++++++++++--- crates/path-cli/tests/resume.rs | 47 +++++++++++ .../remote-session-persistence-landscape.md | 2 +- 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c4e395..41616e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to the Toolpath workspace are documented here. alias** for `--persist tmux` — combining both flags is an error. - New `--via ssh|mosh|et` transport axis carries the persist-wrapped launch command; `ssh` is implemented (the prior default behavior), - `mosh`/`et` are reserved seams that error "not yet supported". + `mosh` is implemented (owns the TTY; custom SSH port via `--ssh`, remote command via `sh -c`); `et` is a reserved seam that errors "not yet supported". - Reachability over Tailscale, Cloudflare Tunnel, or a bastion hop needs no new flag — it rides the existing `~/.ssh/config` `Host` alias resolution already documented for `--remote`. diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 52b68bfb..56b40b53 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -109,9 +109,9 @@ //! //! `--via` selects the transport that carries the persist-wrapped //! remote command for the interactive launch ([`launch_invocation`]). -//! `ssh` (the default) is implemented; `mosh` and `et` are reserved -//! seams that currently error with "not yet supported" — the type is -//! wired through so a future transport only needs a new match arm. +//! `ssh` (the default) and `mosh` are implemented; `et` is a reserved +//! seam that errors with "not yet supported" — the type is wired +//! through so a future transport only needs a new match arm. //! //! ## Reachability //! @@ -188,7 +188,7 @@ pub struct ResumeArgs { #[arg(long, value_enum, requires = "remote")] pub persist: Option, - /// Transport for the interactive launch. ssh (default); mosh/et reserved. + /// Transport for the interactive launch. ssh (default) + mosh; et reserved. #[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")] pub via: Transport, } @@ -1357,7 +1357,7 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul Some(_) => anyhow::bail!("remote resume currently supports only --harness claude"), } - // Validate --via before any remote side effects — mosh/et are reserved + // Validate --via before any remote side effects — et is a reserved // seams that always error; fail here rather than after shipping files // and printing persistence notes. match args.via { @@ -1826,8 +1826,8 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { .all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) } -/// Transport protocol for remote resume. v1 implements only SSH; mosh and ET -/// are reserved for future use. +/// Transport protocol for remote resume. ssh + mosh implemented; ET is +/// reserved for future use. #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] pub enum Transport { Ssh, @@ -1836,7 +1836,7 @@ pub enum Transport { } /// Carry the persist-wrapped remote command over the chosen transport. -/// v1 implements only ssh; mosh/et are reserved (spec: Transport axis). +/// ssh + mosh implemented; et is reserved (spec: Transport axis). fn launch_invocation( transport: Transport, remote: &str, @@ -1844,13 +1844,34 @@ fn launch_invocation( ) -> Result<(String, Vec)> { match transport { Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), - Transport::Mosh => { - anyhow::bail!("--via mosh is not yet supported (reserved); use --via ssh") - } + Transport::Mosh => mosh_invocation(remote, remote_cmd), Transport::Et => anyhow::bail!("--via et is not yet supported (reserved); use --via ssh"), } } +/// Build the `mosh` client invocation. Unlike ssh, mosh owns the TTY +/// (so no `-t`) and takes the remote command after `--`; it's run via +/// `sh -c` so compound persist-wrapped commands work. A custom SSH port +/// rides mosh's `--ssh` handshake override (mosh's own `-p` is the UDP +/// port range, not the SSH port). User/host/port come from the parsed +/// URL; a bare alias defers to `~/.ssh/config` via the inner `ssh`. +fn mosh_invocation(remote: &str, remote_cmd: &str) -> Result<(String, Vec)> { + let target = parse_ssh_url(remote)?; + let mut argv = Vec::new(); + if let Some(port) = target.port { + argv.push(format!("--ssh=ssh -p {port}")); + } + argv.push(match &target.user { + Some(user) => format!("{user}@{}", target.host), + None => target.host.clone(), + }); + argv.push("--".to_string()); + argv.push("sh".to_string()); + argv.push("-c".to_string()); + argv.push(remote_cmd.to_string()); + Ok(("mosh".to_string(), argv)) +} + /// Remote session-persistence backend for `--remote` resume. See the /// design spec: three launch mechanisms (direct-wrap, layout-wrap for /// zellij, attach-only for shpool). @@ -3098,9 +3119,42 @@ mod tests { vec!["-t".to_string(), "h".to_string(), "claude -r x".to_string()] ); - let err = launch_invocation(Transport::Mosh, "ssh://h", "claude -r x").unwrap_err(); + // et is still a reserved seam. + let err = launch_invocation(Transport::Et, "ssh://h", "x").unwrap_err(); assert!(err.to_string().contains("not yet supported"), "{err}"); - assert!(launch_invocation(Transport::Et, "ssh://h", "x").is_err()); + } + + #[test] + fn launch_invocation_mosh_builds_client_command() { + // mosh owns the TTY (no `ssh -t`); a custom SSH port rides `--ssh`, + // and the compound remote command runs via `sh -c`. + let (bin, argv) = + launch_invocation(Transport::Mosh, "ssh://dev@h:2222", "cd /x && claude -r y").unwrap(); + assert_eq!(bin, "mosh"); + assert_eq!( + argv, + vec![ + "--ssh=ssh -p 2222".to_string(), + "dev@h".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "cd /x && claude -r y".to_string(), + ] + ); + + // No port → no --ssh override; bare host. + let (_bin, argv) = launch_invocation(Transport::Mosh, "ssh://h", "claude -r y").unwrap(); + assert_eq!( + argv, + vec![ + "h".to_string(), + "--".to_string(), + "sh".to_string(), + "-c".to_string(), + "claude -r y".to_string(), + ] + ); } #[test] diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 10b54e6c..115e3d6d 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -546,6 +546,53 @@ fn remote_resume_falls_back_to_plain_when_no_backend_available() { ); } +/// `--via mosh` ships over SFTP as usual, then launches the harness with +/// the `mosh` client (not `ssh`): mosh owns the TTY and takes the command +/// after `--` via `sh -c`, with a custom SSH port on `--ssh`. +#[test] +fn remote_resume_via_mosh_launches_with_mosh_client() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binaries(&["ssh", "mosh", "claude"]); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:claude-code", "claude-code://resume-via-mosh"); + 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@host:2222".to_string()); + args.via = Transport::Mosh; + args.persist = Some(PersistBackend::Plain); + + let rec = RecordingExec::default(); + run_with_strategy(args, &rec).unwrap(); + + // Session still ships over the SFTP transport. + assert_eq!(rec.writes().len(), 1, "session file should ship"); + // Launch uses the mosh client, not ssh. + let cap = rec.captured(); + assert_eq!( + cap.binary, "mosh", + "should launch via mosh, got {}", + cap.binary + ); + assert!( + cap.args.iter().any(|a| a == "--ssh=ssh -p 2222"), + "custom SSH port should ride --ssh, got {:?}", + cap.args + ); + assert!( + cap.args.iter().any(|a| a == "dev@host") && cap.args.contains(&"--".to_string()), + "should target dev@host after --, got {:?}", + cap.args + ); + assert!( + cap.args.iter().any(|a| a.contains("claude -r")), + "the harness command should ride through, 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 diff --git a/docs/agents/remote-session-persistence-landscape.md b/docs/agents/remote-session-persistence-landscape.md index e1874ac3..e0d8a996 100644 --- a/docs/agents/remote-session-persistence-landscape.md +++ b/docs/agents/remote-session-persistence-landscape.md @@ -79,7 +79,7 @@ Keeps one client↔host link alive; does **not** anchor the session server-side. transport). Researchy; not dependable yet. In `path`: `--via` swaps the launch client binary; the persistence wrapping is -identical. v1 implements only `ssh`; `mosh`/`et` are reserved (seam in place). +identical. `ssh` and `mosh` are implemented; `et` is reserved (seam in place). --- From 0f4e41fc38c53315681f4f9ecc6fcba68cfeb86d Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 25 Jul 2026 16:23:46 -0400 Subject: [PATCH 36/39] =?UTF-8?q?refactor(resume):=20rename=20--via=20?= =?UTF-8?q?=E2=86=92=20--transport;=20ssh=20only,=20nothing=20reserved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the transport decision: --via implied routing, but this flag selects a *carrier protocol* for the interactive byte stream — nothing else. Rename to --transport; the name polices its own boundary (teleport/tailscale aren't transports). - Transport enum reduces to a single `Ssh` variant (default). The flag stays as the extension seam — a future carrier is a variant + match arm — but nothing is reserved: drop the `mosh`/`et` values and their bespoke error strings. Unknown input now gets clap's generic `invalid value 'X' … [possible values: ssh]`. - Reverts the just-added mosh transport (mosh_invocation + tests): transport is low-stakes for an agent TUI — `claude -r` reconstructs the session, and what must survive a disconnect is the *process* (tmux/dtach's job, --persist). Not worth a named reservation. - launch_invocation loses its error arms (single transport); run_remote drops the now-moot --via pre-validation. Field/flag/help/docs updated. Internal design-doc prose about the excluded transports is left for a separate editorial pass. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +- crates/path-cli/src/cmd_resume.rs | 119 ++++++--------------------- crates/path-cli/tests/resume.rs | 78 +----------------- crates/path-cli/tests/support/mod.rs | 2 +- 4 files changed, 30 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41616e3b..a687a186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,10 @@ All notable changes to the Toolpath workspace are documented here. non-TTY invocation auto-picks the best available backend (tmux > zellij > abduco > dtach > plain). `--tmux` is now a **deprecated alias** for `--persist tmux` — combining both flags is an error. - - New `--via ssh|mosh|et` transport axis carries the persist-wrapped - launch command; `ssh` is implemented (the prior default behavior), - `mosh` is implemented (owns the TTY; custom SSH port via `--ssh`, remote command via `sh -c`); `et` is a reserved seam that errors "not yet supported". + - 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`. diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 56b40b53..9aae4ffa 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -105,13 +105,13 @@ //! alias** for `--persist tmux` ([`resolve_persist_flag`]); combining //! both flags is an error. //! -//! ## Transport (`--via ssh|mosh|et`) +//! ## Transport (`--transport ssh`) //! -//! `--via` selects the transport that carries the persist-wrapped -//! remote command for the interactive launch ([`launch_invocation`]). -//! `ssh` (the default) and `mosh` are implemented; `et` is a reserved -//! seam that errors with "not yet supported" — the type is wired -//! through so a future transport only needs a new match arm. +//! `--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 //! @@ -188,9 +188,10 @@ pub struct ResumeArgs { #[arg(long, value_enum, requires = "remote")] pub persist: Option, - /// Transport for the interactive launch. ssh (default) + mosh; et reserved. + /// 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 via: Transport, + pub transport: Transport, } /// Resolve the effective persist backend from `--tmux`/`--persist`, @@ -1357,16 +1358,6 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul Some(_) => anyhow::bail!("remote resume currently supports only --harness claude"), } - // Validate --via before any remote side effects — et is a reserved - // seams that always error; fail here rather than after shipping files - // and printing persistence notes. - match args.via { - Transport::Ssh => {} - other => { - let _ = launch_invocation(other, remote, "")?; - } - } - // 1. Resolve + validate locally so a bad document fails on the host, // not deep inside an SSH session — and project the session fully // in memory: the remote never sees the toolpath document, only the @@ -1485,7 +1476,7 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // 5. Interactive launch of the harness against the shipped session, // with a real TTY — the one step that stays on the real `ssh` // binary (it needs the user's terminal and ssh config). - let (binary, argv) = launch_invocation(args.via, remote, &plan.remote_command)?; + let (binary, argv) = launch_invocation(args.transport, remote, &plan.remote_command)?; let cwd = std::env::current_dir()?; exec_harness(&binary, &argv, &cwd, exec) } @@ -1826,17 +1817,17 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { .all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) } -/// Transport protocol for remote resume. ssh + mosh implemented; ET is -/// reserved for future use. -#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +/// 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, - Mosh, - Et, } /// Carry the persist-wrapped remote command over the chosen transport. -/// ssh + mosh implemented; et is reserved (spec: Transport axis). fn launch_invocation( transport: Transport, remote: &str, @@ -1844,34 +1835,9 @@ fn launch_invocation( ) -> Result<(String, Vec)> { match transport { Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), - Transport::Mosh => mosh_invocation(remote, remote_cmd), - Transport::Et => anyhow::bail!("--via et is not yet supported (reserved); use --via ssh"), } } -/// Build the `mosh` client invocation. Unlike ssh, mosh owns the TTY -/// (so no `-t`) and takes the remote command after `--`; it's run via -/// `sh -c` so compound persist-wrapped commands work. A custom SSH port -/// rides mosh's `--ssh` handshake override (mosh's own `-p` is the UDP -/// port range, not the SSH port). User/host/port come from the parsed -/// URL; a bare alias defers to `~/.ssh/config` via the inner `ssh`. -fn mosh_invocation(remote: &str, remote_cmd: &str) -> Result<(String, Vec)> { - let target = parse_ssh_url(remote)?; - let mut argv = Vec::new(); - if let Some(port) = target.port { - argv.push(format!("--ssh=ssh -p {port}")); - } - argv.push(match &target.user { - Some(user) => format!("{user}@{}", target.host), - None => target.host.clone(), - }); - argv.push("--".to_string()); - argv.push("sh".to_string()); - argv.push("-c".to_string()); - argv.push(remote_cmd.to_string()); - Ok(("mosh".to_string(), argv)) -} - /// Remote session-persistence backend for `--remote` resume. See the /// design spec: three launch mechanisms (direct-wrap, layout-wrap for /// zellij, attach-only for shpool). @@ -2418,7 +2384,7 @@ mod tests { remote: Some("ssh://dev@example.com:2222".to_string()), tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, } } @@ -2575,7 +2541,7 @@ mod tests { remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, }; let recorder = RecordingExec::default(); @@ -2710,7 +2676,7 @@ mod tests { remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, }; a.remote = Some("ssh://h".into()); a.tmux = true; @@ -2746,7 +2712,7 @@ mod tests { remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -2784,7 +2750,7 @@ mod tests { remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -2849,7 +2815,7 @@ mod tests { remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, }; let result = resolve_input(&args); @@ -2881,7 +2847,7 @@ mod tests { remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); @@ -3111,50 +3077,13 @@ mod tests { } #[test] - fn launch_invocation_ssh_and_deferred_transports() { + 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()] ); - - // et is still a reserved seam. - let err = launch_invocation(Transport::Et, "ssh://h", "x").unwrap_err(); - assert!(err.to_string().contains("not yet supported"), "{err}"); - } - - #[test] - fn launch_invocation_mosh_builds_client_command() { - // mosh owns the TTY (no `ssh -t`); a custom SSH port rides `--ssh`, - // and the compound remote command runs via `sh -c`. - let (bin, argv) = - launch_invocation(Transport::Mosh, "ssh://dev@h:2222", "cd /x && claude -r y").unwrap(); - assert_eq!(bin, "mosh"); - assert_eq!( - argv, - vec![ - "--ssh=ssh -p 2222".to_string(), - "dev@h".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "cd /x && claude -r y".to_string(), - ] - ); - - // No port → no --ssh override; bare host. - let (_bin, argv) = launch_invocation(Transport::Mosh, "ssh://h", "claude -r y").unwrap(); - assert_eq!( - argv, - vec![ - "h".to_string(), - "--".to_string(), - "sh".to_string(), - "-c".to_string(), - "claude -r y".to_string(), - ] - ); } #[test] diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 115e3d6d..b9e0ca75 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -270,7 +270,7 @@ fn cache_id_input_loads_and_projects() { remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, }; let recorder = RecordingExec::default(); @@ -448,35 +448,6 @@ fn remote_resume_persist_dtach_records_launch_and_ships() { ); } -/// `--via et` (a reserved transport) must fail on the host BEFORE any -/// remote side effect — nothing probed, nothing shipped, nothing -/// launched — so a doomed transport never leaves a half-staged session. -#[test] -fn remote_resume_via_et_errors_before_any_remote_touch() { - 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-via-et"); - 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.via = Transport::Et; - - let rec = RecordingExec::with_available(["tmux"]); - let err = run_with_strategy(args, &rec).unwrap_err(); - - assert!( - err.to_string().contains("et is not yet supported"), - "actual: {err}" - ); - assert!(rec.homes().is_empty(), "must not probe the remote"); - assert!(rec.writes().is_empty(), "must not ship anything"); - assert!(rec.captured().binary.is_empty(), "must not launch"); -} - /// 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 @@ -546,53 +517,6 @@ fn remote_resume_falls_back_to_plain_when_no_backend_available() { ); } -/// `--via mosh` ships over SFTP as usual, then launches the harness with -/// the `mosh` client (not `ssh`): mosh owns the TTY and takes the command -/// after `--` via `sh -c`, with a custom SSH port on `--ssh`. -#[test] -fn remote_resume_via_mosh_launches_with_mosh_client() { - let _env = env_lock(); - let _home = ScopedHome::new(); - let _path = ScopedPath::with_binaries(&["ssh", "mosh", "claude"]); - let cwd = tempfile::tempdir().unwrap(); - - let path = make_convo_path("agent:claude-code", "claude-code://resume-via-mosh"); - 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@host:2222".to_string()); - args.via = Transport::Mosh; - args.persist = Some(PersistBackend::Plain); - - let rec = RecordingExec::default(); - run_with_strategy(args, &rec).unwrap(); - - // Session still ships over the SFTP transport. - assert_eq!(rec.writes().len(), 1, "session file should ship"); - // Launch uses the mosh client, not ssh. - let cap = rec.captured(); - assert_eq!( - cap.binary, "mosh", - "should launch via mosh, got {}", - cap.binary - ); - assert!( - cap.args.iter().any(|a| a == "--ssh=ssh -p 2222"), - "custom SSH port should ride --ssh, got {:?}", - cap.args - ); - assert!( - cap.args.iter().any(|a| a == "dev@host") && cap.args.contains(&"--".to_string()), - "should target dev@host after --, got {:?}", - cap.args - ); - assert!( - cap.args.iter().any(|a| a.contains("claude -r")), - "the harness command should ride through, 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 diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 3a7f13ed..0d7441a0 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -206,7 +206,7 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs remote: None, tmux: false, persist: None, - via: Transport::Ssh, + transport: Transport::Ssh, } } From ccb0ebdc3dd93f10e5b3df4883de01f690d329ac Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 25 Jul 2026 16:40:24 -0400 Subject: [PATCH 37/39] fix(resume): ssh-CLI ship fallback for proxied/bastion hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libssh2 dials a raw TCP socket and can't traverse ProxyJump/ ProxyCommand, so a host reachable only through a bastion/tunnel/mesh launched fine (the ssh CLI honors config) but silently FAILED at the probe/ship step. Close the gap: - RemoteConn becomes an enum: Libssh2 (SFTP/SCP as before) | Cli. - connect_remote returns Cli when ssh_config declares a ProxyJump/ ProxyCommand for the host, OR when a direct libssh2 dial fails. - Cli mode runs every file op through the ssh binary (pwd, mkdir -p, cat > dest, cd && pwd -P, command -v) — honors full ~/.ssh/config. - ssh_config parser learns has_proxy. Unit tests: proxy detection + ssh-CLI argv shape. CLI ship primitives verified live over the real ssh binary against the sandbox. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +- crates/path-cli/src/cmd_resume.rs | 299 ++++++++++++++++++++++++------ 2 files changed, 249 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a687a186..e7c717fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,13 @@ All notable changes to the Toolpath workspace are documented here. 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`. + 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`) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 9aae4ffa..5a078d31 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -674,12 +674,28 @@ pub struct RealExec { conn: std::sync::Mutex>, } -/// A live authenticated session plus the SFTP channel if the server -/// offers one (`None` ⇒ SCP/exec fallback). -struct RemoteConn { - key: SshTarget, - sess: ssh2::Session, - sftp: Option, +/// 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 { @@ -721,23 +737,27 @@ impl ExecStrategy for RealExec { } fn remote_home(&self, target: &SshTarget) -> Result { - self.with_conn(target, |conn| match &conn.sftp { - Some(sftp) => { + 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()) } - None => { - let out = exec_channel_capture(&conn.sess, "pwd")?; - validate_remote_home(&out) - } + 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.sftp { - Some(sftp) => { + 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(); @@ -754,8 +774,15 @@ impl ExecStrategy for RealExec { } Ok(()) } - None => { - exec_channel_capture(&conn.sess, &format!("mkdir -p {}", shell_single_quote(dir))) + 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(()) } @@ -763,36 +790,40 @@ impl ExecStrategy for RealExec { } fn remote_realpath(&self, target: &SshTarget, path: &str) -> Result { - self.with_conn(target, |conn| match &conn.sftp { - // SFTP realpath resolves symlinks + relative components to the - // canonical absolute path. - Some(sftp) => { - let real = sftp + // `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"))?; - Ok(real.to_string_lossy().to_string()) - } - // No SFTP subsystem: `cd && pwd -P` gives the physical - // (symlink-resolved) directory, matching Claude's getcwd. - None => { - let out = exec_channel_capture( - &conn.sess, - &format!("cd {} && pwd -P", shell_single_quote(path)), - ) - .with_context(|| format!("realpath {path} on remote"))?; - let real = out.trim().to_string(); - if real.is_empty() { - anyhow::bail!("remote `pwd -P` for {path} returned nothing"); - } - Ok(real) + .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.sftp { - Some(sftp) => { + 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}"))?; @@ -800,11 +831,11 @@ impl ExecStrategy for RealExec { .with_context(|| format!("write remote file {path}"))?; Ok(()) } - None => { - // SCP protocol upload — libssh2's scp_send, no external - // binary. - let mut ch = conn - .sess + 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) @@ -815,6 +846,11 @@ impl ExecStrategy for RealExec { 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}")) + } }) } @@ -838,7 +874,10 @@ impl ExecStrategy for RealExec { .collect::>() .join("; "); self.with_conn(target, |conn| { - let out = exec_channel_capture(&conn.sess, &probe)?; + 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()) @@ -858,13 +897,69 @@ impl RealExec { f: impl FnOnce(&RemoteConn) -> Result, ) -> Result { let mut guard = self.conn.lock().unwrap(); - if guard.as_ref().is_none_or(|c| &c.key != target) { + 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`. @@ -933,6 +1028,10 @@ struct SshHostConfig { /// 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 @@ -1000,6 +1099,11 @@ fn parse_ssh_config(content: &str, host: &str, home: &std::path::Path) -> SshHos "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; + } _ => {} } } @@ -1145,25 +1249,55 @@ fn agent_auth_with_key( /// [`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 — the interactive launch goes through the real `ssh` binary -/// with the user's full config. +/// 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}"); - // Bounded connect + per-operation timeouts: a wedged remote should - // fail with context, never hang the resume silently. - let sock = addr + // 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() - .with_context(|| format!("resolve {addr}"))? - .next() - .with_context(|| format!("no address for {addr}"))?; - let tcp = std::net::TcpStream::connect_timeout(&sock, std::time::Duration::from_secs(10)) - .with_context(|| format!("connect to {addr}"))?; + .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 @@ -1192,7 +1326,7 @@ fn connect_remote(target: &SshTarget) -> Result { eprintln!("note: remote has no SFTP subsystem — using SCP fallback"); } - Ok(RemoteConn { + Ok(RemoteConn::Libssh2 { key: target.clone(), sess, sftp, @@ -2109,6 +2243,57 @@ mod tests { 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()] + ); } #[test] From 5378f5b74e8cd11d05cd0424f88abe167cb59002 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 25 Jul 2026 17:27:11 -0400 Subject: [PATCH 38/39] refactor(resume): cut persist backends to tmux + dtach + plain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (state lives in Claude Code's session files, not the terminal), and Claude's Ink TUI repaints itself. That deletes tmux's two distinguishing wins (remain-on-exit post-mortem, server-side repaint), leaving tmux (tested default, ubiquitous, introspectable) and dtach (no daemon, socket-is-the-API, argv-exec) as the only backends that earn their place, plus plain. - Drop Abduco/Zellij/Shpool from PersistBackend + bin/describe/ DISPLAY_ORDER/preferred_backend; delete zellij_plan/shpool_plan. - PersistPlan struct collapses to a plain String (no more extra_file/ post_note plumbing in run_remote — those existed only for zellij/ shpool). - dtach upgraded to `-A -r winch sh -c '…'` (-r winch makes the self-repainting TUI redraw on reattach; was -z). Both shipped backends verified live: tmux (persistence + real claude -r resume) earlier; dtach now (socket lifecycle + argv fidelity — `-r` and the session id arrive as separate args, so the quoting-class bug can't exist). Tests + module docs updated; full path-cli suite + clippy green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 31 ++- crates/path-cli/src/cmd_resume.rs | 380 ++++++------------------------ 2 files changed, 98 insertions(+), 313 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c717fc..47b75e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,27 @@ All notable changes to the Toolpath workspace are documented here. - **`path-cli`** (0.18.0): `path resume --remote` gains `--persist `, generalizing the previous `--tmux`-only detach story into a - picker over six backends: `plain`, `tmux`, `abduco`, `dtach`, `zellij`, - `shpool`. Three launch mechanisms: direct-wrap (`plain`/`tmux`/`abduco`/ - `dtach` — the harness command is wrapped inline), layout-wrap (`zellij` - — a generated KDL layout drives the launch), and attach-only (`shpool` - — a persistent remote shell; resume prints the attach command instead - of launching it directly). The remote is probed for installed backends - (mirroring the harness picker); `--persist X` skips the picker; a - non-TTY invocation auto-picks the best available backend (tmux > - zellij > abduco > dtach > plain). `--tmux` is now a **deprecated - alias** for `--persist tmux` — combining both flags is an error. + 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 diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 5a078d31..de9be048 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -83,22 +83,19 @@ //! ## Session persistence (`--persist `) //! //! `--remote` resumes can survive an SSH disconnect by wrapping the -//! remote launch in a session multiplexer, chosen from six backends -//! ([`PersistBackend`]): `plain` (no wrapping — dies on disconnect, -//! always offered), `tmux`, `abduco`, `dtach`, `zellij`, `shpool`. Each -//! backend maps to one of three launch mechanisms: -//! 1. **Direct-wrap** (`plain`, `tmux`, `abduco`, `dtach`) — the harness -//! command is wrapped inline (e.g. `tmux new-session -A -s path- -//! …`); detach/re-run to re-attach. -//! 2. **Layout-wrap** (`zellij`) — a generated KDL layout drives the -//! launch so the harness starts inside a named zellij session/pane. -//! 3. **Attach-only** (`shpool`) — shpool keeps a persistent remote shell -//! running the harness; resume prints the `shpool attach` command for -//! the user to run rather than launching it directly. +//! 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 > zellij > abduco > -//! dtach > plain); an interactive TTY shows a picker built from +//! 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 @@ -1567,13 +1564,7 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul } } }; - let plan = persist_plan( - Harness::Claude, - &session_id, - launch_cwd.as_deref(), - backend, - &home, - ); + 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 @@ -1595,22 +1586,10 @@ fn run_remote(args: &ResumeArgs, remote: &str, exec: &dyn ExecStrategy) -> Resul // (launch cwd was created + canonicalized before shipping, above.) - if let Some((path, body)) = &plan.extra_file { - if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { - exec.remote_mkdirs(&target, parent) - .with_context(|| format!("creating {parent} on {remote}"))?; - } - exec.remote_write(&target, path, body) - .with_context(|| format!("shipping {path} to {remote}"))?; - } - if let Some(note) = &plan.post_note { - eprintln!("{note}"); - } - // 5. Interactive launch of the harness against the shipped session, // with a real TTY — the one step that stays on the real `ssh` // binary (it needs the user's terminal and ssh config). - let (binary, argv) = launch_invocation(args.transport, remote, &plan.remote_command)?; + let (binary, argv) = launch_invocation(args.transport, remote, &remote_command)?; let cwd = std::env::current_dir()?; exec_harness(&binary, &argv, &cwd, exec) } @@ -1712,15 +1691,8 @@ fn normalize_remote_cwd(cwd: &std::path::Path, home: &str) -> String { /// itself is created over SFTP before launch — this is the only remote /// shell string left, and it's minimal because a `cd` can't happen /// anywhere else. -struct PersistPlan { - remote_command: String, - extra_file: Option<(String, Vec)>, - post_note: Option, -} - -/// Reduce a session id to `[A-Za-z0-9_-]` so it's safe to embed both in a -/// multiplexer session name and in a remote file path (the zellij layout -/// file lives at `/.cache/path/zellij-.kdl`). Any character +/// 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 { @@ -1736,13 +1708,20 @@ fn sanitize_session_name(session_id: &str) -> String { .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, - home: &str, -) -> PersistPlan { +) -> String { let launch: Vec = std::iter::once(harness.name().to_string()) .chain(argv_for(harness, session_id).iter().map(|a| shell_quote(a))) .collect(); @@ -1751,70 +1730,29 @@ fn persist_plan( Some(dir) => format!("cd {} && {inner}", shell_quote(dir)), None => inner, }; - // Detachable session names can't contain `.`/`:`, and this name also - // feeds a remote file path (the zellij layout file), so sanitize down - // to a safe charset rather than just stripping `.`/`:` — a crafted - // session_id with `/` or `..` must not be able to steer the write. let name = format!("path-{}", sanitize_session_name(session_id)); - let mut extra_file = None; - let mut post_note = None; - let remote_command = match backend { + match backend { PersistBackend::Plain => inner, PersistBackend::Tmux => format!( "tmux new-session -A -s {} {}", shell_quote(&name), shell_single_quote(&inner) ), - PersistBackend::Abduco => format!( - "abduco -A {} sh -c {}", - shell_quote(&name), - shell_single_quote(&inner) - ), + // `-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 {} -z sh -c {}", + "dtach -A {} -r winch sh -c {}", shell_quote(&format!( "/tmp/path-dtach-{}", sanitize_session_name(session_id) )), shell_single_quote(&inner) ), - PersistBackend::Zellij => zellij_plan(&name, &inner, home, &mut extra_file), - PersistBackend::Shpool => shpool_plan(&name, &inner, &mut post_note), - }; - PersistPlan { - remote_command, - extra_file, - post_note, } } -fn zellij_plan( - name: &str, - inner: &str, - home: &str, - extra: &mut Option<(String, Vec)>, -) -> String { - let layout_path = format!("{home}/.cache/path/zellij-{name}.kdl"); - // Single-pane layout that runs INNER via the shell. - let kdl = format!( - "layout {{\n pane command=\"sh\" {{\n args \"-c\" {inner:?}\n }}\n}}\n" - ); - *extra = Some((layout_path.clone(), kdl.into_bytes())); - format!( - "zellij --session {} --layout {}", - shell_quote(name), - shell_quote(&layout_path) - ) -} - -fn shpool_plan(name: &str, inner: &str, note: &mut Option) -> String { - *note = Some(format!( - "shpool has no command arg — in the persistent shell, run:\n {inner}" - )); - format!("shpool attach {}", shell_quote(name)) -} - /// Quote for the remote shell only when needed — plain flags, ids, and /// paths stay bare so the echoed recipe reads like something a human /// would type; anything else gets [`shell_single_quote`]. @@ -1922,15 +1860,10 @@ fn persist_candidates(available: &std::collections::BTreeSet) -> Vec dtach, +/// falling back to Plain if neither is installed. fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend { - const PRIORITY: [PersistBackend; 4] = [ - PersistBackend::Tmux, - PersistBackend::Zellij, - PersistBackend::Abduco, - PersistBackend::Dtach, - ]; + const PRIORITY: [PersistBackend; 2] = [PersistBackend::Tmux, PersistBackend::Dtach]; PRIORITY .into_iter() .find(|b| b.bin().is_some_and(|bin| available.contains(bin))) @@ -1972,17 +1905,17 @@ fn launch_invocation( } } -/// Remote session-persistence backend for `--remote` resume. See the -/// design spec: three launch mechanisms (direct-wrap, layout-wrap for -/// zellij, attach-only for shpool). +/// 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, - Abduco, Dtach, - Zellij, - Shpool, } impl PersistBackend { @@ -1991,10 +1924,7 @@ impl PersistBackend { match self { PersistBackend::Plain => None, PersistBackend::Tmux => Some("tmux"), - PersistBackend::Abduco => Some("abduco"), PersistBackend::Dtach => Some("dtach"), - PersistBackend::Zellij => Some("zellij"), - PersistBackend::Shpool => Some("shpool"), } } @@ -2002,22 +1932,14 @@ impl PersistBackend { match self { PersistBackend::Plain => "plain — no persistence; dies on disconnect", PersistBackend::Tmux => "tmux — detachable; survives drops, reattachable", - PersistBackend::Abduco => "abduco — minimal detach/attach; survives drops", - PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops", - PersistBackend::Zellij => "zellij — detachable workspace (layout-launched)", - PersistBackend::Shpool => { - "shpool — persistent shell (attach-only; run the command yourself)" - } + PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops (-r winch redraw)", } } - /// Fixed picker display order. - const DISPLAY_ORDER: [PersistBackend; 6] = [ + /// Fixed picker display / preference order (tmux > dtach > plain). + const DISPLAY_ORDER: [PersistBackend; 3] = [ PersistBackend::Tmux, - PersistBackend::Zellij, - PersistBackend::Abduco, PersistBackend::Dtach, - PersistBackend::Shpool, PersistBackend::Plain, ]; } @@ -2369,178 +2291,62 @@ mod tests { } #[test] - fn remote_launch_command_derives_from_harness_table() { - // Binary + argv come from the same per-harness table the local - // resume uses; safe args stay bare so the recipe reads cleanly. + 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, - "sess-1", - None, - PersistBackend::Plain, - "/home/u" - ) - .remote_command, + persist_plan(Harness::Claude, id, None, PersistBackend::Plain), "claude -r sess-1" ); - // Directory creation happens over SFTP before launch — the shell - // string stays minimal: just the cd and the harness. assert_eq!( persist_plan( Harness::Claude, - "sess-1", + id, Some("/srv/work"), - PersistBackend::Plain, - "/home/u" - ) - .remote_command, + PersistBackend::Plain + ), "cd /srv/work && claude -r sess-1" ); - } - - #[test] - fn remote_launch_command_tmux_wraps_for_detachable_sessions() { - // --tmux: the whole launch runs inside a named tmux session so - // it survives SSH disconnects; -A re-attaches on a second run. + // tmux: attach-or-create named session wrapping the command. assert_eq!( - persist_plan( - Harness::Claude, - "sess-1", - None, - PersistBackend::Tmux, - "/home/u" - ) - .remote_command, + 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, - "sess-1", - Some("/srv/work"), - PersistBackend::Tmux, - "/home/u" - ) - .remote_command, - "tmux new-session -A -s path-sess-1 'cd /srv/work && claude -r sess-1'" - ); - } - - #[test] - fn persist_plan_direct_wrap_backends() { - let id = "sess-1"; - let plain = persist_plan(Harness::Claude, id, None, PersistBackend::Plain, "/home/u"); - assert_eq!(plain.remote_command, "claude -r sess-1"); - assert!(plain.extra_file.is_none() && plain.post_note.is_none()); - - let tmux = persist_plan( - Harness::Claude, - id, - Some("/srv/w"), - PersistBackend::Tmux, - "/home/u", - ); - assert_eq!( - tmux.remote_command, + 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'" ); - - let abduco = persist_plan(Harness::Claude, id, None, PersistBackend::Abduco, "/home/u"); + // dtach: attach-or-create on a socket, `-r winch` so the TUI redraws. assert_eq!( - abduco.remote_command, - "abduco -A path-sess-1 sh -c 'claude -r sess-1'" - ); - - let dtach = persist_plan(Harness::Claude, id, None, PersistBackend::Dtach, "/home/u"); - assert_eq!( - dtach.remote_command, - "dtach -A /tmp/path-dtach-sess-1 -z sh -c 'claude -r sess-1'" - ); - } - - #[test] - fn persist_plan_shpool_attach_only_with_note() { - let p = persist_plan( - Harness::Claude, - "sess-1", - Some("/srv/w"), - PersistBackend::Shpool, - "/home/u", - ); - assert_eq!(p.remote_command, "shpool attach path-sess-1"); - let note = p.post_note.expect("shpool note"); - assert!( - note.contains("cd /srv/w && claude -r sess-1"), - "note: {note}" + persist_plan(Harness::Claude, id, None, PersistBackend::Dtach), + "dtach -A /tmp/path-dtach-sess-1 -r winch sh -c 'claude -r sess-1'" ); - assert!(p.extra_file.is_none()); } #[test] fn persist_plan_sanitizes_session_name() { - // A hostile session id must not leak `/` or `..` into the - // multiplexer session name or the zellij layout file path. + // 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, - "/home/u", - ); - // The session-name slot (the `-s ` argument) must contain no - // `/` or `..` — unlike the inner harness argv, which legitimately - // carries the raw session id as a CLI argument to `claude -r`. - let name_arg = tmux - .remote_command - .split_whitespace() - .nth(4) - .unwrap_or_default(); + 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}" ); - let dtach = persist_plan( - Harness::Claude, - hostile, - None, - PersistBackend::Dtach, - "/home/u", - ); - // The socket path is the `-A ` argument, third whitespace - // token; check just that token, not the whole command (whose - // trailing inner argv legitimately carries the raw hostile id). - let socket_arg = dtach - .remote_command - .split_whitespace() - .nth(2) - .unwrap_or_default(); + 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}" ); - - let zellij = persist_plan( - Harness::Claude, - hostile, - None, - PersistBackend::Zellij, - "/home/u", - ); - let (layout_path, _) = zellij.extra_file.expect("zellij ships a layout file"); - assert!( - !layout_path - .trim_start_matches("/home/u/.cache/path/zellij-") - .contains('/'), - "zellij layout path leaks a slash beyond the fixed prefix: {layout_path}" - ); - assert!( - !layout_path.contains(".."), - "zellij layout path leaks traversal: {layout_path}" - ); } #[test] @@ -3293,36 +3099,13 @@ mod tests { fn persist_backend_bin_and_order() { assert_eq!(PersistBackend::Plain.bin(), None); assert_eq!(PersistBackend::Tmux.bin(), Some("tmux")); - assert_eq!(PersistBackend::Shpool.bin(), Some("shpool")); - // Display order is stable and complete. - assert_eq!(PersistBackend::DISPLAY_ORDER.len(), 6); + assert_eq!(PersistBackend::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 persist_plan_zellij_ships_layout() { - let p = persist_plan( - Harness::Claude, - "sess-1", - Some("/srv/w"), - PersistBackend::Zellij, - "/home/u", - ); - assert_eq!( - p.remote_command, - "zellij --session path-sess-1 --layout /home/u/.cache/path/zellij-path-sess-1.kdl" - ); - let (path, body) = p.extra_file.expect("layout shipped"); - assert_eq!(path, "/home/u/.cache/path/zellij-path-sess-1.kdl"); - let body = String::from_utf8(body).unwrap(); - assert!( - body.contains("cd /srv/w && claude -r sess-1"), - "body: {body}" - ); - assert!(body.contains("pane"), "must be a KDL layout: {body}"); - } - #[test] fn recording_exec_remote_which_returns_canned_set() { let rec = RecordingExec::with_available(["tmux", "dtach"]); @@ -3343,26 +3126,21 @@ mod tests { #[test] fn persist_candidates_and_preference() { use std::collections::BTreeSet; - let avail: BTreeSet = ["dtach", "zellij"].iter().map(|s| s.to_string()).collect(); - let cands = persist_candidates(&avail); - // DISPLAY_ORDER filtered to available + always Plain, in order. + // Only dtach installed → [dtach, plain]; dtach preferred. + let avail: BTreeSet = ["dtach"].iter().map(|s| s.to_string()).collect(); assert_eq!( - cands, - vec![ - PersistBackend::Zellij, - PersistBackend::Dtach, - PersistBackend::Plain - ] + persist_candidates(&avail), + vec![PersistBackend::Dtach, PersistBackend::Plain] ); - assert_eq!(preferred_backend(&avail), PersistBackend::Zellij); // tmux absent -> zellij + 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); - let with_tmux: BTreeSet = - ["tmux", "shpool"].iter().map(|s| s.to_string()).collect(); - assert_eq!(preferred_backend(&with_tmux), PersistBackend::Tmux); // shpool never preferred over tmux + // 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] @@ -3371,12 +3149,9 @@ mod tests { let expected = [ (Plain, "plain — no persistence; dies on disconnect"), (Tmux, "tmux — detachable; survives drops, reattachable"), - (Abduco, "abduco — minimal detach/attach; survives drops"), - (Dtach, "dtach — tiny detach/attach; survives drops"), - (Zellij, "zellij — detachable workspace (layout-launched)"), ( - Shpool, - "shpool — persistent shell (attach-only; run the command yourself)", + Dtach, + "dtach — tiny detach/attach; survives drops (-r winch redraw)", ), ]; // DISPLAY_ORDER holds every variant, so matching its length keeps @@ -3388,7 +3163,6 @@ mod tests { ); for (backend, text) in expected { assert_eq!(backend.describe(), text, "describe() for {backend:?}"); - // Each blurb leads with the backend's own name, lowercased. let name = format!("{backend:?}").to_lowercase(); assert!( backend.describe().starts_with(&name), From 4a1c806138e8a8f2192f8d324b52de73741abed4 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 25 Jul 2026 17:45:01 -0400 Subject: [PATCH 39/39] docs(resume): drop design-exploration prose; trim exe.dev runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the transport/backend decisions, the repo shouldn't carry the non-goals/exploration prose (the excluded tools live in the decision record + CHANGELOG + git history, not the tree): - Remove remote-session-persistence-landscape.md (a survey of tools we evaluated and mostly rejected), the six-backend picker design spec, and its plan doc — all superseded by the shipped tmux+dtach+plain / ssh-only design documented in the code + CHANGELOG. - Trim remote-resume-targets.md to the verified exe.dev runbook: fix the stale 'path needed on the remote' claim (v3 ships the session — remote needs only sshd + claude), drop the design-spec pointer, note the proxied-host ssh-CLI fallback, and cut the speculative Sprite section. Co-Authored-By: Claude Fable 5 --- docs/agents/remote-resume-targets.md | 92 +-- .../remote-session-persistence-landscape.md | 144 ---- .../2026-07-23-remote-persistence-picker.md | 772 ------------------ ...-07-23-remote-persistence-picker-design.md | 210 ----- 4 files changed, 15 insertions(+), 1203 deletions(-) delete mode 100644 docs/agents/remote-session-persistence-landscape.md delete mode 100644 docs/superpowers/plans/2026-07-23-remote-persistence-picker.md delete mode 100644 docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md diff --git a/docs/agents/remote-resume-targets.md b/docs/agents/remote-resume-targets.md index 7df81843..acfe235d 100644 --- a/docs/agents/remote-resume-targets.md +++ b/docs/agents/remote-resume-targets.md @@ -1,13 +1,13 @@ -# Remote resume targets: exe.dev and Sprite +# 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) for the -preflight/ship half — so a target must be reachable as a plain `ssh` host with -`path` **and** `claude` installed. +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 also: `crates/path-cli/src/cmd_resume.rs` module docs (the remote protocol) -and the design spec `docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`. +See `crates/path-cli/src/cmd_resume.rs` module docs for the full remote protocol. --- @@ -28,17 +28,14 @@ straight into the remote-resume contract. This is the reference setup. StrictHostKeyChecking accept-new ``` -**Provision + prepare a VM** +**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 - -# install path on the box: -ssh pathremote.exe.xyz 'curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal' -ssh pathremote.exe.xyz 'sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential pkg-config libssl-dev cmake' -tar czf - --exclude=target --exclude=.git . | ssh pathremote.exe.xyz 'rm -rf ~/toolpath && mkdir -p ~/toolpath && tar xzf - -C ~/toolpath' -ssh pathremote.exe.xyz 'cd ~/toolpath && source ~/.cargo/env && cargo build --release -p path-cli' # ~3m on 2 CPUs -ssh pathremote.exe.xyz 'sudo cp ~/toolpath/target/release/path /usr/local/bin/path' +``` +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): @@ -53,67 +50,8 @@ path resume --remote ssh://pathremote.exe.xyz --harness claude ``` **Housekeeping:** persistent VM = billing. `ssh exe.dev rm pathremote` to delete; -`ssh exe.dev ls --json` to list. Rebuild + recopy `path` if the branch's -remote-resume code changes materially. - ---- - -## Sprite (Fly.io) — 📋 documented, not yet stood up - -Sprite is authed (org `robert-delanghe`, card on file, token minted) but **does -not fit the SSH contract out of the box**: it exposes `sprite console` / -`sprite exec` / `sprite proxy`, not a raw `ssh` endpoint. The lift is to give it -one. - -### The endpoint decision (do option A) - -- **A — sshd inside + `sprite proxy` → `localhost:`** *(recommended)*. - Dialing `localhost:` is a **raw TCP socket**, which libssh2 (the - preflight/ship half) handles — so both ship and launch work. -- **B — `ProxyCommand` shim over `sprite exec`**. Hits a known limitation: - **libssh2 does not honor `ProxyCommand`**, so the interactive launch would work - but the file-ship step fails. Do not use until the ssh-CLI ship fallback exists - (tracked as an open question in the design spec). - -### Steps (option A) - -1. **Create a sprite** - ```bash - sprite create -o robert-delanghe pathremote - ``` -2. **Install + start sshd inside it** (via `sprite console` / `sprite exec`) - ```bash - sprite exec -- sudo apt-get update -qq - sprite exec -- sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server - sprite exec -- sudo sed -i 's/#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config - sprite exec -- 'mkdir -p ~/.ssh && printf "%s\n" "$(cat ~/.ssh/id_ed25519_signing.pub)" >> ~/.ssh/authorized_keys' # push your pubkey - sprite exec -- sudo service ssh start - ``` -3. **Forward the SSH port to localhost** - ```bash - sprite proxy 2222 # maps a local port to the sprite's :22 (confirm exact mapping syntax with `sprite proxy --help`) - ``` -4. **Install `path` + `claude`** — same recipe as exe.dev, but over the tunnel - (`ssh -p 2222 user@localhost …`). Then OAuth `claude` in a real terminal: - ```bash - ssh -t -p 2222 user@localhost claude - ``` -5. **Verify** - ```bash - path resume --remote ssh://user@localhost:2222 --harness claude - ``` - -### Open items for Sprite -- Confirm `sprite proxy` direction/port-mapping syntax (`sprite proxy --help`). -- The proxy tunnel must stay up for the duration of the resume (ship + launch). -- Alternative to sshd: if a future `path` gains a `sprite exec`-based transport - (a `--via sprite` seam), Sprite could skip sshd entirely — not planned. - ---- - -## Quick reference +`ssh exe.dev ls --json` to list. -| Target | SSH fit | `path` | `claude` | Status | -|---|---|---|---|---| -| exe.dev `pathremote.exe.xyz` | native | ✅ built (this branch) | ✅ OAuth'd | verified working | -| Sprite | needs sshd + `sprite proxy` | ⬜ | ⬜ | documented, not stood up | +**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/docs/agents/remote-session-persistence-landscape.md b/docs/agents/remote-session-persistence-landscape.md deleted file mode 100644 index e0d8a996..00000000 --- a/docs/agents/remote-session-persistence-landscape.md +++ /dev/null @@ -1,144 +0,0 @@ -# Remote session persistence & transport: the landscape - -Reference for `path resume --remote`. Captures the tool landscape behind the -`--persist` (session-survival) and `--via` (connection-survival) axes, and why -reachability is delegated to `~/.ssh/config`. Companion to the design spec -(`docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`) and the -targets runbook (`docs/agents/remote-resume-targets.md`). - -## The four independent layers - -Remote resume touches four *separate* concerns that **stack rather than compete**. -The classic mistake is asking one tool to cover two layers (expecting tmux to -solve roaming, or mosh to solve NAT). Keep them separate: - -| Layer | Concern | In `path` | -|---|---|---| -| **Reachability** | Can I connect at all? (NAT, mesh, broker) | `~/.ssh/config` — not a flag | -| **Connection-survival** | Does the link survive roaming/sleep/IP change? | `--via ssh\|mosh\|et` | -| **Session-survival** | Does work survive a client death / reattach elsewhere? | `--persist ` | -| **Workspace** | Panes / tabs / layout | a `--persist` backend (zellij) | - ---- - -## Layer 2 — Session survival (`--persist`) - -Anchors the session **server-side** so the harness keeps running under a daemon -even if the client dies, the laptop reboots, or you reattach from another machine. - -### Command-wrapping (clean fit — hand them the command) -- **tmux** — the incumbent. `tmux new-session -A -s ''`; `-A` - attaches-or-creates so re-running reattaches. Full multiplexer (panes, tabs, - status bar, copy-mode) that happens to persist. -- **abduco** — minimal detach/attach only (from the dvtm author; splitting is - left to dvtm). `abduco -A `. Mature, tiny; no output-replay on - reattach, rougher resize handling. -- **dtach** — the ~20-year-old ancestor; does even less (no session listing, you - manage socket paths). `dtach -A `. Works, but little reason over - abduco/shpool today. - -### Layout-wrapping -- **zellij** — the modern Rust "tmux designed this decade": discoverable - keybindings (status bar shows modes), floating/stacked panes, **resurrection** - (serializes sessions to disk → survives a reboot, re-runs pane commands on - attach after confirmation), WASM plugins with a capability/permission model, - KDL layouts as declarative workspace-as-data. No "new named session running - command X" one-liner — the supported path is a **KDL layout** with the command - in a pane, launched `zellij --session --layout `. - -### Attach-only (persistence, no multiplexer) -- **shpool** (Google) — "shell pool": a per-user daemon holds PTYs; `shpool - attach ` reconnects or creates. Persistence *only* — no panes/tabs/status - bar/prefix; your terminal emulator's native scrollback keeps working (a real - ergonomic win). One client per session (reattaching steals from a dead - connection). **No command arg** — attach always starts the configured shell, - so it can't be handed a one-shot command; needs `loginctl enable-linger` to - survive full logout. Best paired with a terminal/WM that owns splitting. - -### Restore-after-reboot -Only **zellij** reconstructs layout + pane commands declaratively. tmux needs -`resurrect`/`continuum` bolted on. Nothing truly restores process state — it's -all "re-run the commands" reconstruction. - ---- - -## Layer 1.5 — Connection survival (`--via`) - -Keeps one client↔host link alive; does **not** anchor the session server-side. - -- **mosh** — state-sync protocol (SSP) over UDP: client and server sync a - terminal-screen state object, so it survives IP changes / sleep / roaming. - No server-side session daemon beyond its own process, no attach-from-elsewhere, - **no scrollback** (its biggest wart). SSH for the initial handshake, then its - own UDP port. `mosh host -- ` (mosh owns the TTY, so no `ssh -t`). -- **Eternal Terminal (et)** — mosh's idea over **TCP** with native scrollback - intact and transparent auto-reconnect. SSH handshake, then its own port. - Knocks: needs its own daemon + open port; development has slowed. -- **SSH3** (misnomer, not an official successor) — SSH semantics over HTTP/3/QUIC; - QUIC gives connection migration natively (mosh's property, standardized at the - transport). Researchy; not dependable yet. - -In `path`: `--via` swaps the launch client binary; the persistence wrapping is -identical. `ssh` and `mosh` are implemented; `et` is reserved (seam in place). - ---- - -## Layer 1 — Reachability (delegated to `~/.ssh/config`) - -These dissolve the *reachability* problem, not the reconnect problem. **None are -a `path` flag** — they're how the host resolves/connects, expressed as a `Host` -block (`ProxyCommand`, `ProxyJump`, or a mesh hostname). Because `--remote` -accepts a `~/.ssh/config` alias, resume rides all of them for free. - -**Mesh / overlay (no public inbound ports):** -- **Tailscale SSH** — WireGuard mesh where `tailscaled` terminates SSH; auth via - your IdP, no keys to manage, no open ports. WireGuard endpoint migration keeps - the *tunnel* alive across IP changes (a plain TCP session inside can still - notice long outages — run mosh/shpool *over* tailscale, not instead). -- **WireGuard / Nebula / NetBird** — same idea, less product around it. - -**Rendezvous / brokered (outbound-only from the host):** -- **Cloudflare Tunnel + `cloudflared access ssh`** — outbound tunnel, SSH brokered - through Cloudflare's edge, optional access policies. -- **upterm / tmate** — reverse-tunnel a session through a relay for sharing / - reaching a NATed box (tmate is literally a tmux fork with a rendezvous server). -- **Teleport / Boundary** — enterprise: certificate-based, audited, - session-recorded access brokers. Heavy, but the only family where the transport - itself has an attribution story (every session tied to a signed identity cert, - recorded, policy-checked before the PTY is granted). - -**Browser-as-client:** -- **ttyd / gotty / sshx** — expose a terminal over WebSocket/HTTP (sshx adds - multiplayer + E2E encryption). "Any device, zero client install"; run behind one - of the mesh/broker layers, not raw. - -### Known limitation -libssh2 (the probe/ship half of `--remote`) **does not honor `ProxyCommand`/ -`ProxyJump`** — it dials a raw TCP socket. A host reachable *only* through a -broker/jump (Cloudflare Tunnel, bastion-only) will launch fine (the `ssh` CLI -honors the config) but **fail at the ship step**. Candidate mitigation: fall back -to shipping over the `ssh` CLI (`ssh host 'cat > dest'` / scp) when the libssh2 -dial fails and a `ProxyCommand` is configured. Tracked as an open question. - ---- - -## Composition guidance - -The layers stack. Pick per concern: - -- **Reachability:** ssh-config alias over Tailscale (identity-attributed) or a - broker; plain host if directly routable. -- **Connection survival:** mosh/et if you roam; skip if the link is stable. -- **Session survival:** tmux (ubiquitous), zellij (workspace + resurrection), - shpool (minimal, terminal-native scrollback), abduco/dtach (tiny). - -For agent runs in a claude-box-style setup, a strong pairing is **Tailscale SSH + -shpool**: identity-attributed transport (auth logged against a signed identity, -not a static `authorized_keys`) with server-side session persistence — versus -mosh, which adds a second auth path and a long-lived UDP daemon to the attack -surface. When you want the workspace layer too, that's **zellij** instead of -shpool. - -The niche bifurcates cleanly and there's no middle tool: transport persistence -(mosh, et), session persistence (shpool, abduco), workspace persistence (zellij, -tmux). Reachability is its own axis on top. diff --git a/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md b/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md deleted file mode 100644 index f2e9b404..00000000 --- a/docs/superpowers/plans/2026-07-23-remote-persistence-picker.md +++ /dev/null @@ -1,772 +0,0 @@ -# Remote Persistence Picker Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let `path resume --remote` choose a remote session-persistence backend (plain/tmux/abduco/dtach/zellij/shpool) via a picker mirroring the harness picker, plus a reserved `--via ssh|mosh|et` transport axis. - -**Architecture:** All work is in `crates/path-cli/src/cmd_resume.rs`. A `PersistBackend` enum + pure `persist_plan()` produce a `PersistPlan { remote_command, extra_file, post_note }`; a `Transport` enum + `launch_invocation()` seam carries it. A one-shot `remote_which` probe over the existing libssh2 `ExecStrategy` feeds candidate assembly + pre-selection, which the picker resolves. `run_remote` is rewired to: probe → pick backend → build plan → ship (+ extra_file) → note → launch via transport. - -**Tech Stack:** Rust (edition 2024), clap `ValueEnum`, ssh2 (libssh2), existing skim/fzf picker in `crates/path-cli/src/skim_picker.rs`. - -## Global Constraints - -- Package `path-cli`; no new dependencies. -- Session name is `format!("path-{}", session_id.replace(['.', ':'], "-"))` — reuse verbatim (tmux/abduco/dtach/zellij/shpool all key on it). -- `INNER` = existing inner launch string built by `remote_launch_command`'s head: `[cd && ] `, quoted via `shell_quote`/`shell_single_quote`. -- Remote command strings must be valid remote-shell syntax (they're passed as one argv element to `ssh` and re-split remotely). Reuse `shell_single_quote` for nesting. -- Backend display order: `tmux, zellij, abduco, dtach, shpool, plain`. Pre-selection priority: `tmux > zellij > abduco > dtach > plain` (shpool never auto-preferred). -- `--via` v1 implements only `ssh`; `mosh`/`et` return a clear "not yet supported" error. -- `--tmux` becomes a deprecated alias for `--persist tmux`; error if combined with `--persist`. -- TDD: failing test → run (fail) → implement → run (pass) → commit, per task. -- Reference: design spec `docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md`. - ---- - -### Task 1: `PersistBackend` enum + clap/describe - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` (near the `Harness` enum) -- Test: same file `#[cfg(test)] mod tests` - -**Interfaces:** -- Produces: `enum PersistBackend { Plain, Tmux, Abduco, Dtach, Zellij, Shpool }`; `PersistBackend::bin(&self) -> Option<&'static str>`; `PersistBackend::describe(&self) -> &'static str`; `PersistBackend::DISPLAY_ORDER: [PersistBackend; 6]`; derives `clap::ValueEnum`, `Copy`, `Clone`, `PartialEq`, `Eq`, `Debug`. - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn persist_backend_bin_and_order() { - assert_eq!(PersistBackend::Plain.bin(), None); - assert_eq!(PersistBackend::Tmux.bin(), Some("tmux")); - assert_eq!(PersistBackend::Shpool.bin(), Some("shpool")); - // Display order is stable and complete. - assert_eq!(PersistBackend::DISPLAY_ORDER.len(), 6); - assert_eq!(PersistBackend::DISPLAY_ORDER[0], PersistBackend::Tmux); - assert!(PersistBackend::Tmux.describe().contains("detach")); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test -p path-cli --lib persist_backend_bin_and_order` -Expected: FAIL — `PersistBackend` not found. - -- [ ] **Step 3: Write minimal implementation** -```rust -/// Remote session-persistence backend for `--remote` resume. See the -/// design spec: three launch mechanisms (direct-wrap, layout-wrap for -/// zellij, attach-only for shpool). -#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] -pub enum PersistBackend { - Plain, - Tmux, - Abduco, - Dtach, - Zellij, - Shpool, -} - -impl PersistBackend { - /// Probe target on the remote (`command -v `); `Plain` has none. - fn bin(&self) -> Option<&'static str> { - match self { - PersistBackend::Plain => None, - PersistBackend::Tmux => Some("tmux"), - PersistBackend::Abduco => Some("abduco"), - PersistBackend::Dtach => Some("dtach"), - PersistBackend::Zellij => Some("zellij"), - PersistBackend::Shpool => Some("shpool"), - } - } - - fn describe(&self) -> &'static str { - match self { - PersistBackend::Plain => "plain — no persistence; dies on disconnect", - PersistBackend::Tmux => "tmux — detachable; survives drops, reattachable", - PersistBackend::Abduco => "abduco — minimal detach/attach; survives drops", - PersistBackend::Dtach => "dtach — tiny detach/attach; survives drops", - PersistBackend::Zellij => "zellij — detachable workspace (layout-launched)", - PersistBackend::Shpool => "shpool — persistent shell (attach-only; run the command yourself)", - } - } - - /// Fixed picker display order. - const DISPLAY_ORDER: [PersistBackend; 6] = [ - PersistBackend::Tmux, - PersistBackend::Zellij, - PersistBackend::Abduco, - PersistBackend::Dtach, - PersistBackend::Shpool, - PersistBackend::Plain, - ]; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cargo test -p path-cli --lib persist_backend_bin_and_order` -Expected: PASS. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): PersistBackend enum for remote persistence backends" -``` - ---- - -### Task 2: `PersistPlan` + `persist_plan()` for direct-wrap backends - -Replaces `remote_launch_command`. Covers `plain/tmux/abduco/dtach`; zellij/shpool land in Tasks 3–4 (return `Plain`-shaped output until then is NOT acceptable — implement their arms as `todo!()`-free explicit branches that Tasks 3/4 fill; here we route them through the direct path with a placeholder that Task 3/4 replace, so keep them out of this task's tests). - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` (`remote_launch_command` → `persist_plan`; update its 2 call sites and the `tmux:`-bool test constructors) -- Test: same file - -**Interfaces:** -- Consumes: `remote_launch_command`'s existing INNER-building head (harness/argv/cwd/quoting). -- Produces: - ```rust - struct PersistPlan { - remote_command: String, - extra_file: Option<(String, Vec)>, // (remote path, contents) — zellij layout - post_note: Option, // shpool guidance - } - fn persist_plan(harness: Harness, session_id: &str, cwd: Option<&str>, backend: PersistBackend, home: &str) -> PersistPlan - ``` - `home` is the resolved remote home (for zellij layout path in Task 3). - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn persist_plan_direct_wrap_backends() { - let id = "sess-1"; - let plain = persist_plan(Harness::Claude, id, None, PersistBackend::Plain, "/home/u"); - assert_eq!(plain.remote_command, "claude -r sess-1"); - assert!(plain.extra_file.is_none() && plain.post_note.is_none()); - - let tmux = persist_plan(Harness::Claude, id, Some("/srv/w"), PersistBackend::Tmux, "/home/u"); - assert_eq!( - tmux.remote_command, - "tmux new-session -A -s path-sess-1 'cd /srv/w && claude -r sess-1'" - ); - - let abduco = persist_plan(Harness::Claude, id, None, PersistBackend::Abduco, "/home/u"); - assert_eq!(abduco.remote_command, "abduco -A path-sess-1 sh -c 'claude -r sess-1'"); - - let dtach = persist_plan(Harness::Claude, id, None, PersistBackend::Dtach, "/home/u"); - assert_eq!( - dtach.remote_command, - "dtach -A /tmp/path-dtach-sess-1 -z sh -c 'claude -r sess-1'" - ); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test -p path-cli --lib persist_plan_direct_wrap_backends` -Expected: FAIL — `persist_plan`/`PersistPlan` not found. - -- [ ] **Step 3: Write minimal implementation** - -Add the struct + function; keep the INNER-building head from `remote_launch_command`. (zellij/shpool arms delegate to a `TODO(task-3/4)` explicit branch — implement as shown so it compiles and the direct arms pass; Tasks 3–4 replace the marked arms.) -```rust -struct PersistPlan { - remote_command: String, - extra_file: Option<(String, Vec)>, - post_note: Option, -} - -fn persist_plan( - harness: Harness, - session_id: &str, - cwd: Option<&str>, - backend: PersistBackend, - home: &str, -) -> PersistPlan { - // INNER — identical to the old remote_launch_command head. - let launch: Vec = std::iter::once(harness.name().to_string()) - .chain(argv_for(harness, session_id).iter().map(|a| shell_quote(a))) - .collect(); - let inner = launch.join(" "); - let inner = match cwd { - Some(dir) => format!("cd {} && {inner}", shell_quote(dir)), - None => inner, - }; - let name = format!("path-{}", session_id.replace(['.', ':'], "-")); - - let mut extra_file = None; - let mut post_note = None; - let remote_command = match backend { - PersistBackend::Plain => inner, - PersistBackend::Tmux => format!( - "tmux new-session -A -s {} {}", - shell_quote(&name), - shell_single_quote(&inner) - ), - PersistBackend::Abduco => format!( - "abduco -A {} sh -c {}", - shell_quote(&name), - shell_single_quote(&inner) - ), - PersistBackend::Dtach => format!( - "dtach -A {} -z sh -c {}", - shell_quote(&format!("/tmp/path-dtach-{}", session_id.replace(['.', ':'], "-"))), - shell_single_quote(&inner) - ), - PersistBackend::Zellij => zellij_plan(&name, &inner, home, &mut extra_file), - PersistBackend::Shpool => shpool_plan(&name, &inner, &mut post_note), - }; - PersistPlan { remote_command, extra_file, post_note } -} -``` -Add temporary stubs so it compiles (Tasks 3–4 replace them): -```rust -fn zellij_plan(name: &str, _inner: &str, _home: &str, _extra: &mut Option<(String, Vec)>) -> String { - // TODO(task-3): layout-wrap. Temporary: attach/create by name. - format!("zellij attach --create {}", shell_quote(name)) -} -fn shpool_plan(name: &str, _inner: &str, _note: &mut Option) -> String { - // TODO(task-4): attach-only + post_note. - format!("shpool attach {}", shell_quote(name)) -} -``` -Then replace the two `remote_launch_command(...)` call sites in `run_remote` with `persist_plan(Harness::Claude, &session_id, launch_cwd.as_deref(), backend, &home)` — but `backend` doesn't exist until Task 8; for now pass `PersistBackend::Tmux` if `args.tmux` else `PersistBackend::Plain`, and use `.remote_command`. Delete `remote_launch_command` and update the two `remote_launch_command_*` tests to call `persist_plan(...).remote_command`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test -p path-cli --lib persist_plan_direct_wrap_backends persist && cargo test -p path-cli resume` -Expected: PASS (all resume tests green). - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): persist_plan + PersistPlan replacing remote_launch_command (direct-wrap backends)" -``` - ---- - -### Task 3: zellij layout-wrap - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`zellij_plan`); Test: same file. - -**Interfaces:** -- Produces: `zellij_plan` fills `extra_file = Some(("/.cache/path/zellij-.kdl", ))` and returns `zellij --session --layout `. - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn persist_plan_zellij_ships_layout() { - let p = persist_plan(Harness::Claude, "sess-1", Some("/srv/w"), PersistBackend::Zellij, "/home/u"); - assert_eq!( - p.remote_command, - "zellij --session path-sess-1 --layout /home/u/.cache/path/zellij-path-sess-1.kdl" - ); - let (path, body) = p.extra_file.expect("layout shipped"); - assert_eq!(path, "/home/u/.cache/path/zellij-path-sess-1.kdl"); - let body = String::from_utf8(body).unwrap(); - assert!(body.contains("cd /srv/w && claude -r sess-1"), "body: {body}"); - assert!(body.contains("pane"), "must be a KDL layout: {body}"); -} -``` - -- [ ] **Step 2: Run to verify fail** - -Run: `cargo test -p path-cli --lib persist_plan_zellij_ships_layout` -Expected: FAIL — assertion (stub returns `attach --create`, no extra_file). - -- [ ] **Step 3: Implement** -```rust -fn zellij_plan( - name: &str, - inner: &str, - home: &str, - extra: &mut Option<(String, Vec)>, -) -> String { - let layout_path = format!("{home}/.cache/path/zellij-{name}.kdl"); - // Single-pane layout that runs INNER via the shell. `close_on_exit` - // keeps the pane if the harness exits so output stays visible. - let kdl = format!( - "layout {{\n pane command=\"sh\" {{\n args \"-c\" {inner:?}\n }}\n}}\n" - ); - *extra = Some((layout_path.clone(), kdl.into_bytes())); - format!( - "zellij --session {} --layout {}", - shell_quote(name), - shell_quote(&layout_path) - ) -} -``` -Note: the `.cache/path` dir is created by the ship step (Task 9 mkdirs the `extra_file`'s parent). - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p path-cli --lib persist_plan_zellij_ships_layout` -Expected: PASS. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): zellij layout-wrap persistence backend" -``` - ---- - -### Task 4: shpool attach-only + post_note - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`shpool_plan`); Test: same file. - -**Interfaces:** -- Produces: `shpool_plan` returns `shpool attach ` and sets `post_note = Some("In the shpool session, run: ")`. - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn persist_plan_shpool_attach_only_with_note() { - let p = persist_plan(Harness::Claude, "sess-1", Some("/srv/w"), PersistBackend::Shpool, "/home/u"); - assert_eq!(p.remote_command, "shpool attach path-sess-1"); - let note = p.post_note.expect("shpool note"); - assert!(note.contains("cd /srv/w && claude -r sess-1"), "note: {note}"); - assert!(p.extra_file.is_none()); -} -``` - -- [ ] **Step 2: Run to verify fail** - -Run: `cargo test -p path-cli --lib persist_plan_shpool_attach_only_with_note` -Expected: FAIL — no post_note. - -- [ ] **Step 3: Implement** -```rust -fn shpool_plan(name: &str, inner: &str, note: &mut Option) -> String { - *note = Some(format!( - "shpool has no command arg — in the persistent shell, run:\n {inner}" - )); - format!("shpool attach {}", shell_quote(name)) -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p path-cli --lib persist_plan_shpool_attach_only_with_note` -Expected: PASS. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): shpool attach-only persistence backend + post-note" -``` - ---- - -### Task 5: `Transport` enum + `launch_invocation` seam - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs`; Test: same file. - -**Interfaces:** -- Produces: `enum Transport { Ssh, Mosh, Et }` (derives `clap::ValueEnum`, `Copy`, `Clone`, `PartialEq`, `Eq`, `Debug`); `fn launch_invocation(transport: Transport, remote: &str, remote_cmd: &str) -> Result<(String, Vec)>`. `Ssh` delegates to existing `ssh_invocation_tty(remote, remote_cmd, true)`; `Mosh`/`Et` bail. - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn launch_invocation_ssh_and_deferred_transports() { - let (bin, argv) = launch_invocation(Transport::Ssh, "ssh://h", "claude -r x").unwrap(); - assert_eq!(bin, "ssh"); - assert_eq!(argv, vec!["-t".to_string(), "h".to_string(), "claude -r x".to_string()]); - - let err = launch_invocation(Transport::Mosh, "ssh://h", "claude -r x").unwrap_err(); - assert!(err.to_string().contains("not yet supported"), "{err}"); - assert!(launch_invocation(Transport::Et, "ssh://h", "x").is_err()); -} -``` - -- [ ] **Step 2: Run to verify fail** - -Run: `cargo test -p path-cli --lib launch_invocation_ssh_and_deferred_transports` -Expected: FAIL — not found. - -- [ ] **Step 3: Implement** -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] -pub enum Transport { - Ssh, - Mosh, - Et, -} - -/// Carry the persist-wrapped remote command over the chosen transport. -/// v1 implements only ssh; mosh/et are reserved (spec: Transport axis). -fn launch_invocation( - transport: Transport, - remote: &str, - remote_cmd: &str, -) -> Result<(String, Vec)> { - match transport { - Transport::Ssh => ssh_invocation_tty(remote, remote_cmd, true), - Transport::Mosh => anyhow::bail!( - "--via mosh is not yet supported (reserved); use --via ssh" - ), - Transport::Et => anyhow::bail!( - "--via et is not yet supported (reserved); use --via ssh" - ), - } -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p path-cli --lib launch_invocation_ssh_and_deferred_transports` -Expected: PASS. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): Transport enum + launch_invocation seam (ssh; mosh/et reserved)" -``` - ---- - -### Task 6: `remote_which` probe on `ExecStrategy` - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`trait ExecStrategy`, `RealExec` impl, `RecordingExec` impl + fields); Test: same file. - -**Interfaces:** -- Produces: `fn remote_which(&self, target: &SshTarget, bins: &[&str]) -> Result>` on `ExecStrategy`. `RealExec` runs one exec channel: `for b in bins; do command -v "$b" >/dev/null 2>&1 && echo "$b"; done`. `RecordingExec` returns a canned set (new field `available: BTreeSet`, default all). - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn recording_exec_remote_which_returns_canned_set() { - let rec = RecordingExec::with_available(["tmux", "dtach"]); - let got = rec - .remote_which(&SshTarget { user: None, host: "h".into(), port: None }, &["tmux", "zellij", "dtach"]) - .unwrap(); - assert!(got.contains("tmux") && got.contains("dtach")); - assert!(!got.contains("zellij")); -} -``` - -- [ ] **Step 2: Run to verify fail** - -Run: `cargo test -p path-cli --lib recording_exec_remote_which_returns_canned_set` -Expected: FAIL — no `remote_which` / `with_available`. - -- [ ] **Step 3: Implement** - -Add to the trait: -```rust -/// Which of `bins` exist on the remote (`command -v`). One exec channel. -fn remote_which( - &self, - target: &SshTarget, - bins: &[&str], -) -> Result>; -``` -`RealExec` impl (uses `with_conn` + `exec_channel_capture`, like `remote_home`'s SCP branch): -```rust -fn remote_which( - &self, - target: &SshTarget, - bins: &[&str], -) -> Result> { - if bins.is_empty() { - return Ok(Default::default()); - } - let probe = bins - .iter() - .map(|b| format!("command -v {} >/dev/null 2>&1 && echo {}", shell_single_quote(b), shell_single_quote(b))) - .collect::>() - .join("; "); - self.with_conn(target, |conn| { - let out = exec_channel_capture(&conn.sess, &probe)?; - Ok(out.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()) - }) -} -``` -`RecordingExec`: add field `available: std::collections::BTreeSet`, constructor `with_available`, and impl: -```rust -pub fn with_available<'a>(bins: impl IntoIterator) -> Self { - Self { available: bins.into_iter().map(String::from).collect(), ..Default::default() } -} -// in impl ExecStrategy for RecordingExec: -fn remote_which(&self, _t: &SshTarget, bins: &[&str]) -> Result> { - Ok(bins.iter().filter(|b| self.available.contains(**b)).map(|b| b.to_string()).collect()) -} -``` -Default `RecordingExec` (via `Default`) has an empty `available`; existing tests that expect a working launch must use `with_available(["tmux","abduco","dtach","zellij","shpool"])` OR pass an explicit `--persist` that skips probing (Task 8 makes `--persist` skip the probe requirement). Keep existing tests green by having them pass `--persist plain` (no probe needed) or `with_available`. - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p path-cli --lib recording_exec_remote_which_returns_canned_set` -Expected: PASS. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): ExecStrategy::remote_which probe (+ RecordingExec canned availability)" -``` - ---- - -### Task 7: CLI flags `--persist` / `--via` + `--tmux` deprecation - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`struct ResumeArgs`); Test: same file. - -**Interfaces:** -- Produces on `ResumeArgs`: `pub persist: Option` (`#[arg(long, value_enum, requires = "remote")]`), `pub via: Transport` (`#[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")]`). Keep `pub tmux: bool`. New `fn resolve_persist_flag(args: &ResumeArgs) -> Result>`: returns `Some(Tmux)` for `--tmux` (with a deprecation eprintln), `Some(x)` for `--persist x`, error if both set, `None` if neither. - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn resolve_persist_flag_maps_tmux_and_rejects_conflict() { - let mut a = crate::cmd_resume::test_args("claude-x"); // helper builds a minimal ResumeArgs - a.remote = Some("ssh://h".into()); - a.tmux = true; - assert_eq!(resolve_persist_flag(&a).unwrap(), Some(PersistBackend::Tmux)); - - a.persist = Some(PersistBackend::Dtach); - assert!(resolve_persist_flag(&a).is_err()); // both --tmux and --persist - - a.tmux = false; - assert_eq!(resolve_persist_flag(&a).unwrap(), Some(PersistBackend::Dtach)); -} -``` -(If no `test_args` helper exists, construct `ResumeArgs { .. }` inline with all fields — check the struct's current fields first.) - -- [ ] **Step 2: Run to verify fail** - -Run: `cargo test -p path-cli --lib resolve_persist_flag_maps_tmux_and_rejects_conflict` -Expected: FAIL — fields/fn missing. - -- [ ] **Step 3: Implement** - -Add fields to `ResumeArgs`: -```rust -/// Remote session-persistence backend. Skips the picker. Requires --remote. -#[arg(long, value_enum, requires = "remote")] -pub persist: Option, - -/// Transport for the interactive launch. ssh (default); mosh/et reserved. -#[arg(long, value_enum, default_value_t = Transport::Ssh, requires = "remote")] -pub via: Transport, -``` -Add the resolver: -```rust -fn resolve_persist_flag(args: &ResumeArgs) -> Result> { - match (args.tmux, args.persist) { - (true, Some(_)) => anyhow::bail!("--tmux is a deprecated alias for --persist tmux; don't combine it with --persist"), - (true, None) => { - eprintln!("note: --tmux is deprecated; use --persist tmux"); - Ok(Some(PersistBackend::Tmux)) - } - (false, p) => Ok(p), - } -} -``` -Update every `ResumeArgs { … }` construction in tests to add `persist: None, via: Transport::Ssh,`. - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p path-cli --lib resolve_persist_flag_maps_tmux_and_rejects_conflict && cargo test -p path-cli resume` -Expected: PASS. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): --persist and --via flags (+ --tmux deprecation alias)" -``` - ---- - -### Task 8: Backend candidate assembly + pre-selection (pure) - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs`; Test: same file. - -**Interfaces:** -- Produces: - ```rust - fn persist_candidates(available: &std::collections::BTreeSet) -> Vec // plain + available, in DISPLAY_ORDER - fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend // priority; plain if none - ``` - -- [ ] **Step 1: Write the failing test** -```rust -#[test] -fn persist_candidates_and_preference() { - use std::collections::BTreeSet; - let avail: BTreeSet = ["dtach", "zellij"].iter().map(|s| s.to_string()).collect(); - let cands = persist_candidates(&avail); - // DISPLAY_ORDER filtered to available + always Plain, in order. - assert_eq!(cands, vec![PersistBackend::Zellij, PersistBackend::Dtach, PersistBackend::Plain]); - assert_eq!(preferred_backend(&avail), PersistBackend::Zellij); // tmux absent -> zellij - - let none: BTreeSet = BTreeSet::new(); - assert_eq!(persist_candidates(&none), vec![PersistBackend::Plain]); - assert_eq!(preferred_backend(&none), PersistBackend::Plain); - - let with_tmux: BTreeSet = ["tmux", "shpool"].iter().map(|s| s.to_string()).collect(); - assert_eq!(preferred_backend(&with_tmux), PersistBackend::Tmux); // shpool never preferred over tmux -} -``` - -- [ ] **Step 2: Run to verify fail** - -Run: `cargo test -p path-cli --lib persist_candidates_and_preference` -Expected: FAIL — not found. - -- [ ] **Step 3: Implement** -```rust -fn persist_candidates(available: &std::collections::BTreeSet) -> Vec { - PersistBackend::DISPLAY_ORDER - .into_iter() - .filter(|b| match b.bin() { - None => true, // Plain always offered - Some(bin) => available.contains(bin), - }) - .collect() -} - -fn preferred_backend(available: &std::collections::BTreeSet) -> PersistBackend { - const PRIORITY: [PersistBackend; 4] = [ - PersistBackend::Tmux, - PersistBackend::Zellij, - PersistBackend::Abduco, - PersistBackend::Dtach, - ]; - PRIORITY - .into_iter() - .find(|b| b.bin().is_some_and(|bin| available.contains(bin))) - .unwrap_or(PersistBackend::Plain) -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p path-cli --lib persist_candidates_and_preference` -Expected: PASS. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(resume): persist candidate assembly + preferred-backend selection" -``` - ---- - -### Task 9: Wire `run_remote` (probe → pick → plan → ship → note → launch) - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (`run_remote`); Test: same file + `crates/path-cli/tests/resume.rs`. - -**Interfaces:** -- Consumes: everything above. Picker UI: for the interactive selection, mirror the existing harness picker (`crate::skim_picker` usage in `share`/`resume` — read `pick_harness`/its call site and follow the same pattern). Selection resolution: - 1. `resolve_persist_flag(args)?` → if `Some(b)`, use `b` (skip probe/picker). - 2. else probe `remote_which(&target, &bins_of_all_backends)`; `cands = persist_candidates(&avail)`. - 3. if interactive (stdin+stderr TTY): fuzzy-pick from `cands` with `preferred_backend(&avail)` pre-selected; else use `preferred_backend(&avail)` (print a note if it fell back to `Plain`). - -- [ ] **Step 1: Write the failing integration test** (in `crates/path-cli/tests/resume.rs`, using `RecordingExec::with_available`) -```rust -#[test] -fn remote_resume_persist_dtach_records_launch_and_ships() { - let rec = RecordingExec::with_available(["dtach"]); - let mut args = /* build ResumeArgs for a file input */; - args.remote = Some("ssh://h".into()); - args.harness = Some(Harness::Claude); - args.persist = Some(PersistBackend::Dtach); - run_with_strategy(&args, &rec).unwrap(); - let cap = rec.captured(); - assert_eq!(cap.binary, "ssh"); - assert!(cap.args.iter().any(|a| a.contains("dtach -A /tmp/path-dtach-")), "{:?}", cap.args); -} -``` -(Model construction on the existing `remote_resume_*` integration tests in the same file.) - -- [ ] **Step 2: Run to verify fail** - -Run: `cargo test -p path-cli --test resume remote_resume_persist_dtach` -Expected: FAIL — `persist`/wiring absent. - -- [ ] **Step 3: Implement** - -In `run_remote`, after `let home = exec.remote_home(...)?` and before the ship step, resolve the backend: -```rust -let backend = match resolve_persist_flag(args)? { - Some(b) => b, - None => { - let bins: Vec<&str> = PersistBackend::DISPLAY_ORDER.iter().filter_map(|b| b.bin()).collect(); - let avail = exec.remote_which(&target, &bins) - .with_context(|| format!("probing persistence backends on {remote}"))?; - let cands = persist_candidates(&avail); - let preferred = preferred_backend(&avail); - if io_is_interactive() { // stdin+stderr TTY, mirror existing picker guard - pick_persist_backend(&cands, preferred)? // fuzzy UI mirroring pick_harness - } else { - if preferred == PersistBackend::Plain { - eprintln!("note: no persistence backend on remote; launching plain (survives nothing)"); - } - preferred - } - } -}; -let plan = persist_plan(Harness::Claude, &session_id, launch_cwd.as_deref(), backend, &home); -``` -Then: build `projects_dir`/ship JSONL as today, PLUS ship `plan.extra_file` (mkdir its parent, write it): -```rust -if let Some((path, body)) = &plan.extra_file { - if let Some(parent) = std::path::Path::new(path).parent().and_then(|p| p.to_str()) { - exec.remote_mkdirs(&target, parent).with_context(|| format!("creating {parent} on {remote}"))?; - } - exec.remote_write(&target, path, body).with_context(|| format!("shipping {path} to {remote}"))?; -} -if let Some(note) = &plan.post_note { - eprintln!("{note}"); -} -``` -Finally replace the launch: -```rust -let (binary, argv) = launch_invocation(args.via, remote, &plan.remote_command)?; -``` -Implement `pick_persist_backend` mirroring the harness picker (read `skim_picker.rs` + the `pick_harness` call site; present `describe()` rows, pre-select `preferred`). Implement `io_is_interactive()` if not already present (reuse the existing TTY check used by `p import`). - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p path-cli --test resume && cargo test -p path-cli --lib cmd_resume && cargo clippy -p path-cli -- -D warnings` -Expected: PASS + clean. - -- [ ] **Step 5: Commit** -```bash -git add crates/path-cli/src/cmd_resume.rs crates/path-cli/tests/resume.rs -git commit -m "feat(resume): wire persistence picker + transport through run_remote" -``` - ---- - -### Task 10: Docs + version bump - -**Files:** Modify `crates/path-cli/src/cmd_resume.rs` (module docs), `CLAUDE.md` (resume bullet), `docs/agents/remote-resume-targets.md`, `crates/path-cli/Cargo.toml`, root `Cargo.toml`, `site/_data/crates.json`, `CHANGELOG.md`. - -- [ ] **Step 1: Update module docs + CLAUDE.md** - -In `cmd_resume.rs` module docs, document `--persist ` (six backends, three mechanisms), `--via ssh|mosh|et`, and that reachability rides `~/.ssh/config` aliases. In `CLAUDE.md`'s `path resume` bullet, add a sentence on the persistence picker + `--via`. - -- [ ] **Step 2: Version bump** (per the release checklist) - -Bump `path-cli` minor in `crates/path-cli/Cargo.toml`, root `Cargo.toml` `[workspace.dependencies]`, `site/_data/crates.json`, and add a `CHANGELOG.md` entry describing the persistence picker + `--via`. - -- [ ] **Step 3: Verify build + full suite** - -Run: `cargo test -p path-cli && cargo clippy --workspace -- -D warnings` -Expected: PASS + clean. - -- [ ] **Step 4: Commit** -```bash -git add -A -git commit -m "docs(resume): document persistence picker + --via; bump path-cli" -``` - ---- - -## Self-Review - -- **Spec coverage:** `--persist` 6 backends (Tasks 1–4, 8–9); 3 mechanisms — direct (T2), layout/zellij (T3), attach-only/shpool (T4); `--via ssh|mosh|et` seam (T5); probe (T6); flags + `--tmux` deprecation (T7); picker + pre-selection + non-TTY default (T8–9); reachability via alias (already shipped; documented T10); tests throughout; version bump (T10). Covered. -- **Open questions from spec** (zellij `--session … --layout` on existing session; libssh2 ProxyCommand ship fallback) are intentionally out of v1 — noted in spec, not tasks. -- **Type consistency:** `PersistBackend`, `PersistPlan { remote_command, extra_file, post_note }`, `Transport`, `persist_plan(harness, session_id, cwd, backend, home)`, `remote_which(target, bins) -> BTreeSet`, `persist_candidates`/`preferred_backend`, `resolve_persist_flag`, `launch_invocation(transport, remote, remote_cmd)` — used consistently across tasks. -- **Placeholder scan:** the only forward-refs are the Task-2 zellij/shpool stubs, explicitly replaced in Tasks 3–4; `pick_persist_backend`/`io_is_interactive` are specified to mirror the existing harness picker (named files to read at execution). diff --git a/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md b/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md deleted file mode 100644 index 4e523451..00000000 --- a/docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md +++ /dev/null @@ -1,210 +0,0 @@ -# Remote resume: session-persistence backend picker - -**Status:** design -**Date:** 2026-07-23 -**Area:** `crates/path-cli/src/cmd_resume.rs` (`--remote` path) - -## Problem - -`path resume --remote ` launches an interactive -`ssh -t host 'claude -r '`. When the SSH link drops, the harness dies and -the resumed work is lost. The branch already added an opt-in `--tmux` flag that -wraps the launch in `tmux new-session -A -s path-` so it survives drops and -is re-attachable — but it is (a) opt-in, (b) hardcoded to tmux, and (c) blind to -whether the remote even has tmux. - -We want the persistence layer to be a **first-class, selectable choice**, modeled -on the existing harness picker (`share`, `resume`, `p import`): probe what the -remote supports, present the options in the fuzzy picker, pre-select a sane -default, and let a flag skip the picker entirely. - -## Goals - -- Choose a remote session-persistence backend the same way we choose a harness: - interactive fuzzy picker by default, `--persist ` to skip it. -- Only offer backends actually installed on the remote (probe once, up front). -- Default to a working persistence backend when one is available, falling back - to a plain launch when none is — never fail *because* a backend is missing. -- Support six backends spanning three launch mechanisms (below). -- Preserve `--tmux` as a deprecated alias so existing usage keeps working. - -## Non-goals - -- **Implementing** transport-layer persistence (mosh, Eternal Terminal) in v1. - These wrap the SSH *connection*, not the remote command — a separate axis from - `--persist`. v1 reserves the flag shape (`--via ssh|mosh`, below) but only - implements `ssh`; mosh lands later. -- Restore-after-reboot / resurrection semantics beyond what each backend already - does on its own. -- Local (non-`--remote`) resume is unchanged. - -## The four independent layers - -Remote resume touches four *separate* concerns that stack rather than compete. -Conflating any two is the classic mistake (expecting tmux to solve roaming, or -mosh to solve NAT). This design keeps them separate: - -| Layer | Concern | How we express it | -|---|---|---| -| **Reachability** | Can I even connect? (NAT, mesh, broker) | **`~/.ssh/config`** — not a flag | -| **Connection-survival** | Does the link survive roaming/sleep? | **`--via ssh\|mosh\|et`** | -| **Session-survival** | Does work survive a client death? | **`--persist `** | -| **Workspace** | Panes/tabs/layout | a `--persist` backend (zellij) | - -### Reachability is delegated to ssh config (no flag) - -Tailscale SSH, WireGuard, Cloudflare Tunnel (`cloudflared access ssh`), -Teleport, Nebula, NetBird — all of these are expressed as a `Host` block -(`ProxyCommand`, `ProxyJump`, or just a mesh hostname). Because `--remote` -accepts a `~/.ssh/config` alias (see the parsing change on this branch), -resume rides every one of them for free — the CLI launch honors the full config. - -**Known limitation:** the **libssh2 probe/ship half does not honor -`ProxyCommand`/`ProxyJump`** — libssh2 dials a raw TCP socket. So a host reachable -*only* through a broker/jump (Cloudflare Tunnel, a bastion) will launch fine but -**fail at the ship step**. Mitigation (candidate, not v1): when the libssh2 dial -fails and the config has a `ProxyCommand`, fall back to shipping over the `ssh` -CLI (`ssh host 'cat > dest'` / scp), which honors the config. Recorded as an open -question; v1 documents the limitation and errors clearly. - -## Transport axis (`--via`) — forward-looking - -Connection-survival only. Orthogonal to `--persist`; the belt-and-suspenders -combo is `--via mosh` + a `--persist` backend. - -- `--via ssh` — **default, the only v1 implementation.** Interactive - `ssh -t host ''`. -- `--via mosh` — **deferred.** `mosh host -- ''` (mosh owns - the TTY, so no `-t`; needs `mosh-server` on the remote → joins the up-front - probe). -- `--via et` — **deferred.** Eternal Terminal: `et host -c ''` - (TCP auto-reconnect, native scrollback; needs `etserver` + its port → probe). - -The persistence wrapping is identical across transports — `--via` only swaps how -the wrapped command is carried. Designing the flag in now keeps the launch code -factored around a `Transport` seam so mosh/et are additive drop-ins, not a -refactor. The libssh2 probe/ship half is unaffected by `--via`. - -## Backends and launch mechanisms - -Let `INNER` be the existing inner launch string: `[cd && ]claude -r `. -Session name is `path-` (already used by the tmux path). - -Backends fall into **three mechanisms**: - -### 1. Direct command-wrap (hand them INNER) -| Backend | Remote command | -|---|---| -| `plain` | `INNER` | -| `tmux` | `tmux new-session -A -s path- 'INNER'` | -| `abduco` | `abduco -A path- sh -c 'INNER'` | -| `dtach` | `dtach -A /tmp/path-dtach- -z sh -c 'INNER'` | - -Reattach on a later resume is automatic: tmux `-A`, abduco `-A`, dtach `-A` all -attach-or-create by name. - -### 2. Layout-wrap (zellij) -zellij has no "new named session running command X" one-liner; the supported -path is a KDL layout file with the command in a pane. - -- **Ship** an extra file `~/.cache/path/zellij-.kdl` alongside the session - JSONL, containing a single pane that runs `INNER`. -- **Launch**: `zellij --session path- --layout ~/.cache/path/zellij-.kdl` - — creates with the layout when new, attaches when the session already exists. -- Reattach later: same command (zellij attaches an existing session). - -### 3. Attach-only (shpool) -`shpool attach ` only ever starts a **shell** — there is no supported way -to run a one-shot command in it. So shpool does **not** auto-run the harness. - -- **Ship** the session JSONL as normal. -- **Launch**: `ssh -t host 'shpool attach path-'` — drops the user into a - persistent shell. -- **Guidance**: before handing off, print the exact command to run: - `run in the shpool session: cd && claude -r `. - -This is honest about shpool's model rather than faking a launch it can't do. - -## User-facing surface - -### Flags -- `--persist ` — `plain|tmux|abduco|dtach|zellij|shpool`. Skips the - picker. Requires `--remote`. -- `--tmux` — **deprecated alias** for `--persist tmux`. Kept working; emits a - one-line deprecation note pointing at `--persist tmux`. Errors if combined - with an explicit `--persist`. -- `--via ` — `ssh` (default) | `mosh` | `et` (both deferred; error - with a "not yet supported" message in v1). Requires `--remote`. See the - Transport axis section. - -### Picker behavior (mirrors the harness picker) -1. After the remote is reachable and home is resolved, **probe** the remote once: - `command -v tmux zellij abduco dtach shpool` in a single exec channel → - the set of available backends. -2. Candidate list = `plain` + every available backend (in a fixed display order: - `tmux, zellij, abduco, dtach, shpool, plain`), each with a short description - of what it buys (`tmux — detachable, survives drops`, `shpool — persistent - shell (attach-only)`, `plain — no persistence`, …). -3. **Pre-select** the highest-priority available backend by the order - `tmux > zellij > abduco > dtach > plain` (shpool is not auto-preferred because - it can't auto-launch). If none installed, pre-select `plain`. -4. **Skip the picker** when: `--persist` given; or `--tmux` given; or not - interactive (no TTY on stdin+stderr) — in which case use the pre-selected - default. Emit a note when falling back to `plain` because nothing was - installed. - -## Internal design - -- New `enum PersistBackend { Plain, Tmux, Abduco, Dtach, Zellij, Shpool }` with: - - `bin() -> Option<&str>` (probe target; `Plain` → `None`). - - `describe() -> &str` (picker row text). - - display/parse for clap (`ValueEnum`) and the picker. -- New `struct PersistPlan { remote_command: String, extra_file: Option<(String, Vec)>, post_note: Option }` - built from `(backend, session_id, launch_cwd)`. `extra_file` is the zellij - layout; `post_note` is the shpool guidance. -- `ExecStrategy` gains `fn remote_which(&self, target, bins: &[&str]) -> Result>` - (single exec channel running `command -v …`), so probing is mockable in tests. -- `run_remote` sequence becomes: resolve/project → connect/home → **probe** → - **resolve backend** (flag or picker) → **build PersistPlan** → ship JSONL - (+ `extra_file`) → print `post_note` → interactive launch of - `plan.remote_command` **via the selected transport**. -- New `enum Transport { Ssh, Mosh, Et }`. A `fn launch_invocation(transport, - remote, remote_cmd) -> (binary, argv)` seam replaces the direct - `ssh_invocation_tty` call: `Ssh` → today's `ssh -t …`; `Mosh`/`Et` → v1 returns - a "not yet supported" error (shape reserved). The probe/ship half never sees - `Transport`. -- `remote_launch_command(harness, id, cwd, tmux: bool)` is replaced by - `persist_plan(harness, id, cwd, backend) -> PersistPlan`. Existing - `tmux: bool` call sites and tests migrate to `PersistBackend`. - -### Quoting -The nested quoting (`ssh 'sh -c '\''cd … && claude …'\'''`) is the sharp edge. -Reuse `shell_single_quote` and add a focused unit test per backend asserting the -exact remote command string for a representative `INNER` (with and without -`--cwd`), so the escaping is pinned. - -## Testing - -- Unit: `persist_plan` output string per backend (× cwd / no-cwd); `PersistBackend` - clap parse + display; picker candidate assembly + pre-selection given a probed - availability set; `--tmux` → tmux mapping + conflict-with-`--persist` error. -- `remote_which` exercised via `RecordingExec` (records the probe, returns a - canned availability set). -- Integration (`tests/resume.rs`, `RecordingExec`): `--persist tmux/abduco/dtach` - record the expected `remote_command`; zellij records the shipped layout file; - shpool records the attach command + post-note; non-TTY defaults to the - pre-selected backend. -- Keep the existing `remote_resume_*` tests green (migrated to the new enum). - -## Rollout / compat - -- Pre-1.0; `--tmux` stays as a deprecated alias (no hard break). -- Bump `path-cli` per the release checklist (minor: additive CLI surface). - -## Open questions - -- zellij `--session X --layout Y` when the session already exists: confirm it - attaches (ignoring the layout) rather than erroring. If it errors, launch logic - branches on "session exists" (probe with `zellij list-sessions`). -- dtach socket dir: `/tmp/path-dtach-` is world-readable-parent; acceptable - for a single-user box, but consider `${XDG_RUNTIME_DIR:-/tmp}`.