diff --git a/CHANGELOG.md b/CHANGELOG.md index 779cadb7..8cd7e75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ All notable changes to the Toolpath workspace are documented here. +## `path resume --remote` pushes a session to a remote host — 2026-08-17 + +`path resume --remote [-C ] +[--dry-run]` projects a local Claude Code session onto a remote host +and attaches to it under tmux. Re-running the same command reattaches +to the live tmux session. Every push is a fork; the local original is +never touched. + +- **`path-cli`** (0.19.0): + - The local host does all toolpath work: it projects the + conversation in memory, stamps it (`Conversation::set_session_id_and_cwd`) + with a minted session id and the remote project directory, and + ships the JSONL over ssh's stdin. The remote never runs `path`. + - Transport is the user's `ssh` binary from the search path, so + ProxyJump, Match blocks, aliases, and host keys work. Remote + scope is POSIX sh remotes with claude and tmux installed. + - One batched, read-only preflight call captures `$HOME`, the + absolute claude path, tmux presence, the physical project path + (`pwd -P`), and tmux session liveness. Every captured value must + be a single-line absolute path before it becomes a path + component, so a login banner errors verbatim instead of turning + into a filename. + - The remote session id is a UUID formatted from the SHA-256 of a + key-sorted canonical serialization of the parsed document, so + re-pushing unchanged content targets the same remote file across + invocations and input shapes. The tmux session is `path-` + of the source session id. + - `-C` names the remote directory (absolute, physical; a symlinked + value errors and names the physical path). The default is the + local cwd with the local home prefix replaced by the remote + `$HOME`. + - Every remote argument passes a POSIX single-quote escaping + helper; the session file lands 0600 via `umask 077`. Credentials + are never read, copied, or written. + - `--dry-run` runs preflight, prints the exact remote commands and + the target file path, and changes nothing on the remote. +- **`toolpath-cli`** (0.19.0): lockstep bump of the deprecated shim. + +## `toolpath-claude`: Conversation::set_session_id_and_cwd — 2026-08-17 + +- **`toolpath-claude`** (0.12.3): `Conversation::set_session_id_and_cwd(session_id, + cwd)` rewrites a conversation in place for a new session ID and + working directory. It sets the conversation-level `session_id`, sets + `session_id` on every entry, sets `project_path`, replaces `cwd` + where an entry has one, rewrites top-level `sessionId` and `cwd` + keys in preamble raw lines, and clears the segment list + `session_ids`. Message content and tool-result payloads stay + untouched. +- Slug-pinning tests cover `PathResolver::conversation_file` with a + foreign home and a remote cwd, including the physical-path case (the + resolver slugs the string it is given, so callers pass the physical + cwd). + ## `path config edit` — 2026-08-14 - **`path-cli`** (0.18.0): new `path config` porcelain command, starting diff --git a/Cargo.lock b/Cargo.lock index 4be9d924..9d6b524f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2487,7 +2487,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "assert_cmd", @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.2" +version = "0.12.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b57ad62b..eadfb69e 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.2", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.12.3", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.18.0", path = "crates/path-cli" } +path-cli = { version = "0.19.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 27bda67b..4a2db9f4 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.18.0" +version = "0.19.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index b95a67f0..5ae81ba2 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -24,6 +24,8 @@ use std::path::PathBuf; #[cfg(not(target_os = "emscripten"))] use crate::cache::cache_ref; +#[cfg(not(target_os = "emscripten"))] +use crate::projection::{build_claude_conversation, serialize_jsonl}; use crate::remote::RepoSpec; #[derive(Subcommand, Debug)] @@ -694,32 +696,6 @@ fn load_path_doc(input: &str) -> Result { }) } -#[cfg(not(target_os = "emscripten"))] -fn build_claude_conversation(path: &toolpath::v1::Path) -> Result { - use toolpath_convo::ConversationProjector; - let view = toolpath_convo::extract_conversation(path); - let projector = toolpath_claude::ClaudeProjector; - projector - .project(&view) - .map_err(|e| anyhow::anyhow!("Projection failed: {}", e)) -} - -#[cfg(not(target_os = "emscripten"))] -fn serialize_jsonl(conv: &toolpath_claude::Conversation) -> Result { - let mut lines = Vec::with_capacity(conv.preamble.len() + conv.entries.len()); - for raw in &conv.preamble { - lines.push(serde_json::to_string(raw)?); - } - for entry in &conv.entries { - lines.push(serde_json::to_string(entry)?); - } - // Trailing newline matters: Claude Code appends to this file on resume, - // and without it the first appended entry lands on the last line. - let mut out = lines.join("\n"); - out.push('\n'); - Ok(out) -} - #[cfg(not(target_os = "emscripten"))] fn write_into_claude_project( conv: &toolpath_claude::Conversation, diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 4b0b6e3d..64f3dd28 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -40,6 +40,8 @@ #![cfg(not(target_os = "emscripten"))] +pub mod remote; + use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; @@ -57,6 +59,9 @@ pub struct ResumeArgs { /// Working directory to run the resumed harness from. Defaults to /// the current shell cwd. The on-disk projection is keyed on this /// directory and the harness will be exec'd with cwd set to it. + /// With --remote it is a directory on the remote host (absolute, + /// physical); the default is the local cwd with the local home + /// prefix replaced by the remote $HOME. #[arg(short = 'C', long)] pub cwd: Option, @@ -64,6 +69,19 @@ pub struct ResumeArgs { #[arg(long, value_enum)] pub harness: Option, + /// Push the session to a remote host over ssh and attach to it + /// under tmux there. The value is an ssh destination (user@host or + /// an ssh config alias), passed to ssh verbatim. Claude only; the + /// remote needs sshd, claude, and tmux. Re-running the same command + /// reattaches to the live tmux session. + #[arg(long)] + pub remote: Option, + + /// With --remote: run preflight, print the exact remote commands + /// and the target file path, and change nothing on the remote. + #[arg(long, requires = "remote")] + pub dry_run: bool, + /// Skip the cache entirely when fetching from Pathbase: don't read /// an existing entry, don't write the fetched body. Useful for /// ephemeral environments where you don't want the cache to grow. @@ -89,6 +107,26 @@ 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<()> { + if args.remote.is_some() { + use std::io::IsTerminal; + let stdin_is_tty = std::io::stdin().is_terminal(); + let search_path: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + let local_home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from); + let local_cwd = std::env::current_dir()?; + return remote::run_remote( + &args, + exec, + local_home.as_deref(), + &local_cwd, + &search_path, + stdin_is_tty, + ); + } + let (graph, source_harness) = resolve_input(&args)?; let path = ensure_path_with_agent(&graph)?; @@ -615,6 +653,8 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; let recorder = RecordingExec::default(); @@ -751,6 +791,8 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -785,6 +827,8 @@ mod tests { no_cache: true, // skip cache write in tests force: false, url: None, + remote: None, + dry_run: false, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -846,6 +890,8 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; let result = resolve_input(&args); @@ -874,6 +920,8 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/src/cmd_resume/remote.rs b/crates/path-cli/src/cmd_resume/remote.rs new file mode 100644 index 00000000..fbdd4ca7 --- /dev/null +++ b/crates/path-cli/src/cmd_resume/remote.rs @@ -0,0 +1,826 @@ +//! `path resume --remote`: push a Claude session to a remote host +//! over ssh and attach to it under tmux. +//! +//! The local host does all toolpath work: it resolves the input, +//! projects the conversation in memory, stamps it with a minted +//! session id and the remote project directory, and ships the JSONL +//! over ssh's stdin. The remote does not run `path`. +//! +//! Every path is a pure function of (document, absolute remote project +//! path, remote home). Each preflight fact can veto with a clear +//! error, and none rewrites an input. Preflight is read-only: the +//! first remote write is the ship step. +//! +//! Transport is the user's `ssh` binary from the search path. Remote +//! scope is POSIX sh remotes; every remote argument passes +//! `sh_quote`. + +use anyhow::{Context, Result, bail}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use super::{ExecStrategy, ResumeArgs, ensure_path_with_agent, resolve_input}; +use crate::harness::Harness; + +/// Entry point for `path resume --remote`. +pub fn run_remote( + args: &ResumeArgs, + exec: &dyn ExecStrategy, + local_home: Option<&Path>, + local_cwd: &Path, + search_path: &[PathBuf], + stdin_is_tty: bool, +) -> Result<()> { + let remote = args + .remote + .as_deref() + .expect("run_remote requires --remote"); + + // ssh parses a leading-dash destination as an option. + if remote.is_empty() || remote.starts_with('-') { + bail!("--remote must be an ssh destination such as user@host (got {remote:?})"); + } + + if !stdin_is_tty && !args.dry_run { + bail!("`path resume --remote` needs an interactive terminal: stdin is not a TTY"); + } + if let Some(h) = args.harness + && h != Harness::Claude + { + bail!( + "remote resume supports claude only (got --harness {})", + h.name() + ); + } + + let (graph, source_harness) = resolve_input(args)?; + let path = ensure_path_with_agent(&graph)?; + if args.harness.is_none() && source_harness != Some(Harness::Claude) { + bail!( + "remote resume supports claude only; the document's source is {}. \ + Pass `--harness claude` to force a Claude projection.", + source_harness.map(|h| h.name()).unwrap_or("unknown") + ); + } + + let conv = crate::projection::build_claude_conversation(path)?; + if conv.session_id.is_empty() { + bail!("projected session has no id"); + } + let tmux_name = tmux_session_name(&conv.session_id); + + let dir = remote_dir_spec(args.cwd.as_deref(), local_cwd, local_home)?; + + let ssh = find_binary("ssh", search_path) + .ok_or_else(|| anyhow::anyhow!("`ssh` not found on PATH"))?; + let ssh = SshRunner { + binary: ssh, + remote, + }; + + // One batched, read-only preflight call. The reattach probe rides + // along because it is read-only too. + let script = preflight_script(&dir, &tmux_name); + let output = ssh.run(&script, None)?; + if !output.status.success() { + bail!( + "ssh to {} failed ({}):\n{}", + remote, + output.status, + String::from_utf8_lossy(&output.stderr).trim_end() + ); + } + let pf = parse_preflight(&output)?; + + let home = validate_captured_path(&pf.home, "remote $HOME", &output)?; + if pf.claude.is_empty() { + let probed: Vec = CLAUDE_PROBE_LOCATIONS + .iter() + .map(|p| format!("~/{p}")) + .collect(); + bail!( + "claude not found on {remote}; probed PATH, {}", + probed.join(", ") + ); + } + let claude = validate_captured_path(&pf.claude, "remote claude path", &output)?; + if !pf.tmux_ok { + bail!("tmux not found on {remote}"); + } + let project_path = resolved_project_path(&dir, &home); + validate_remote_cwd(&project_path)?; + match pf.pwd.as_deref() { + None => bail!( + "project directory {project_path} does not exist on {remote}; \ + create it or pass -C" + ), + Some(physical) if physical != project_path => bail!( + "project directory {project_path} is not physical on {remote} \ + (it resolves to {physical}); pass the physical path: -C {physical}" + ), + Some(_) => {} + } + + let attach_cmd = attach_command(&tmux_name); + let attach_args = vec!["-t".to_string(), remote.to_string(), attach_cmd.clone()]; + + if pf.session_live { + eprintln!( + "Remote tmux session {tmux_name} is live on {remote}. \ + Attaching without re-shipping: the session keeps the \ + content from its original push." + ); + if args.dry_run { + eprintln!( + " attach: {} -t {} {}", + ssh.binary.display(), + remote, + sh_quote(&attach_cmd) + ); + eprintln!("Dry run: nothing was written or launched."); + return Ok(()); + } + return exec.exec(&ssh.binary.to_string_lossy(), &attach_args, local_cwd); + } + + // Canonical means key-sorted: serde_json::Value's map is a + // BTreeMap, where a direct Graph::to_json would serialize HashMap + // fields in per-instance random order and break id stability. + let canonical_json = serde_json::to_value(&graph) + .and_then(|v| serde_json::to_string(&v)) + .context("serialize document")?; + let remote_id = mint_remote_id(&canonical_json); + if remote_id == conv.session_id { + bail!("minted remote session id equals the source session id"); + } + if !is_uuid_shaped(&remote_id) { + bail!("minted remote session id is not UUID-shaped: {remote_id}"); + } + + // Slug rules live in toolpath-claude. + let resolver = toolpath_claude::PathResolver::new().with_home(home.as_str()); + let slug_dir = path_to_string(&resolver.project_dir(&project_path)?)?; + let target = path_to_string(&resolver.conversation_file(&project_path, &remote_id)?)?; + + let ship_cmd = ship_command(&slug_dir, &target); + let launch_cmd = launch_command(&tmux_name, &project_path, &claude, &remote_id); + + if args.dry_run { + eprintln!("Remote resume plan for {remote}:"); + eprintln!(" remote home: {home}"); + eprintln!(" claude: {claude}"); + eprintln!(" project dir: {project_path}"); + eprintln!(" session id: {remote_id}"); + eprintln!(" session file: {target}"); + eprintln!(" tmux session: {tmux_name}"); + eprintln!(" ship: ssh {} {}", remote, sh_quote(&ship_cmd)); + eprintln!(" launch: ssh {} {}", remote, sh_quote(&launch_cmd)); + eprintln!(" attach: ssh -t {} {}", remote, sh_quote(&attach_cmd)); + eprintln!("Dry run: nothing was written or launched."); + return Ok(()); + } + + let mut conv = conv; + conv.set_session_id_and_cwd(&remote_id, &project_path); + let jsonl = crate::projection::serialize_jsonl(&conv)?; + + eprintln!("Shipping session {remote_id} to {remote}:{target}"); + let shipped = ssh.run(&ship_cmd, Some(jsonl.as_bytes()))?; + if !shipped.status.success() { + bail!( + "shipping the session to {} failed ({}):\n{}", + remote, + shipped.status, + String::from_utf8_lossy(&shipped.stderr).trim_end() + ); + } + + eprintln!("Launching {tmux_name} in {project_path}"); + let launched = ssh.run(&launch_cmd, None)?; + if !launched.status.success() { + bail!( + "launching tmux session {} on {} failed ({}):\n{}", + tmux_name, + remote, + launched.status, + String::from_utf8_lossy(&launched.stderr).trim_end() + ); + } + + exec.exec(&ssh.binary.to_string_lossy(), &attach_args, local_cwd) +} + +// ── Remote directory ───────────────────────────────────────────────── + +/// The remote project directory before the remote `$HOME` is known. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RemoteDir { + /// `-C` value, verbatim. + Explicit(String), + /// Local cwd relative to the local home; empty means `$HOME` itself. + HomeRelative(String), +} + +fn remote_dir_spec( + cwd: Option<&Path>, + local_cwd: &Path, + local_home: Option<&Path>, +) -> Result { + if let Some(p) = cwd { + let s = p + .to_str() + .context("-C must be valid UTF-8")? + .trim_end_matches('/'); + let s = if s.is_empty() { "/" } else { s }; + validate_remote_cwd(s)?; + return Ok(RemoteDir::Explicit(s.to_string())); + } + let local_home = + local_home.context("cannot determine the local home directory; pass -C ")?; + match home_swap_suffix(local_cwd, local_home) { + Some(suffix) => Ok(RemoteDir::HomeRelative(suffix)), + None => bail!( + "the local cwd {} is not under the local home {}; pass -C ", + local_cwd.display(), + local_home.display() + ), + } +} + +/// Local cwd relative to the local home, as a `/`-joined suffix. +/// `None` when the cwd is not under the home. +pub(crate) fn home_swap_suffix(local_cwd: &Path, local_home: &Path) -> Option { + let rel = local_cwd.strip_prefix(local_home).ok()?; + Some(rel.to_str()?.to_string()) +} + +pub(crate) fn resolved_project_path(dir: &RemoteDir, remote_home: &str) -> String { + match dir { + RemoteDir::Explicit(p) => p.clone(), + RemoteDir::HomeRelative(suffix) if suffix.is_empty() => remote_home.to_string(), + RemoteDir::HomeRelative(suffix) => { + format!("{}/{}", remote_home.trim_end_matches('/'), suffix) + } + } +} + +pub(crate) fn validate_remote_cwd(s: &str) -> Result<()> { + if !s.starts_with('/') { + bail!("the remote project directory must be absolute (got {s})"); + } + if s.contains('\n') { + bail!("the remote project directory must be a single line"); + } + if s.split('/').any(|c| c == "..") { + bail!("the remote project directory must not contain `..` (got {s})"); + } + Ok(()) +} + +// ── Preflight ──────────────────────────────────────────────────────── + +/// Locations probed for `claude` when `command -v` finds nothing, +/// relative to the remote home. An ssh exec channel runs a non-login +/// shell whose PATH lacks the user's profile additions, so common +/// install locations get a direct probe. The preflight script and +/// the not-found error both derive from this list. +const CLAUDE_PROBE_LOCATIONS: [&str; 3] = [ + ".local/bin/claude", + ".claude/local/claude", + ".npm-global/bin/claude", +]; + +// Tags for the fact lines the remote scripts print, one +// `=` line per fact. `TP_` abbreviates toolpath and keeps +// the tags distinct from any real remote output. +const TAG_HOME: &str = "TP_HOME"; +const TAG_CLAUDE: &str = "TP_CLAUDE"; +const TAG_TMUX: &str = "TP_TMUX"; +const TAG_PWD: &str = "TP_PWD"; +const TAG_SESSION: &str = "TP_SESSION"; +/// Emit and parse order of the preflight fact lines. The script +/// prints one `=` line per entry; the parser reads them +/// back in this order. +const PREFLIGHT_TAGS: [&str; 5] = [TAG_HOME, TAG_CLAUDE, TAG_TMUX, TAG_PWD, TAG_SESSION]; + +#[derive(Debug)] +pub(crate) struct PreflightFacts { + pub(crate) home: String, + pub(crate) claude: String, + pub(crate) tmux_ok: bool, + /// Physical path from `pwd -P`, `None` when the dir is missing. + pub(crate) pwd: Option, + pub(crate) session_live: bool, +} + +/// One POSIX-sh script gathering every preflight fact as `TP_*=` lines. +/// Read-only: it makes no change on the remote. +pub(crate) fn preflight_script(dir: &RemoteDir, tmux_name: &str) -> String { + let dir_expr = match dir { + RemoteDir::Explicit(p) => sh_quote(p), + RemoteDir::HomeRelative(suffix) if suffix.is_empty() => "\"$HOME\"".to_string(), + RemoteDir::HomeRelative(suffix) => format!("\"$HOME\"{}", sh_quote(&format!("/{suffix}"))), + }; + let probes: String = CLAUDE_PROBE_LOCATIONS + .iter() + .map(|p| format!("if [ -z \"$c\" ] && [ -x \"$HOME/{p}\" ]; then c=\"$HOME/{p}\"; fi\n")) + .collect(); + format!( + r#"set -u +printf '{home}=%s\n' "$HOME" +c='' +if command -v claude >/dev/null 2>&1; then c=$(command -v claude); fi +{probes}printf '{claude}=%s\n' "$c" +if command -v tmux >/dev/null 2>&1; then t=ok; else t=missing; fi +printf '{tmux}=%s\n' "$t" +if cd {dir_expr} 2>/dev/null; then p=$(pwd -P); else p=''; fi +printf '{pwd}=%s\n' "$p" +if [ "$t" = ok ] && tmux has-session -t {name} 2>/dev/null; then s=live; else s=none; fi +printf '{session}=%s\n' "$s" +"#, + home = TAG_HOME, + claude = TAG_CLAUDE, + tmux = TAG_TMUX, + pwd = TAG_PWD, + session = TAG_SESSION, + probes = probes, + dir_expr = dir_expr, + // A bare `-t ` matches any session whose name starts + // with , so a session named `path-x-foo` would read as + // a live `path-x`. The `=` prefix requires the exact name. + name = sh_quote(&format!("={tmux_name}")), + ) +} + +/// Parse the five `TP_*=` lines. Anything else (a registration notice, +/// a MOTD on stdout, a partial run) errors with the output verbatim, +/// so a banner cannot become a path component. +pub(crate) fn parse_preflight(output: &Output) -> Result { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + let tags: Vec = PREFLIGHT_TAGS.iter().map(|t| format!("{t}=")).collect(); + if lines.len() != tags.len() + || lines + .iter() + .zip(&tags) + .any(|(l, t)| !l.starts_with(t.as_str())) + { + bail!( + "unexpected preflight output from the remote (a login banner or notice?); \ + output was:\n{}", + preflight_transcript(output) + ); + } + let val = |i: usize| lines[i][tags[i].len()..].to_string(); + let pwd = val(3); + Ok(PreflightFacts { + home: val(0), + claude: val(1), + tmux_ok: val(2) == "ok", + pwd: if pwd.is_empty() { None } else { Some(pwd) }, + session_live: val(4) == "live", + }) +} + +fn preflight_transcript(output: &Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let mut s = stdout.trim_end().to_string(); + if !stderr.trim().is_empty() { + s.push_str("\n(stderr) "); + s.push_str(stderr.trim_end()); + } + s +} + +/// A value captured from the remote may only become a path component if +/// it is a non-empty single line starting with `/`. +fn validate_captured_path(value: &str, what: &str, output: &Output) -> Result { + if value.is_empty() || !value.starts_with('/') || value.contains('\n') { + bail!( + "{what} is not an absolute single-line path; preflight output was:\n{}", + preflight_transcript(output) + ); + } + Ok(value.to_string()) +} + +// ── Remote commands ────────────────────────────────────────────────── + +pub(crate) fn ship_command(slug_dir: &str, target: &str) -> String { + format!( + "umask 077; mkdir -p {} && cat > {}", + sh_quote(slug_dir), + sh_quote(target) + ) +} + +pub(crate) fn launch_command( + tmux_name: &str, + cwd: &str, + claude_path: &str, + session_id: &str, +) -> String { + // The inner string is the command tmux hands to `sh -c`, so it is + // quoted twice: once for that inner shell, once for the remote + // shell that parses the whole tmux invocation. + let inner = format!( + "env LANG=C.UTF-8 {} -r {}", + sh_quote(claude_path), + sh_quote(session_id) + ); + format!( + "tmux new-session -d -s {} -c {} {}", + sh_quote(tmux_name), + sh_quote(cwd), + sh_quote(&inner) + ) +} + +/// The `=` target prefix pins tmux to an exact session-name match; +/// `-d` detaches any stale client. Fresh launch and reattach both end +/// in this command. +pub(crate) fn attach_command(tmux_name: &str) -> String { + format!( + "tmux attach-session -d -t {}", + sh_quote(&format!("={tmux_name}")) + ) +} + +// ── Small pure helpers ─────────────────────────────────────────────── + +/// POSIX single-quote escaping: the only special character inside +/// single quotes is the single quote itself. +pub(crate) fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// `path-`: the first 8 characters of the source session id, +/// with anything outside `[A-Za-z0-9_-]` replaced by `-`. +pub fn tmux_session_name(source_session_id: &str) -> String { + let short: String = source_session_id + .chars() + .take(8) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + format!("path-{short}") +} + +/// A UUID formatted from the SHA-256 of the document JSON. +/// `Builder::from_random_bytes` forces the version-4 and variant +/// bits. Callers pass the canonical serialization of the parsed +/// document, so unchanged content mints the same id across +/// invocations and input shapes. +pub fn mint_remote_id(doc_json: &str) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(doc_json.as_bytes()); + let mut b = [0u8; 16]; + b.copy_from_slice(&digest[..16]); + uuid::Builder::from_random_bytes(b).into_uuid().to_string() +} + +pub(crate) fn is_uuid_shaped(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + parts.len() == 5 + && [8, 4, 4, 4, 12] + .iter() + .zip(&parts) + .all(|(len, p)| p.len() == *len && p.chars().all(|c| c.is_ascii_hexdigit())) +} + +fn find_binary(name: &str, search_path: &[PathBuf]) -> Option { + search_path + .iter() + .map(|d| d.join(name)) + .find(|c| c.is_file()) +} + +fn path_to_string(p: &Path) -> Result { + Ok(p.to_str() + .context("remote path is not valid UTF-8")? + .to_string()) +} + +// ── ssh transport ──────────────────────────────────────────────────── + +struct SshRunner<'a> { + binary: PathBuf, + remote: &'a str, +} + +impl SshRunner<'_> { + /// Run `ssh ` with stdin fed from `stdin` (or + /// closed), capturing stdout and stderr. + fn run(&self, command: &str, stdin: Option<&[u8]>) -> Result { + use std::io::Write; + let mut cmd = Command::new(&self.binary); + cmd.arg(self.remote) + .arg(command) + .stdin(if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = spawn_retrying_busy(&mut cmd) + .with_context(|| format!("spawn {}", self.binary.display()))?; + // Feed stdin from a thread while the parent drains stdout and + // stderr: a write-then-drain sequence deadlocks once either + // side outgrows a pipe buffer. A write error (the remote + // exited before reading) is dropped so the status and stderr + // in the output carry the real failure. + let writer = match stdin { + Some(bytes) => { + let mut pipe = child.stdin.take().context("child stdin unavailable")?; + let bytes = bytes.to_vec(); + Some(std::thread::spawn(move || pipe.write_all(&bytes))) + } + None => None, + }; + let output = child + .wait_with_output() + .with_context(|| format!("wait for {}", self.binary.display()))?; + if let Some(w) = writer { + let _ = w.join(); + } + Ok(output) + } +} + +/// `spawn` with a retry on `ExecutableFileBusy`. Parallel test threads +/// can hold a freshly written ssh shim open across another thread's +/// fork-to-exec window; retrying is harmless in production. +fn spawn_retrying_busy(cmd: &mut Command) -> std::io::Result { + let mut delay = std::time::Duration::from_millis(5); + for _ in 0..5 { + match cmd.spawn() { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + std::thread::sleep(delay); + delay *= 2; + } + other => return other, + } + } + cmd.spawn() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── sh_quote ───────────────────────────────────────────────────── + + #[test] + fn sh_quote_plain_and_empty() { + assert_eq!(sh_quote("/a/b"), "'/a/b'"); + assert_eq!(sh_quote(""), "''"); + } + + #[test] + fn sh_quote_embedded_single_quote() { + assert_eq!(sh_quote("a'b"), "'a'\\''b'"); + } + + #[test] + fn sh_quote_round_trips_through_sh() { + let nasty = "$(rm -rf ~); `x`; $HOME 'quoted' \"double\" \\ * ? ; & | > <"; + let out = std::process::Command::new("sh") + .arg("-c") + .arg(format!("printf %s {}", sh_quote(nasty))) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout), nasty); + } + + // ── tmux session name ──────────────────────────────────────────── + + #[test] + fn tmux_name_takes_first_8_chars() { + assert_eq!( + tmux_session_name("abcd1234-5678-90ab-cdef-000000000000"), + "path-abcd1234" + ); + } + + #[test] + fn tmux_name_sanitizes_and_accepts_short_ids() { + assert_eq!(tmux_session_name("a.b:c/d!"), "path-a-b-c-d-"); + assert_eq!(tmux_session_name("ab"), "path-ab"); + } + + // ── remote id minting ──────────────────────────────────────────── + + #[test] + fn mint_is_idempotent_and_input_sensitive() { + let a = mint_remote_id("{\"doc\":1}"); + let b = mint_remote_id("{\"doc\":1}"); + let c = mint_remote_id("{\"doc\":2}"); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn mint_is_uuid_shaped_with_forced_bits() { + let id = mint_remote_id("anything"); + assert!(is_uuid_shaped(&id), "not uuid-shaped: {id}"); + assert_eq!(&id[14..15], "4", "version nibble: {id}"); + assert!( + matches!(&id[19..20], "8" | "9" | "a" | "b"), + "variant nibble: {id}" + ); + } + + #[test] + fn canonical_serialization_sorts_map_keys() { + // Minted-id stability requires serde_json::Value to sort map + // keys. Any dependency that enables serde_json's + // `preserve_order` feature switches Value to insertion order + // for the whole workspace and changes every minted id. + let v: serde_json::Value = serde_json::from_str(r#"{"zeta":1,"alpha":2,"mid":3}"#).unwrap(); + assert_eq!( + serde_json::to_string(&v).unwrap(), + r#"{"alpha":2,"mid":3,"zeta":1}"# + ); + } + + #[test] + fn mint_differs_from_a_source_session_id() { + let doc = "{\"session\":\"11111111-2222-4333-8444-555555555555\"}"; + assert_ne!(mint_remote_id(doc), "11111111-2222-4333-8444-555555555555"); + } + + #[test] + fn uuid_shape_check() { + assert!(is_uuid_shaped("0a1b2c3d-0000-4000-8000-000000000000")); + assert!(!is_uuid_shaped("0a1b2c3d-0000-4000-8000-00000000000")); + assert!(!is_uuid_shaped("not-a-uuid")); + assert!(!is_uuid_shaped("../../../../etc/passwd")); + } + + // ── home swap ──────────────────────────────────────────────────── + + #[test] + fn home_swap_maps_subdirectories() { + assert_eq!( + home_swap_suffix(Path::new("/home/r/work/proj"), Path::new("/home/r")), + Some("work/proj".to_string()) + ); + } + + #[test] + fn home_swap_of_home_itself_is_empty() { + assert_eq!( + home_swap_suffix(Path::new("/home/r"), Path::new("/home/r")), + Some(String::new()) + ); + } + + #[test] + fn home_swap_outside_home_is_none() { + assert_eq!( + home_swap_suffix(Path::new("/srv/data"), Path::new("/home/r")), + None + ); + } + + #[test] + fn resolved_project_path_joins_home_and_suffix() { + let dir = RemoteDir::HomeRelative("work/proj".to_string()); + assert_eq!( + resolved_project_path(&dir, "/home/exedev"), + "/home/exedev/work/proj" + ); + let home_only = RemoteDir::HomeRelative(String::new()); + assert_eq!( + resolved_project_path(&home_only, "/home/exedev"), + "/home/exedev" + ); + let explicit = RemoteDir::Explicit("/data/proj".to_string()); + assert_eq!( + resolved_project_path(&explicit, "/home/exedev"), + "/data/proj" + ); + } + + // ── remote cwd validation ──────────────────────────────────────── + + #[test] + fn remote_cwd_must_be_absolute_without_dotdot() { + assert!(validate_remote_cwd("/home/exedev/proj").is_ok()); + assert!(validate_remote_cwd("relative/dir").is_err()); + assert!(validate_remote_cwd("/home/../etc").is_err()); + assert!(validate_remote_cwd("/a\n/b").is_err()); + } + + // ── preflight parse ────────────────────────────────────────────── + + fn output_with_stdout(stdout: &str) -> Output { + use std::os::unix::process::ExitStatusExt; + Output { + status: std::process::ExitStatus::from_raw(0), + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + } + } + + #[test] + fn preflight_parses_tagged_lines() { + let out = output_with_stdout( + "TP_HOME=/home/exedev\nTP_CLAUDE=/usr/bin/claude\nTP_TMUX=ok\nTP_PWD=/home/exedev/proj\nTP_SESSION=none\n", + ); + let pf = parse_preflight(&out).unwrap(); + assert_eq!(pf.home, "/home/exedev"); + assert_eq!(pf.claude, "/usr/bin/claude"); + assert!(pf.tmux_ok); + assert_eq!(pf.pwd.as_deref(), Some("/home/exedev/proj")); + assert!(!pf.session_live); + } + + #[test] + fn preflight_missing_dir_and_live_session_decode() { + let out = output_with_stdout( + "TP_HOME=/h\nTP_CLAUDE=\nTP_TMUX=missing\nTP_PWD=\nTP_SESSION=live\n", + ); + let pf = parse_preflight(&out).unwrap(); + assert!(pf.claude.is_empty()); + assert!(!pf.tmux_ok); + assert!(pf.pwd.is_none()); + assert!(pf.session_live); + } + + #[test] + fn preflight_banner_is_rejected_verbatim() { + let banner = "Please complete registration at https://exe.dev to continue."; + let err = parse_preflight(&output_with_stdout(banner)).unwrap_err(); + let s = err.to_string(); + assert!(s.contains("banner"), "actual: {s}"); + assert!(s.contains(banner), "verbatim output missing: {s}"); + } + + #[test] + fn preflight_script_is_read_only_and_quotes_the_dir() { + let dir = RemoteDir::Explicit("/data/it's".to_string()); + let script = preflight_script(&dir, "path-abcd1234"); + assert!(script.contains("pwd -P")); + assert!(script.contains("'/data/it'\\''s'")); + assert!(script.contains("has-session -t '=path-abcd1234'")); + for verb in ["mkdir", ">", "rm ", "touch"] { + assert!( + !script.contains(&format!("\n{verb}")), + "preflight must be read-only; found {verb}" + ); + } + } + + #[test] + fn preflight_script_home_relative_dir_uses_remote_home() { + let dir = RemoteDir::HomeRelative("work/proj".to_string()); + let script = preflight_script(&dir, "path-x"); + assert!( + script.contains(r#"cd "$HOME"'/work/proj'"#), + "script:\n{script}" + ); + let home_only = preflight_script(&RemoteDir::HomeRelative(String::new()), "path-x"); + assert!(home_only.contains(r#"cd "$HOME" "#), "script:\n{home_only}"); + } + + // ── remote command construction ────────────────────────────────── + + #[test] + fn ship_command_makes_dir_and_writes_under_umask() { + let cmd = ship_command( + "/home/e/.claude/projects/-home-e-proj", + "/home/e/.claude/projects/-home-e-proj/abc.jsonl", + ); + assert_eq!( + cmd, + "umask 077; mkdir -p '/home/e/.claude/projects/-home-e-proj' && \ + cat > '/home/e/.claude/projects/-home-e-proj/abc.jsonl'" + ); + } + + #[test] + fn attach_command_pins_the_exact_session_name() { + assert_eq!( + attach_command("path-abcd1234"), + "tmux attach-session -d -t '=path-abcd1234'" + ); + } + + #[test] + fn launch_command_nests_quoting_for_tmux() { + let cmd = launch_command("path-abcd1234", "/home/e/proj", "/usr/bin/claude", "id-1"); + assert_eq!( + cmd, + "tmux new-session -d -s 'path-abcd1234' -c '/home/e/proj' \ + 'env LANG=C.UTF-8 '\\''/usr/bin/claude'\\'' -r '\\''id-1'\\'''" + ); + } +} diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 43cbbd29..12dc2ef5 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -36,6 +36,8 @@ mod fuzzy; pub mod harness; mod io; pub mod kinds; +#[cfg(not(target_os = "emscripten"))] +mod projection; mod providers; mod query; mod remote; diff --git a/crates/path-cli/src/projection.rs b/crates/path-cli/src/projection.rs new file mode 100644 index 00000000..10f54927 --- /dev/null +++ b/crates/path-cli/src/projection.rs @@ -0,0 +1,32 @@ +//! In-memory Claude projection: `Path` → `toolpath_claude::Conversation` +//! → resume-ready JSONL. Shared by `p export claude` and +//! `path resume --remote`; cmd modules consume it, never the other way +//! around. + +use anyhow::Result; + +pub(crate) fn build_claude_conversation( + path: &toolpath::v1::Path, +) -> Result { + use toolpath_convo::ConversationProjector; + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_claude::ClaudeProjector; + projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e)) +} + +pub(crate) fn serialize_jsonl(conv: &toolpath_claude::Conversation) -> Result { + let mut lines = Vec::with_capacity(conv.preamble.len() + conv.entries.len()); + for raw in &conv.preamble { + lines.push(serde_json::to_string(raw)?); + } + for entry in &conv.entries { + lines.push(serde_json::to_string(entry)?); + } + // Trailing newline matters: Claude Code appends to this file on resume, + // and without it the first appended entry lands on the last line. + let mut out = lines.join("\n"); + out.push('\n'); + Ok(out) +} diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..3acbc83e 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -262,6 +262,8 @@ fn cache_id_input_loads_and_projects() { input: cache_id.to_string(), cwd: Some(cwd.path().to_path_buf()), harness: Some(Harness::Claude), + remote: None, + dry_run: false, no_cache: false, force: false, url: None, diff --git a/crates/path-cli/tests/resume_remote.rs b/crates/path-cli/tests/resume_remote.rs new file mode 100644 index 00000000..d62abe9e --- /dev/null +++ b/crates/path-cli/tests/resume_remote.rs @@ -0,0 +1,548 @@ +//! Integration tests for `path resume --remote`. +//! +//! Tests dispatch through `path_cli::cmd_resume::remote::run_remote` with a +//! fake `ssh` shim on the injected search path (see +//! [`support::SshShim`]) and a `RecordingExec` for the attach step. The +//! shim records argv and stdin per invocation and replies with scripted +//! stdout, so every remote interaction is asserted without a network. + +#![cfg(not(target_os = "emscripten"))] +#![cfg(unix)] + +use path_cli::cmd_resume::RecordingExec; +use path_cli::cmd_resume::remote::{mint_remote_id, run_remote, tmux_session_name}; +use path_cli::harness::Harness; + +mod support; +use support::*; + +/// A tempdir standing in for the local home. `run_remote` takes the +/// home and the local cwd as parameters, so no environment scoping is +/// needed. +struct TestHome { + dir: tempfile::TempDir, +} + +impl TestHome { + fn new() -> Self { + Self { + dir: tempfile::tempdir().unwrap(), + } + } + + fn home_dir(&self) -> std::path::PathBuf { + self.dir.path().to_path_buf() + } +} + +const REMOTE: &str = "exedev@testhost"; +const REMOTE_HOME: &str = "/home/exedev"; +const REMOTE_CLAUDE: &str = "/usr/local/bin/claude"; +const PROJECT: &str = "/data/proj"; + +/// The five preflight fact lines a healthy remote reports. +fn preflight_ok(pwd: &str, session: &str) -> String { + format!( + "TP_HOME={REMOTE_HOME}\nTP_CLAUDE={REMOTE_CLAUDE}\nTP_TMUX=ok\nTP_PWD={pwd}\nTP_SESSION={session}\n" + ) +} + +/// A doc file plus the sandbox it lives in. +fn claude_doc(dir: &std::path::Path) -> std::path::PathBuf { + let path = make_convo_path("agent:claude-code", "claude-code://remote-int-session"); + write_path_to_temp(dir, path) +} + +/// The JSONL `run_remote` ships: the projected conversation with the +/// minted id and the remote project directory applied. +fn expected_shipped_jsonl(doc_file: &std::path::Path, remote_id: &str, project: &str) -> String { + use toolpath_convo::ConversationProjector; + let json = std::fs::read_to_string(doc_file).unwrap(); + let graph = toolpath::v1::Graph::from_json(&json).unwrap(); + let path = graph.single_path().unwrap(); + let view = toolpath_convo::extract_conversation(path); + let mut conv = toolpath_claude::ClaudeProjector.project(&view).unwrap(); + conv.set_session_id_and_cwd(remote_id, project); + let mut lines: Vec = conv + .preamble + .iter() + .map(|r| serde_json::to_string(r).unwrap()) + .collect(); + lines.extend( + conv.entries + .iter() + .map(|e| serde_json::to_string(e).unwrap()), + ); + let mut out = lines.join("\n"); + out.push('\n'); + out +} + +fn source_session_tmux_name() -> String { + tmux_session_name("remote-int-session") +} + +// ── Mint stability across input serializations ────────────────────── + +#[test] +fn same_document_mints_the_same_id_across_serializations() { + let docs = tempfile::tempdir().unwrap(); + let compact_file = claude_doc(docs.path()); + let compact = std::fs::read_to_string(&compact_file).unwrap(); + let value: serde_json::Value = serde_json::from_str(&compact).unwrap(); + let pretty_file = docs.path().join("pretty.json"); + std::fs::write(&pretty_file, serde_json::to_string_pretty(&value).unwrap()).unwrap(); + + let mut ship_commands = Vec::new(); + for doc in [&compact_file, &pretty_file] { + let home = TestHome::new(); + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "none")); + let recorder = RecordingExec::default(); + run_remote( + &args_remote(doc.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap(); + ship_commands.push(shim.argv(1)[1].clone()); + } + assert_eq!( + ship_commands[0], ship_commands[1], + "the minted id must depend on the parsed document, not the input bytes" + ); +} + +// ── Fresh push: preflight, ship, launch, attach ───────────────────── + +#[test] +fn fresh_push_ships_launches_and_attaches() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "none")); + + let recorder = RecordingExec::default(); + run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap(); + + assert_eq!(shim.calls(), 3, "preflight, ship, launch"); + + // Preflight: one batched call, remote first, script second. + let preflight = shim.argv(0); + assert_eq!(preflight[0], REMOTE); + assert_eq!(preflight.len(), 2); + assert!(preflight[1].contains("pwd -P")); + assert!(preflight[1].contains("has-session")); + + // Ship: the exact command, and byte-identical JSONL on stdin. + let raw_json = std::fs::read_to_string(&doc_file).unwrap(); + let graph = toolpath::v1::Graph::from_json(&raw_json).unwrap(); + let canonical = serde_json::to_string(&serde_json::to_value(&graph).unwrap()).unwrap(); + let remote_id = mint_remote_id(&canonical); + let resolver = toolpath_claude::PathResolver::new().with_home(REMOTE_HOME); + let slug_dir = resolver.project_dir(PROJECT).unwrap(); + let target = resolver.conversation_file(PROJECT, &remote_id).unwrap(); + let ship = shim.argv(1); + assert_eq!(ship[0], REMOTE); + assert_eq!( + ship[1], + format!( + "umask 077; mkdir -p '{}' && cat > '{}'", + slug_dir.display(), + target.display() + ) + ); + let expected = expected_shipped_jsonl(&doc_file, &remote_id, PROJECT); + assert_eq!( + String::from_utf8(shim.stdin_bytes(1)).unwrap(), + expected, + "shipped JSONL must be byte-identical to the rewritten projection" + ); + + // Launch: detached tmux session running claude on the minted id. + let launch = shim.argv(2); + assert_eq!(launch[0], REMOTE); + let name = source_session_tmux_name(); + assert_eq!( + launch[1], + format!( + "tmux new-session -d -s '{name}' -c '{PROJECT}' \ + 'env LANG=C.UTF-8 '\\''{REMOTE_CLAUDE}'\\'' -r '\\''{remote_id}'\\'''" + ) + ); + + // Attach: exec'd with a pty through the strategy. + let cap = recorder.captured(); + assert_eq!(cap.binary, shim.ssh_path().to_string_lossy()); + assert_eq!( + cap.args, + vec![ + "-t".to_string(), + REMOTE.to_string(), + format!("tmux attach-session -d -t '={name}'"), + ] + ); +} + +// ── Reattach shortcut ─────────────────────────────────────────────── + +#[test] +fn live_session_reattaches_without_ship_or_launch() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "live")); + + let recorder = RecordingExec::default(); + run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap(); + + assert_eq!(shim.calls(), 1, "preflight only; no ship, no launch"); + let cap = recorder.captured(); + assert_eq!(cap.binary, shim.ssh_path().to_string_lossy()); + assert!(cap.args[2].contains("attach-session")); +} + +// ── Dry run ───────────────────────────────────────────────────────── + +#[test] +fn dry_run_runs_preflight_and_nothing_else() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "none")); + + let recorder = RecordingExec::default(); + run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), true), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap(); + + assert_eq!(shim.calls(), 1, "preflight only"); + assert!( + recorder.captured().binary.is_empty(), + "dry run must not attach" + ); +} + +// ── Preflight failures ────────────────────────────────────────────── + +fn run_expecting_err(shim: &SshShim, cwd: Option<&str>) -> anyhow::Error { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let recorder = RecordingExec::default(); + run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, cwd, false), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err() +} + +#[test] +fn preflight_missing_claude_errors_with_probe_list() { + let shim = SshShim::new(); + shim.respond( + 0, + &format!( + "TP_HOME={REMOTE_HOME}\nTP_CLAUDE=\nTP_TMUX=ok\nTP_PWD={PROJECT}\nTP_SESSION=none\n" + ), + ); + let err = run_expecting_err(&shim, Some(PROJECT)); + let s = err.to_string(); + assert!(s.contains("claude not found"), "actual: {s}"); + assert!(s.contains(".npm-global"), "actual: {s}"); + assert_eq!(shim.calls(), 1); +} + +#[test] +fn preflight_missing_tmux_errors() { + let shim = SshShim::new(); + shim.respond( + 0, + &format!( + "TP_HOME={REMOTE_HOME}\nTP_CLAUDE={REMOTE_CLAUDE}\nTP_TMUX=missing\nTP_PWD={PROJECT}\nTP_SESSION=none\n" + ), + ); + let err = run_expecting_err(&shim, Some(PROJECT)); + assert!(err.to_string().contains("tmux not found"), "actual: {err}"); + assert_eq!(shim.calls(), 1); +} + +#[test] +fn preflight_missing_project_dir_errors() { + let shim = SshShim::new(); + shim.respond(0, &preflight_ok("", "none")); + let err = run_expecting_err(&shim, Some(PROJECT)); + let s = err.to_string(); + assert!(s.contains("does not exist"), "actual: {s}"); + assert!(s.contains(PROJECT), "actual: {s}"); + assert_eq!(shim.calls(), 1); +} + +#[test] +fn preflight_symlinked_project_dir_names_the_physical_path() { + let shim = SshShim::new(); + shim.respond(0, &preflight_ok("/data/real-proj", "none")); + let err = run_expecting_err(&shim, Some("/data/proj")); + let s = err.to_string(); + assert!(s.contains("physical"), "actual: {s}"); + assert!(s.contains("-C /data/real-proj"), "actual: {s}"); + assert_eq!(shim.calls(), 1, "veto only; no ship after the mismatch"); +} + +#[test] +fn preflight_banner_output_is_rejected_verbatim() { + let banner = "Please complete registration with `ssh exe.dev` first."; + let shim = SshShim::new(); + shim.respond(0, banner); + let err = run_expecting_err(&shim, Some(PROJECT)); + let s = format!("{err:#}"); + assert!(s.contains("banner"), "actual: {s}"); + assert!(s.contains(banner), "verbatim output missing: {s}"); + assert_eq!(shim.calls(), 1, "nothing shipped after a banner reply"); +} + +#[test] +fn unreachable_remote_errors_with_ssh_status() { + let shim = SshShim::new(); + shim.exit_with(0, 255); + let err = run_expecting_err(&shim, Some(PROJECT)); + let s = err.to_string(); + assert!(s.contains("ssh to"), "actual: {s}"); + assert_eq!(shim.calls(), 1); +} + +// ── Early argument errors: zero remote touches ────────────────────── + +#[test] +fn non_tty_stdin_errors_before_any_remote_work() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + + let recorder = RecordingExec::default(); + let err = run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + false, + ) + .unwrap_err(); + + assert!(err.to_string().contains("TTY"), "actual: {err}"); + assert_eq!(shim.calls(), 0, "no remote touches on a non-TTY error"); +} + +#[test] +fn option_shaped_remote_errors_before_any_remote_work() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + + let err = run_remote( + &args_remote( + doc_file.to_str().unwrap(), + "-oProxyCommand=evil", + Some(PROJECT), + false, + ), + &RecordingExec::default(), + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err(); + + assert!(err.to_string().contains("ssh destination"), "actual: {err}"); + assert_eq!(shim.calls(), 0); +} + +#[test] +fn non_claude_harness_flag_errors_before_any_remote_work() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + + let mut args = args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false); + args.harness = Some(Harness::Codex); + let err = run_remote( + &args, + &RecordingExec::default(), + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err(); + + assert!( + err.to_string().contains("supports claude only"), + "actual: {err}" + ); + assert_eq!(shim.calls(), 0); +} + +#[test] +fn non_claude_source_document_errors_before_any_remote_work() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let path = make_convo_path("agent:gemini-cli", "gemini-cli://remote-int-gemini"); + let doc_file = write_path_to_temp(docs.path(), path); + let shim = SshShim::new(); + + let err = run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &RecordingExec::default(), + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err(); + + assert!( + err.to_string().contains("supports claude only"), + "actual: {err}" + ); + assert_eq!(shim.calls(), 0); +} + +#[test] +fn bad_input_errors_before_any_remote_work() { + let home = TestHome::new(); + let shim = SshShim::new(); + + let err = run_remote( + &args_remote("definitely-not-a-cache-id", REMOTE, Some(PROJECT), false), + &RecordingExec::default(), + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err(); + + assert!( + err.to_string().contains("couldn't resolve"), + "actual: {err}" + ); + assert_eq!(shim.calls(), 0); +} + +#[test] +fn relative_cwd_errors_before_any_remote_work() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + + let err = run_remote( + &args_remote( + doc_file.to_str().unwrap(), + REMOTE, + Some("relative/dir"), + false, + ), + &RecordingExec::default(), + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err(); + + assert!(err.to_string().contains("absolute"), "actual: {err}"); + assert_eq!(shim.calls(), 0); +} + +#[test] +fn dotdot_cwd_errors_before_any_remote_work() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + + let err = run_remote( + &args_remote( + doc_file.to_str().unwrap(), + REMOTE, + Some("/data/../etc"), + false, + ), + &RecordingExec::default(), + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err(); + + assert!(err.to_string().contains(".."), "actual: {err}"); + assert_eq!(shim.calls(), 0); +} + +// ── Idempotence across invocations ────────────────────────────────── + +#[test] +fn re_running_the_same_push_mints_the_same_id_and_target() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + + let mut ship_cmds = Vec::new(); + for _ in 0..2 { + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "none")); + run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &RecordingExec::default(), + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap(); + ship_cmds.push(shim.argv(1)[1].clone()); + } + assert_eq!( + ship_cmds[0], ship_cmds[1], + "unchanged content must target the same remote file" + ); +} diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index bf7597ba..681fb004 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -184,6 +184,112 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs input: input.to_string_lossy().to_string(), cwd: Some(cwd.to_path_buf()), harness: Some(harness), + remote: None, + dry_run: false, + no_cache: false, + force: false, + url: None, + } +} + +/// A fake `ssh` binary on an injectable search path. It records argv +/// (NUL-separated, so multi-line script arguments survive) to +/// `argv-` and stdin to `stdin-` in the record dir, replies with +/// the contents of `response-` when present, and exits with the +/// code in `exit-` (default 0). `` counts invocations from 0. +pub struct SshShim { + bin: tempfile::TempDir, + records: tempfile::TempDir, +} + +impl SshShim { + pub fn new() -> Self { + let bin = tempfile::tempdir().unwrap(); + let records = tempfile::tempdir().unwrap(); + let script = format!( + r#"#!/bin/sh +d='{dir}' +n=0 +while [ -e "$d/argv-$n" ]; do n=$((n+1)); done +: > "$d/argv-$n" +for a in "$@"; do printf '%s\0' "$a" >> "$d/argv-$n"; done +cat > "$d/stdin-$n" +if [ -e "$d/response-$n" ]; then cat "$d/response-$n"; fi +if [ -e "$d/exit-$n" ]; then exit "$(cat "$d/exit-$n")"; fi +exit 0 +"#, + dir = records.path().display() + ); + let p = bin.path().join("ssh"); + std::fs::write(&p, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&p).unwrap().permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&p, perm).unwrap(); + } + Self { bin, records } + } + + pub fn ssh_path(&self) -> PathBuf { + self.bin.path().join("ssh") + } + + pub fn search_path(&self) -> Vec { + vec![self.bin.path().to_path_buf()] + } + + /// Stdout the shim prints on invocation `call`. + pub fn respond(&self, call: usize, body: &str) { + std::fs::write(self.records.path().join(format!("response-{call}")), body).unwrap(); + } + + /// Exit code the shim returns on invocation `call`. + pub fn exit_with(&self, call: usize, code: i32) { + std::fs::write( + self.records.path().join(format!("exit-{call}")), + code.to_string(), + ) + .unwrap(); + } + + /// Number of recorded invocations. + pub fn calls(&self) -> usize { + (0..) + .take_while(|n| self.records.path().join(format!("argv-{n}")).exists()) + .count() + } + + /// Recorded argv of invocation `call` (without the binary name). + pub fn argv(&self, call: usize) -> Vec { + let raw = std::fs::read(self.records.path().join(format!("argv-{call}"))).unwrap(); + raw.split(|b| *b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8(s.to_vec()).unwrap()) + .collect() + } + + /// Recorded stdin bytes of invocation `call`. + pub fn stdin_bytes(&self, call: usize) -> Vec { + std::fs::read(self.records.path().join(format!("stdin-{call}"))).unwrap() + } +} + +impl Default for SshShim { + fn default() -> Self { + Self::new() + } +} + +/// Construct `ResumeArgs` for a `--remote` test. +pub fn args_remote(input: &str, remote: &str, cwd: Option<&str>, dry_run: bool) -> ResumeArgs { + ResumeArgs { + input: input.to_string(), + cwd: cwd.map(PathBuf::from), + harness: None, + remote: Some(remote.to_string()), + dry_run, no_cache: false, force: false, url: None, diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index acb66c2f..a5c1caf3 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.2" +version = "0.12.3" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/paths.rs b/crates/toolpath-claude/src/paths.rs index cd3331a8..c14e92ee 100644 --- a/crates/toolpath-claude/src/paths.rs +++ b/crates/toolpath-claude/src/paths.rs @@ -189,6 +189,44 @@ mod tests { ); } + #[test] + fn test_conversation_file_foreign_home() { + // A resolver over another machine's home computes that machine's + // session-file layout; nothing here touches the local filesystem. + let resolver = PathResolver::new().with_home("/home/exedev"); + assert_eq!( + resolver + .conversation_file( + "/home/exedev/work/my_repo.rs", + "0a1b2c3d-0000-4000-8000-000000000000" + ) + .unwrap(), + PathBuf::from( + "/home/exedev/.claude/projects/-home-exedev-work-my-repo-rs/0a1b2c3d-0000-4000-8000-000000000000.jsonl" + ) + ); + } + + #[test] + fn test_conversation_file_does_not_canonicalize() { + // The resolver slugs the string it is given: a symlinked logical + // path and its physical target map to different project dirs, so + // callers must pass the physical cwd. + let resolver = PathResolver::new().with_home("/Users/exedev"); + let physical = resolver + .conversation_file("/private/var/proj", "s1") + .unwrap(); + let logical = resolver.conversation_file("/var/proj", "s1").unwrap(); + assert_eq!( + physical, + PathBuf::from("/Users/exedev/.claude/projects/-private-var-proj/s1.jsonl") + ); + assert_eq!( + logical, + PathBuf::from("/Users/exedev/.claude/projects/-var-proj/s1.jsonl") + ); + } + #[test] fn test_list_projects() { let temp = TempDir::new().unwrap(); diff --git a/crates/toolpath-claude/src/types.rs b/crates/toolpath-claude/src/types.rs index 9be71b90..d819d0b9 100644 --- a/crates/toolpath-claude/src/types.rs +++ b/crates/toolpath-claude/src/types.rs @@ -515,6 +515,40 @@ impl Conversation { }) } + /// Rewrites the conversation to a new session id and working + /// directory, in place. + /// + /// Sets the conversation-level `session_id` and sets `session_id` + /// on every entry. Sets `project_path`. Replaces each entry's + /// `cwd` only where it is present. Rewrites the top-level + /// `sessionId` and `cwd` keys in preamble raw lines. Clears + /// `session_ids`: the result is a single new session, not a + /// segment chain. Message content and tool-result payloads are + /// untouched, so paths quoted inside them keep referring to the + /// source machine. + pub fn set_session_id_and_cwd(&mut self, session_id: &str, cwd: &str) { + self.session_id = session_id.to_string(); + self.session_ids.clear(); + self.project_path = Some(cwd.to_string()); + for entry in &mut self.entries { + entry.session_id = Some(session_id.to_string()); + if entry.cwd.is_some() { + entry.cwd = Some(cwd.to_string()); + } + } + for raw in &mut self.preamble { + let Some(obj) = raw.as_object_mut() else { + continue; + }; + if let Some(v) = obj.get_mut("sessionId") { + *v = Value::String(session_id.to_string()); + } + if let Some(v) = obj.get_mut("cwd") { + *v = Value::String(cwd.to_string()); + } + } + } + /// Full text of the first user message, untruncated. pub fn first_user_text(&self) -> Option { self.entries.iter().find_map(|e| { @@ -1180,4 +1214,118 @@ mod tests { let convo = Conversation::new("empty".to_string()); assert!(convo.title(50).is_none()); } + + // ── Conversation::set_session_id_and_cwd ───────────────────────────────────────── + + fn conversation_for_rewrite() -> Conversation { + let mut convo = Conversation::new("old-session".to_string()); + convo.preamble = vec![ + serde_json::json!({ + "type": "permission-mode", + "permissionMode": "default", + "sessionId": "old-session", + }), + serde_json::json!({ + "type": "file-history-snapshot", + "cwd": "/old/project", + "snapshot": {"cwd": "/old/project"}, + }), + serde_json::json!({"type": "ai-title", "title": "old-session"}), + ]; + + let entries = vec![ + // Full envelope: sessionId and cwd present. + r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"old-session","cwd":"/old/project","message":{"role":"user","content":"Run ls in /old/project"}}"#, + // No cwd. + r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"old-session","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls /old/project"}}]}}"#, + // No sessionId, no cwd; carries a tool-result payload. + r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","toolUseResult":{"stdout":"/old/project/file.rs"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"/old/project/file.rs"}]}}"#, + ]; + for entry_json in entries { + convo.add_entry(serde_json::from_str(entry_json).unwrap()); + } + convo + } + + #[test] + fn test_set_session_id_and_cwd_everywhere() { + let mut convo = conversation_for_rewrite(); + convo.session_ids = vec!["old-a".to_string(), "old-b".to_string()]; + convo.set_session_id_and_cwd("new-session", "/new/project"); + + assert_eq!(convo.session_id, "new-session"); + assert!(convo.session_ids.is_empty()); + for entry in &convo.entries { + assert_eq!(entry.session_id.as_deref(), Some("new-session")); + } + } + + #[test] + fn test_cwd_replaced_only_where_present() { + let mut convo = conversation_for_rewrite(); + convo.set_session_id_and_cwd("new-session", "/new/project"); + + assert_eq!(convo.project_path.as_deref(), Some("/new/project")); + assert_eq!(convo.entries[0].cwd.as_deref(), Some("/new/project")); + assert!(convo.entries[1].cwd.is_none()); + assert!(convo.entries[2].cwd.is_none()); + } + + #[test] + fn test_project_path_set_when_absent() { + let mut convo = Conversation::new("old-session".to_string()); + convo.set_session_id_and_cwd("new-session", "/new/project"); + + assert_eq!(convo.session_id, "new-session"); + assert_eq!(convo.project_path.as_deref(), Some("/new/project")); + } + + #[test] + fn test_preamble_top_level_keys_rewritten() { + let mut convo = conversation_for_rewrite(); + let untouched_line = convo.preamble[2].clone(); + convo.set_session_id_and_cwd("new-session", "/new/project"); + + assert_eq!(convo.preamble[0]["sessionId"], "new-session"); + assert_eq!(convo.preamble[0]["permissionMode"], "default"); + assert_eq!(convo.preamble[1]["cwd"], "/new/project"); + // Top-level keys only: the nested snapshot keeps its cwd. + assert_eq!(convo.preamble[1]["snapshot"]["cwd"], "/old/project"); + // A line without sessionId or cwd is untouched, even where a + // value happens to equal the old session id. + assert_eq!(convo.preamble[2], untouched_line); + } + + #[test] + fn test_message_content_untouched() { + let convo = conversation_for_rewrite(); + let messages_before: Vec<_> = convo + .entries + .iter() + .map(|e| serde_json::to_value(&e.message).unwrap()) + .collect(); + let results_before: Vec<_> = convo + .entries + .iter() + .map(|e| e.tool_use_result.clone()) + .collect(); + + let mut convo = convo; + convo.set_session_id_and_cwd("new-session", "/new/project"); + + let messages_after: Vec<_> = convo + .entries + .iter() + .map(|e| serde_json::to_value(&e.message).unwrap()) + .collect(); + let results_after: Vec<_> = convo + .entries + .iter() + .map(|e| e.tool_use_result.clone()) + .collect(); + assert_eq!(messages_before, messages_after); + assert_eq!(results_before, results_after); + // Paths quoted in content still name the source machine. + assert!(convo.entries[0].text().contains("/old/project")); + } } diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index a3aa6782..31b0dd21 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.18.0" +version = "0.19.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.18.0" } +path-cli = { path = "../path-cli", version = "0.19.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index da0fcd41..b6d90df7 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.2", + "version": "0.12.3", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.18.0", + "version": "0.19.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.18.0", + "version": "0.19.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli",