diff --git a/CHANGELOG.md b/CHANGELOG.md index 2320fb26..c5480cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to the Toolpath workspace are documented here. +## path-cli 0.21.0 — 2026-08-27 + +- **`path-cli`** (0.21.0): `path resume` takes `--remote ` + (Claude only) and `--dry-run` behind the `resume-remote` cargo + feature. The command plans a resume on an ssh host: two read-only + ssh calls report the remote home, the claude path, tmux, the physical + project directory, the tmux session state, and the session file's + existence. `-C` names the remote project directory; the default is + the local cwd with the local home swapped for the remote home. The + session ID is the one `p export claude --derive-session-id` derives. + The plan prints what a run does (attach, launch, or ship). + `--dry-run` stops after the plan; without it the command stops with + an error, because ship, launch, and attach are not implemented yet. + The transport is a new internal ssh module, an in-process SSH client + (`russh`) compiled only with the feature; each probe has a 60s + wall-clock timeout. +- **`toolpath-cli`** (0.21.0): lockstep bump of the deprecated shim. + +## toolpath-claude 0.13.3 — 2026-08-28 + +- **`toolpath-claude`** (0.13.3): `sanitize_project_path(path)` is + public. It returns the directory name under `~/.claude/projects` for + a project path: `/`, `_`, and `.` become `-`. + ## path-cli 0.20.0 — 2026-08-27 - **`path-cli`** (0.20.0): `p export claude` takes `--derive-session-id` diff --git a/CLAUDE.md b/CLAUDE.md index 6620aa34..d7fc79ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`); provider crates also h - `toolpath-claude` has a `watcher` feature (default: on) gating `notify`/`tokio` dependencies for filesystem watching - `toolpath-gemini` has a `watcher` feature (default: on) gating the polling-based `ConversationWatcher` module -- `path-cli` has `embedded-picker` (default: on; the skim picker) and `resume-remote` (default: off) gating the `p export claude` flags `--derive-session-id` and `--cwd` and the code behind them (`crates/path-cli/src/cmd_export/remote_session.rs`); `scripts/resume-remote.sh` builds with it. The gate is `all(feature = "resume-remote", not(target_os = "emscripten"))`: the feature has no effect on the wasm build. Test both states: `cargo test -p path-cli` and `cargo test -p path-cli --features resume-remote`. +- `path-cli` has `embedded-picker` (default: on; the skim picker) and `resume-remote` (default: off) gating the `p export claude` flags `--derive-session-id` and `--cwd`, `path resume --remote` and `--dry-run`, and the code behind them (`crates/path-cli/src/claude_session.rs`, `crates/path-cli/src/cmd_export/remote_session.rs`, `crates/path-cli/src/ssh.rs`, `crates/path-cli/src/cmd_resume/remote.rs` and its two `.sh` probe scripts); the `russh` and `shlex` dependencies are optional on it; `scripts/resume-remote.sh` builds with it. The gate is `all(feature = "resume-remote", not(target_os = "emscripten"))`: the feature has no effect on the wasm build. Test both states: `cargo test -p path-cli` and `cargo test -p path-cli --features resume-remote`. ## Desktop app diff --git a/Cargo.lock b/Cargo.lock index 36819c53..14880401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,43 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -124,6 +161,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.6.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -179,6 +228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -209,12 +259,35 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.11.0", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -260,13 +333,51 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher", ] [[package]] @@ -304,6 +415,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -319,6 +436,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.61" @@ -349,6 +475,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -363,6 +502,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.1" @@ -431,6 +582,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "color-eyre" version = "0.6.5" @@ -499,6 +656,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -534,6 +697,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -543,6 +712,24 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -598,16 +785,54 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -618,6 +843,53 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.20.11" @@ -725,12 +997,34 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deltae" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -794,6 +1088,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher", +] + [[package]] name = "difflib" version = "0.4.0" @@ -806,8 +1109,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -854,12 +1169,75 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -884,6 +1262,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -958,6 +1348,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "figment" version = "0.10.19" @@ -1013,6 +1419,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -1193,6 +1609,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1227,10 +1654,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", + "zeroize", ] [[package]] @@ -1254,6 +1694,17 @@ dependencies = [ "url", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1329,12 +1780,36 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hifijson" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "242402749acf71e6f32f5857598b7002c4058a4e3c3b22b4c7d51cab9aea754e" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.4.0" @@ -1374,6 +1849,18 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1627,6 +2114,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "insta" version = "1.47.2" @@ -1661,6 +2158,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "internal-russh-num-bigint" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", +] + [[package]] name = "interprocess" version = "2.4.2" @@ -1921,6 +2430,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2111,6 +2640,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.0" @@ -2166,6 +2701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2180,6 +2716,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "nix" version = "0.28.0" @@ -2462,6 +3023,67 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3adadc44070da6f464b0918655a12f5792c156e088d8c4082d13e27d94c3e791" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.2", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "windows", + "windows-strings", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2485,9 +3107,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "path-cli" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "assert_cmd", @@ -2508,10 +3139,12 @@ dependencies = [ "regex", "reqwest", "rusqlite", + "russh", "serde", "serde_json", "serde_json_canonicalizer", - "sha2", + "sha2 0.10.9", + "shlex", "similar", "skim", "tempfile", @@ -2551,6 +3184,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac", +] + [[package]] name = "pear" version = "0.2.9" @@ -2574,6 +3217,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2619,8 +3271,18 @@ version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ - "pest", - "sha2", + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", ] [[package]] @@ -2681,6 +3343,45 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "der", + "pbkdf2", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.10.1", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2693,6 +3394,29 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2793,6 +3517,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2999,6 +3750,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -3024,6 +3786,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -3289,6 +4057,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + [[package]] name = "ring" version = "0.17.14" @@ -3299,7 +4077,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3323,6 +4101,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3337,6 +4134,102 @@ dependencies = [ "smallvec", ] +[[package]] +name = "russh" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bab1b87d915817d5d9cc352637cd40d5f0b298a48c6309af9156a4addc3031" +dependencies = [ + "aes", + "aws-lc-rs", + "bitflags 2.11.1", + "block-padding", + "byteorder", + "bytes", + "cbc", + "cipher", + "crypto-bigint", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.2", + "getrandom 0.4.2", + "ghash", + "hex-literal", + "hmac", + "inout", + "internal-russh-num-bigint", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval", + "rand 0.10.2", + "rand_core 0.10.1", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.12.0", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.18", + "tokio", + "typenum", + "universal-hash", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -3443,7 +4336,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3464,6 +4357,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3514,6 +4417,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3664,6 +4593,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serial2" version = "0.2.37" @@ -3682,8 +4621,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -3693,8 +4643,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -3768,6 +4750,22 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3859,6 +4857,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "chacha20", + "cipher", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4032,7 +5105,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -4244,7 +5317,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.13.2" +version = "0.13.3" dependencies = [ "anyhow", "chrono", @@ -4310,7 +5383,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "toolpath", @@ -4332,7 +5405,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "toolpath", @@ -4381,7 +5454,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "thiserror 1.0.69", "toolpath", @@ -4644,12 +5717,28 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -4964,7 +6053,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -5064,6 +6153,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -5077,6 +6187,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5105,6 +6226,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -5194,6 +6325,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5399,6 +6539,17 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.3" @@ -5477,9 +6628,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index dd8f2007..5bff6ff0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.1", 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.13.2", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.13.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.20.0", path = "crates/path-cli" } +path-cli = { version = "0.21.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 627d7ba2..adb8e3e9 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.20.0" +version = "0.21.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" @@ -57,6 +57,8 @@ pathbase-client = { workspace = true } git2 = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } +shlex = { version = "1.3", optional = true } +russh = { version = "0.63", optional = true } rusqlite = { workspace = true } rayon = { workspace = true } uuid = { workspace = true, features = ["v4"] } @@ -88,8 +90,10 @@ vendored-openssl = ["git2/vendored-openssl"] # (`--no-default-features`) for the minimal build. embedded-picker = ["dep:skim", "dep:regex"] # Remote resume (scripts/resume-remote.sh): experimental, off by -# default. Gates `p export claude --derive-session-id` and `--cwd`. -resume-remote = ["dep:serde_json_canonicalizer"] +# default. Gates `p export claude --derive-session-id` and `--cwd`, +# `path resume --remote` and `--dry-run`, and the ssh transport +# module. Pulls in the in-process SSH client. +resume-remote = ["dep:serde_json_canonicalizer", "dep:shlex", "dep:russh"] [dev-dependencies] assert_cmd = "2" diff --git a/crates/path-cli/src/claude_session.rs b/crates/path-cli/src/claude_session.rs new file mode 100644 index 00000000..efd9fba2 --- /dev/null +++ b/crates/path-cli/src/claude_session.rs @@ -0,0 +1,96 @@ +//! The identity of a Claude Code session file that `path` writes for +//! another host: the session ID derived from the document, and the +//! rules for the `cwd` the session is keyed on. `p export claude` and +//! `path resume --remote` share them. + +use anyhow::{Context, Result}; + +/// The session ID derived from the document `json`: a v4-shaped UUID +/// from the first 128 bits of the SHA-256 of its RFC 8785 (JCS) form. +/// Key order and whitespace in `json` do not change the ID. +pub(crate) fn session_id_from_document_hash(json: &str) -> Result { + use sha2::{Digest, Sha256}; + let document: serde_json::Value = + serde_json::from_str(json).context("Failed to parse toolpath document")?; + let canonical = serde_json_canonicalizer::to_string(&document).context("serialize document")?; + let digest = Sha256::digest(canonical.as_bytes()); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + Ok(uuid::Builder::from_random_bytes(bytes) + .into_uuid() + .to_string()) +} + +/// Claude Code keys a session on the exact `cwd` string, so the value +/// must be an absolute POSIX path in normalized form on one line: no +/// `.`, `..`, or empty component. One trailing `/` is dropped. The +/// directory may be on another machine, so it is not required to +/// exist. +pub(crate) fn parse_cwd_arg(raw: &str) -> Result { + let Some(rest) = raw.strip_prefix('/') else { + anyhow::bail!("the directory must be an absolute POSIX path (got {raw:?})"); + }; + if rest.contains('\n') { + anyhow::bail!("the directory must be a single line (got {raw:?})"); + } + if rest.is_empty() { + return Ok("/".to_string()); + } + let rest = rest.strip_suffix('/').unwrap_or(rest); + if rest + .split('/') + .any(|c| c.is_empty() || c == "." || c == "..") + { + anyhow::bail!( + "the directory must not contain an empty, `.`, or `..` component (got {raw:?})" + ); + } + Ok(format!("/{rest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_cwd_arg_rejects_unnormalized_paths() { + for bad in [ + "relative/dir", + "/a/../b", + "/a/./b", + "/a//b", + "//", + "", + "/a\nb", + ] { + assert!(parse_cwd_arg(bad).is_err(), "{bad:?}"); + } + assert_eq!(parse_cwd_arg("/a/b/").unwrap(), "/a/b"); + assert_eq!(parse_cwd_arg("/").unwrap(), "/"); + } + + /// A fixed document and the ID `session_id_from_document_hash` + /// returns for it. `DOC_REORDERED` is the same document with other + /// key order and whitespace. + const DOC: &str = r#"{"a":1,"b":{"c":[1,2],"d":"x"}}"#; + const DOC_REORDERED: &str = "{ \"b\": {\"d\": \"x\", \"c\": [1, 2]}, \"a\": 1 }"; + const DOC_DERIVED_ID: &str = "402a3ca5-2530-407e-9029-f96879adff54"; + + #[test] + fn session_id_from_document_hash_is_a_v4_uuid_of_the_key_sorted_document() { + let id = session_id_from_document_hash(DOC).unwrap(); + assert_eq!(id, DOC_DERIVED_ID); + assert_eq!( + session_id_from_document_hash(DOC_REORDERED).unwrap(), + DOC_DERIVED_ID + ); + assert_ne!( + session_id_from_document_hash(r#"{"a":2}"#).unwrap(), + DOC_DERIVED_ID + ); + let uuid = uuid::Uuid::parse_str(&id).unwrap(); + assert_eq!(uuid.get_version_num(), 4); + assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122); + assert!(session_id_from_document_hash("not json").is_err()); + } +} diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index aad6f3b7..4c22b62c 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -654,7 +654,7 @@ fn run_claude(args: ClaudeArgs) -> Result<()> { let conversation = { let mut conversation = conversation; if args.remote.derive_session_id { - let id = remote_session::session_id_from_document_hash(&document_json)?; + let id = crate::claude_session::session_id_from_document_hash(&document_json)?; conversation.rename_session(&id); } if let Some(dir) = &args.remote.cwd { @@ -3521,7 +3521,8 @@ mod tests { #[cfg(feature = "resume-remote")] mod resume_remote { use super::*; - use crate::cmd_export::remote_session::{RemoteSessionArgs, session_id_from_document_hash}; + use crate::claude_session::session_id_from_document_hash; + use crate::cmd_export::remote_session::RemoteSessionArgs; /// `make_path_doc` with `cwd` recorded on every step, plus one /// headerless line that carries a `cwd`. diff --git a/crates/path-cli/src/cmd_export/remote_session.rs b/crates/path-cli/src/cmd_export/remote_session.rs index f9852c0e..dd01ef23 100644 --- a/crates/path-cli/src/cmd_export/remote_session.rs +++ b/crates/path-cli/src/cmd_export/remote_session.rs @@ -1,7 +1,7 @@ //! `p export claude --derive-session-id` and `--cwd`: the session's ID //! and cwd on the host that resumes it. -use anyhow::{Context, Result}; +use crate::claude_session::parse_cwd_arg; /// The `p export claude` flags that rewrite the projected session /// before it is written. @@ -28,79 +28,3 @@ pub struct RemoteSessionArgs { #[arg(long, value_name = "DIR", conflicts_with = "project", value_parser = parse_cwd_arg)] pub(super) cwd: Option, } - -/// The session ID derived from the document `json`: a v4-shaped UUID -/// from the first 128 bits of the SHA-256 of its RFC 8785 (JCS) form. -/// Key order and whitespace in `json` do not change the ID. -pub(super) fn session_id_from_document_hash(json: &str) -> Result { - use sha2::{Digest, Sha256}; - let document: serde_json::Value = - serde_json::from_str(json).context("Failed to parse toolpath document")?; - let canonical = serde_json_canonicalizer::to_string(&document).context("serialize document")?; - let digest = Sha256::digest(canonical.as_bytes()); - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&digest[..16]); - Ok(uuid::Builder::from_random_bytes(bytes) - .into_uuid() - .to_string()) -} - -/// Claude Code keys a session on the exact `cwd` string, so the value -/// must be an absolute POSIX path in normalized form: no `.`, `..`, or -/// empty component. One trailing `/` is dropped. The directory may be -/// on another machine, so it is not required to exist. -fn parse_cwd_arg(raw: &str) -> Result { - let Some(rest) = raw.strip_prefix('/') else { - anyhow::bail!("--cwd must be an absolute POSIX path (got {raw:?})"); - }; - if rest.is_empty() { - return Ok("/".to_string()); - } - let rest = rest.strip_suffix('/').unwrap_or(rest); - if rest - .split('/') - .any(|c| c.is_empty() || c == "." || c == "..") - { - anyhow::bail!("--cwd must not contain an empty, `.`, or `..` component (got {raw:?})"); - } - Ok(format!("/{rest}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cwd_flag_rejects_unnormalized_paths() { - for bad in ["relative/dir", "/a/../b", "/a/./b", "/a//b", "//", ""] { - assert!(parse_cwd_arg(bad).is_err(), "{bad:?}"); - } - assert_eq!(parse_cwd_arg("/a/b/").unwrap(), "/a/b"); - assert_eq!(parse_cwd_arg("/").unwrap(), "/"); - } - - /// A fixed document and the ID `session_id_from_document_hash` - /// returns for it. `DOC_REORDERED` is the same document with other - /// key order and whitespace. - const DOC: &str = r#"{"a":1,"b":{"c":[1,2],"d":"x"}}"#; - const DOC_REORDERED: &str = "{ \"b\": {\"d\": \"x\", \"c\": [1, 2]}, \"a\": 1 }"; - const DOC_DERIVED_ID: &str = "402a3ca5-2530-407e-9029-f96879adff54"; - - #[test] - fn session_id_from_document_hash_is_a_v4_uuid_of_the_key_sorted_document() { - let id = session_id_from_document_hash(DOC).unwrap(); - assert_eq!(id, DOC_DERIVED_ID); - assert_eq!( - session_id_from_document_hash(DOC_REORDERED).unwrap(), - DOC_DERIVED_ID - ); - assert_ne!( - session_id_from_document_hash(r#"{"a":2}"#).unwrap(), - DOC_DERIVED_ID - ); - let uuid = uuid::Uuid::parse_str(&id).unwrap(); - assert_eq!(uuid.get_version_num(), 4); - assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122); - assert!(session_id_from_document_hash("not json").is_err()); - } -} diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 4b0b6e3d..b0d46c69 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -44,9 +44,12 @@ use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; +#[cfg(feature = "resume-remote")] +mod remote; + use crate::harness::Harness; -#[derive(Args, Debug)] +#[derive(Args, Debug, Default)] pub struct ResumeArgs { /// Toolpath document to resume from. Accepted shapes: a Pathbase /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand @@ -80,6 +83,20 @@ pub struct ResumeArgs { /// then `$PATHBASE_URL`, then `https://pathbase.dev`. #[arg(long)] pub url: Option, + + #[cfg(feature = "resume-remote")] + /// Plan the resume on this ssh destination instead of this + /// machine (`user@host`; Claude only). With `--remote`, + /// `-C` names the remote project directory; default: the local cwd + /// with the local home swapped for the remote home. Read-only for + /// now: the command stops after the plan. + #[arg(long, value_parser = crate::ssh::Destination::parse)] + pub remote: Option, + + #[cfg(feature = "resume-remote")] + /// Stop after printing the plan. Only with --remote. + #[arg(long, requires = "remote")] + pub dry_run: bool, } pub fn run(args: ResumeArgs) -> Result<()> { @@ -89,7 +106,30 @@ 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<()> { - let (graph, source_harness) = resolve_input(&args)?; + let resolved = resolve_input(&args)?; + #[cfg(feature = "resume-remote")] + if let Some(dest) = &args.remote { + let home = crate::config::home_dir(); + let transport = crate::ssh::Russh::new( + std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from), + home.as_deref() + .context("cannot determine the home directory")? + .join(".ssh"), + )?; + return run_remote_with_transport( + &args, + dest, + &resolved, + &transport, + home.as_deref(), + &std::env::current_dir()?, + ); + } + let ResolvedInput { + graph, + source_harness, + .. + } = resolved; let path = ensure_path_with_agent(&graph)?; let cwd = match args.cwd.as_ref() { @@ -117,6 +157,46 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<() use toolpath::v1::{Graph, Path as TPath, PathOrRef}; +#[cfg(feature = "resume-remote")] +/// `--remote`: validate the input the same way a local resume does, +/// then hand the document text to the remote planner. Claude only. +fn run_remote_with_transport( + args: &ResumeArgs, + dest: &crate::ssh::Destination, + resolved: &ResolvedInput, + transport: &dyn crate::ssh::Transport, + local_home: Option<&std::path::Path>, + local_cwd: &std::path::Path, +) -> Result<()> { + ensure_path_with_agent(&resolved.graph)?; + match (args.harness, resolved.source_harness) { + (Some(Harness::Claude), _) | (None, Some(Harness::Claude)) => {} + (Some(h), _) => anyhow::bail!( + "remote resume supports claude only (got --harness {})", + h.name() + ), + (None, source) => anyhow::bail!( + "remote resume supports claude only; the document's source is {}. \ + Pass `--harness claude` to force a Claude projection.", + source.map_or("unknown", |h| h.name()) + ), + } + let remote_dir_flag = args + .cwd + .as_deref() + .map(|p| p.to_str().context("-C must be valid UTF-8")) + .transpose()?; + remote::run_remote( + &resolved.json, + dest, + remote_dir_flag, + args.dry_run, + transport, + local_home, + local_cwd, + ) +} + /// Read a path's source harness from `meta.source` (set by /// `toolpath-convo::derive_path` to the provider id), falling back to /// actor-string sniffing across the path's steps. @@ -193,10 +273,23 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { Ok(path) } -/// Resolve the user-supplied `` argument into a parsed `Graph` -/// plus the source harness inferred from its single inline path (if -/// any). See spec § "Input resolution" for the order. -pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { +/// A resolved input: the parsed document, its source harness, and +/// the JSON text it was parsed from. The text is kept because the +/// remote session ID hashes the document text, not a type +/// round-trip. +#[derive(Debug)] +pub(crate) struct ResolvedInput { + pub(crate) graph: Graph, + pub(crate) source_harness: Option, + #[cfg(feature = "resume-remote")] + pub(crate) json: String, +} + +/// Resolve the user-supplied `` argument into a +/// [`ResolvedInput`]: the parsed `Graph` plus the source harness +/// inferred from its single inline path (if any). See spec § "Input +/// resolution" for the order. +pub(crate) fn resolve_input(args: &ResumeArgs) -> Result { let raw = args.input.as_str(); enum Shape<'a> { @@ -216,7 +309,7 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option Shape::CacheId(raw) }; - let graph: Graph = match shape { + let (json, source) = match shape { Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { // Probe the local cache before going to the network. The cache // id is purely a function of the parsed (owner, repo, id), so @@ -233,8 +326,7 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option let json = std::fs::read_to_string(&cache_path) .with_context(|| format!("read {}", cache_path.display()))?; eprintln!("Resolved {} → {} (cached)", raw, cache_id); - Graph::from_json(&json) - .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))? + (json, format!("cache entry {}", cache_path.display())) } else { let derived = crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref())?; if !args.no_cache { @@ -244,14 +336,14 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; eprintln!("Resolved {} → {}", raw, derived.cache_id); } - derived.doc + let json = derived.doc.to_json().map_err(|e| anyhow::anyhow!("{e}"))?; + (json, "fetched from Pathbase".to_string()) } } - Shape::FilePath(p) => { - let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?; - Graph::from_json(&json) - .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? - } + Shape::FilePath(p) => ( + std::fs::read_to_string(p).with_context(|| format!("read {}", p))?, + format!("file {p}"), + ), Shape::CacheId(id) => { let file = crate::cache::cache_ref(id).map_err(|e| { anyhow::anyhow!( @@ -262,13 +354,19 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option })?; let json = std::fs::read_to_string(&file) .with_context(|| format!("read {}", file.display()))?; - Graph::from_json(&json) - .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? + (json, format!("cache entry {}", file.display())) } }; - let harness = graph.single_path().and_then(infer_source_harness); - Ok((graph, harness)) + let graph = Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("not a valid toolpath document ({source}): {e}"))?; + let source_harness = graph.single_path().and_then(infer_source_harness); + Ok(ResolvedInput { + graph, + source_harness, + #[cfg(feature = "resume-remote")] + json, + }) } /// Probe `$PATH` (or `path_override`, for tests) for a given binary name. @@ -588,6 +686,48 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { mod tests { use super::*; + #[cfg(feature = "resume-remote")] + #[test] + fn remote_rejects_a_non_claude_harness_before_any_remote_call() { + let mut path = make_convo_path_for_resume("codex://remote-test-session"); + path.steps[0].step.actor = "agent:codex".to_string(); + let graph = toolpath::v1::Graph::from_path(path); + let resolved = ResolvedInput { + json: graph.to_json().unwrap(), + graph, + source_harness: Some(Harness::Codex), + }; + let args = |harness: Option| ResumeArgs { + input: "unused".to_string(), + harness, + dry_run: true, + ..Default::default() + }; + let dest = crate::ssh::Destination::parse("user@host").unwrap(); + let fake = crate::ssh::fake::FakeSsh::new(); + let run = |harness| { + run_remote_with_transport( + &args(harness), + &dest, + &resolved, + &fake, + None, + std::path::Path::new("/"), + ) + }; + + let err = run(None).unwrap_err(); + assert!(err.to_string().contains("supports claude only"), "{err:#}"); + assert!(err.to_string().contains("--harness claude"), "{err:#}"); + + let err = run(Some(Harness::Codex)).unwrap_err(); + assert!(err.to_string().contains("got --harness codex"), "{err:#}"); + assert!( + fake.calls().is_empty(), + "no remote call before the harness check" + ); + } + #[test] fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { let _env = crate::config::TEST_ENV_LOCK @@ -612,9 +752,7 @@ mod tests { input: doc_file.to_string_lossy().to_string(), cwd: Some(cwd.path().to_path_buf()), harness: Some(Harness::Claude), - no_cache: false, - force: false, - url: None, + ..Default::default() }; let recorder = RecordingExec::default(); @@ -748,11 +886,13 @@ mod tests { input: p.to_string_lossy().to_string(), cwd: None, harness: None, - no_cache: false, - force: false, - url: None, + ..Default::default() }; - let (g, harness) = resolve_input(&args).unwrap(); + let ResolvedInput { + graph: g, + source_harness: harness, + .. + } = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); assert_eq!(harness, Some(Harness::Claude)); } @@ -783,10 +923,13 @@ mod tests { cwd: None, harness: None, no_cache: true, // skip cache write in tests - force: false, - url: None, + ..Default::default() }; - let (g, harness) = resolve_input(&args).unwrap(); + let ResolvedInput { + graph: g, + source_harness: harness, + .. + } = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); assert_eq!(harness, Some(Harness::Codex)); } @@ -843,11 +986,9 @@ mod tests { ), cwd: None, harness: None, - no_cache: false, - force: false, - url: None, + ..Default::default() }; - let result = resolve_input(&args); + let result = resolve_input(&args).map(|r| (r.graph, r.source_harness)); // Restore env before asserting so a panic doesn't poison sibling tests. unsafe { @@ -871,9 +1012,7 @@ mod tests { input: "definitely/not/a/real/cache/id".to_string(), cwd: None, harness: None, - no_cache: false, - force: false, - url: None, + ..Default::default() }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/src/cmd_resume/probe_host.sh b/crates/path-cli/src/cmd_resume/probe_host.sh new file mode 100644 index 00000000..dd47e600 --- /dev/null +++ b/crates/path-cli/src/cmd_resume/probe_host.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# $1.. are claude locations relative to $HOME, probed when command -v +# finds nothing. Prints one TP_= line per fact and nothing +# else. No single quotes anywhere in this file: RemoteCommand::script +# passes the text to sh -c as one single-quoted word. +set -u +printf "TP_HOME=%s\n" "$HOME" +c="" +if command -v claude >/dev/null 2>&1; then c=$(command -v claude); fi +for probe in "$@"; do + if [ -z "$c" ] && [ -x "$HOME/$probe" ]; then c="$HOME/$probe"; fi +done +printf "TP_CLAUDE=%s\n" "$c" +if command -v tmux >/dev/null 2>&1; then t=ok; else t=missing; fi +printf "TP_TMUX=%s\n" "$t" diff --git a/crates/path-cli/src/cmd_resume/probe_project_dir.sh b/crates/path-cli/src/cmd_resume/probe_project_dir.sh new file mode 100644 index 00000000..725318e5 --- /dev/null +++ b/crates/path-cli/src/cmd_resume/probe_project_dir.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# $1 the project directory, $2 the tmux session name, $3 the session +# file. Prints one TP_= line per fact and nothing else. No +# single quotes anywhere in this file: RemoteCommand::script passes the +# text to sh -c as one single-quoted word. +set -u +if cd "$1" 2>/dev/null; then p=$(pwd -P); else p=""; fi +printf "TP_PWD=%s\n" "$p" +# The = prefix pins tmux to an exact session-name match; a bare -t NAME +# also matches any session whose name extends it. +if tmux has-session -t "=$2" 2>/dev/null; then s=live; else s=none; fi +printf "TP_SESSION=%s\n" "$s" +if [ -e "$3" ]; then e=yes; else e=no; fi +printf "TP_TARGET=%s\n" "$e" 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..5eeb2510 --- /dev/null +++ b/crates/path-cli/src/cmd_resume/remote.rs @@ -0,0 +1,488 @@ +//! `path resume --remote`: plan a Claude session resume on a remote +//! host. Read-only: the command stops after the plan. +//! +//! The local host does all toolpath work. The remote runs two +//! constant `sh` scripts (`probe_host.sh` and `probe_project_dir.sh`, +//! next to this module) that print `TP_=` fact lines; +//! [`crate::ssh::parse_facts`] rejects any other output, so a login +//! banner cannot become a path component. + +use anyhow::{Context, Result, bail}; +use std::path::Path; +use std::time::Duration; + +use crate::ssh::{Destination, RemoteCommand, Transport, fail_unless_success, parse_facts}; + +/// Wall-clock bound on one probe. +const PROBE_TIMEOUT: Duration = Duration::from_secs(60); + +/// What a run would do, decided by the two remote facts of call 2. +/// The remote wins once it exists: nothing overwrites a remote +/// session file, and a live session is attached to as is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunAction { + /// tmux session live: attach. + Attach, + /// Session file present, no live session: launch, attach. + Launch, + /// Session file absent: ship, launch, attach. + Ship, +} + +/// The plan for one remote resume, assembled from the document and +/// the two probe calls. +struct RemotePlan { + remote_home: String, + claude_path: String, + project_dir: String, + session_id: String, + session_file: String, + tmux_name: String, + action: RunAction, +} + +/// Entry point. `local_home` and `local_cwd` are inputs so tests +/// control them; production passes the real ones. +pub(super) fn run_remote( + document_json: &str, + dest: &Destination, + remote_dir_flag: Option<&str>, + dry_run: bool, + transport: &dyn Transport, + local_home: Option<&Path>, + local_cwd: &Path, +) -> Result<()> { + let session_id = crate::claude_session::session_id_from_document_hash(document_json)?; + let tmux_name = tmux_session_name(&session_id); + + let facts = probe_host(transport, dest)?; + let project_dir = remote_project_dir(remote_dir_flag, &facts.home, local_home, local_cwd)?; + + let slug_dir = remote_slug_dir(&facts.home, &project_dir); + let session_file = format!("{slug_dir}/{session_id}.jsonl"); + + let dir_facts = probe_project_dir(transport, dest, &project_dir, &tmux_name, &session_file)?; + match dir_facts.physical_dir.as_deref() { + None => { + bail!("project directory {project_dir} does not exist on {dest}; create it or pass -C") + } + Some(physical) if physical != project_dir => bail!( + "project directory {project_dir} is not physical on {dest} \ + (it resolves to {physical}); pass the physical path: -C {physical}" + ), + Some(_) => {} + } + + let action = if dir_facts.tmux_session_live { + RunAction::Attach + } else if dir_facts.session_file_exists { + RunAction::Launch + } else { + RunAction::Ship + }; + + let plan = RemotePlan { + remote_home: facts.home, + claude_path: facts.claude, + project_dir, + session_id, + session_file, + tmux_name, + action, + }; + print_plan(&plan, dest); + + if dry_run { + eprintln!("Dry run: nothing was written or launched."); + return Ok(()); + } + bail!( + "`path resume --remote` stops after the plan for now; \ + ship, launch, and attach are not implemented yet. \ + Use scripts/resume-remote.sh to run the plan." + ); +} + +fn print_plan(plan: &RemotePlan, dest: &Destination) { + let action = match plan.action { + RunAction::Attach => "attach to the live session. The remote tree and turns are kept.", + RunAction::Launch => { + "launch on the remote file, attach. The remote tree and turns are kept." + } + RunAction::Ship => "ship, launch, attach.", + }; + eprintln!("Remote resume plan for {dest}:"); + eprintln!(" remote home: {}", plan.remote_home); + eprintln!(" claude: {}", plan.claude_path); + eprintln!(" project dir: {}", plan.project_dir); + eprintln!(" session ID: {}", plan.session_id); + eprintln!(" session file: {}", plan.session_file); + eprintln!(" tmux session: {}", plan.tmux_name); + eprintln!(" run: {action}"); +} + +// ── Call 1: host facts ─────────────────────────────────────────────── + +/// 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. +const CLAUDE_PROBE_LOCATIONS: [&str; 3] = [ + ".local/bin/claude", + ".claude/local/claude", + ".npm-global/bin/claude", +]; + +struct HostFacts { + home: String, + claude: String, +} + +/// Remote home, claude path, and tmux presence, in one read-only call. +fn probe_host(transport: &dyn Transport, dest: &Destination) -> Result { + let command = RemoteCommand::script(include_str!("probe_host.sh")).args(CLAUDE_PROBE_LOCATIONS); + let output = transport.run(dest, &command, None, PROBE_TIMEOUT)?; + fail_unless_success(&output, "host probe", dest)?; + let [home, claude, tmux] = parse_facts(&output, ["TP_HOME", "TP_CLAUDE", "TP_TMUX"])?; + + let home = captured_absolute_path(&home, "remote $HOME", dest)?; + if claude.is_empty() { + let probed: Vec = CLAUDE_PROBE_LOCATIONS + .iter() + .map(|p| format!("~/{p}")) + .collect(); + bail!( + "claude not found on {dest}; probed PATH, {}", + probed.join(", ") + ); + } + let claude = captured_absolute_path(&claude, "remote claude path", dest)?; + if tmux != "ok" { + bail!("tmux not found on {dest}"); + } + Ok(HostFacts { home, claude }) +} + +// ── Call 2: project directory facts ────────────────────────────────── + +struct ProjectDirFacts { + /// `pwd -P` inside the directory, `None` when it is missing. + physical_dir: Option, + tmux_session_live: bool, + session_file_exists: bool, +} + +/// The directory's physical path, the tmux session state, and the +/// session file's existence, in one read-only call. +fn probe_project_dir( + transport: &dyn Transport, + dest: &Destination, + project_dir: &str, + tmux_name: &str, + session_file: &str, +) -> Result { + let command = RemoteCommand::script(include_str!("probe_project_dir.sh")) + .arg(project_dir) + .arg(tmux_name) + .arg(session_file); + let output = transport.run(dest, &command, None, PROBE_TIMEOUT)?; + fail_unless_success(&output, "project directory probe", dest)?; + let [pwd, session, target] = parse_facts(&output, ["TP_PWD", "TP_SESSION", "TP_TARGET"])?; + Ok(ProjectDirFacts { + physical_dir: if pwd.is_empty() { None } else { Some(pwd) }, + tmux_session_live: session == "live", + session_file_exists: target == "yes", + }) +} + +// ── Remote project directory ───────────────────────────────────────── + +/// `-C`, else the local cwd with the local home swapped for the +/// remote home. Both go through +/// [`crate::claude_session::parse_cwd_arg`]. +fn remote_project_dir( + remote_dir_flag: Option<&str>, + remote_home: &str, + local_home: Option<&Path>, + local_cwd: &Path, +) -> Result { + if let Some(dir) = remote_dir_flag { + return crate::claude_session::parse_cwd_arg(dir); + } + let local_home = + local_home.context("cannot determine the local home directory; pass -C ")?; + let suffix = local_cwd + .strip_prefix(local_home) + .ok() + .and_then(slash_joined) + .with_context(|| { + format!( + "the local cwd {} is not under the local home {}; pass -C ", + local_cwd.display(), + local_home.display() + ) + })?; + let dir = if suffix.is_empty() { + remote_home.to_string() + } else { + format!("{}/{}", remote_home.trim_end_matches('/'), suffix) + }; + crate::claude_session::parse_cwd_arg(&dir) +} + +// ── Small pure helpers ─────────────────────────────────────────────── + +/// `/.claude/projects/` with `/` separators, whatever the +/// local separator is. +fn remote_slug_dir(remote_home: &str, project_dir: &str) -> String { + format!( + "{}/.claude/projects/{}", + remote_home.trim_end_matches('/'), + toolpath_claude::sanitize_project_path(project_dir) + ) +} + +/// A value captured from the remote may only become a path component +/// if it is a non-empty single line starting with `/`. +fn captured_absolute_path(value: &str, what: &str, dest: &Destination) -> Result { + if value.is_empty() || !value.starts_with('/') || value.contains('\n') { + bail!("{what} from {dest} is not an absolute single-line path (got {value:?})"); + } + Ok(value.to_string()) +} + +/// `path-`. The ID is a +/// hyphenated UUID, so the name is always a valid tmux session name. +fn tmux_session_name(session_id: &str) -> String { + format!("path-{}", &session_id[..8]) +} + +/// The components of a relative path joined with `/`, `None` when a +/// component is not valid UTF-8. `Path::to_str` would keep the local +/// separator, which is `\` on a Windows local host. +fn slash_joined(rel: &Path) -> Option { + let parts: Option> = rel.components().map(|c| c.as_os_str().to_str()).collect(); + Some(parts?.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ssh::fake::FakeSsh; + + /// One valid single-path document with an agent actor, as text. + fn doc_json() -> String { + r#"{"graph":{"id":"g1"},"paths":[{"path":{"id":"p1","head":"s1"},"steps":[{"step":{"id":"s1","actor":"agent:claude-code","timestamp":"2026-01-01T00:00:00Z"},"change":{}}]}]}"# + .to_string() + } + + fn dest() -> Destination { + Destination::parse("user@host").unwrap() + } + + const HOME: &str = "/home/remote"; + const DIR: &str = "/home/remote/work"; + + /// Queues the call-1 reply: home, claude on PATH, tmux ok. + fn reply_host_ok(fake: &FakeSsh) { + fake.reply( + 0, + &format!("TP_HOME={HOME}\nTP_CLAUDE=/usr/local/bin/claude\nTP_TMUX=ok\n"), + ); + } + + /// Queues the call-2 reply from the three facts. + fn reply_dir(fake: &FakeSsh, physical: &str, session: &str, target: &str) { + fake.reply( + 0, + &format!("TP_PWD={physical}\nTP_SESSION={session}\nTP_TARGET={target}\n"), + ); + } + + fn run(fake: &FakeSsh, dry_run: bool) -> Result<()> { + run_remote( + &doc_json(), + &dest(), + Some(DIR), + dry_run, + fake, + Some(Path::new("/home/local")), + Path::new("/home/local/work"), + ) + } + + #[test] + fn file_absent_plans_a_ship_and_stops_without_dry_run() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, DIR, "none", "no"); + let err = run(&fake, false).unwrap_err(); + assert!(err.to_string().contains("not implemented yet"), "{err:#}"); + // Read-only: both calls ran, neither fed stdin. + let calls = fake.calls(); + assert_eq!(calls.len(), 2); + for call in &calls { + assert!(call.input.is_none()); + } + } + + #[test] + fn dry_run_stops_cleanly_for_each_action() { + for (session, target) in [("none", "no"), ("none", "yes"), ("live", "yes")] { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, DIR, session, target); + run(&fake, true).unwrap(); + assert_eq!(fake.calls().len(), 2); + } + } + + #[test] + fn call_2_carries_the_dir_the_exact_tmux_name_and_the_target() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, DIR, "none", "no"); + run(&fake, true).unwrap(); + let session_id = crate::claude_session::session_id_from_document_hash(&doc_json()).unwrap(); + let command = &fake.calls()[1].command; + assert!(command.contains(DIR), "{command}"); + assert!( + command.contains(&format!("path-{}", &session_id[..8])), + "{command}" + ); + assert!( + command.contains(&format!("{session_id}.jsonl")), + "{command}" + ); + } + + #[test] + fn missing_dir_errors_and_names_it() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, "", "none", "no"); + let err = run(&fake, true).unwrap_err(); + assert!(err.to_string().contains("does not exist"), "{err:#}"); + assert!(err.to_string().contains(DIR), "{err:#}"); + } + + #[test] + fn non_physical_dir_errors_with_the_c_hint() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, "/private/home/remote/work", "none", "no"); + let err = run(&fake, true).unwrap_err(); + let text = err.to_string(); + assert!(text.contains("not physical"), "{err:#}"); + assert!(text.contains("-C /private/home/remote/work"), "{err:#}"); + } + + #[test] + fn a_login_banner_errors_and_quotes_the_reply() { + let fake = FakeSsh::new(); + fake.reply(0, "Welcome to the machine!\nTP_HOME=/home/remote\n"); + let err = run(&fake, true).unwrap_err(); + let text = format!("{err:#}"); + assert!(text.contains("login banner"), "{text}"); + assert!(text.contains("Welcome to the machine!"), "{text}"); + } + + #[test] + fn missing_claude_and_missing_tmux_error() { + let fake = FakeSsh::new(); + fake.reply(0, &format!("TP_HOME={HOME}\nTP_CLAUDE=\nTP_TMUX=ok\n")); + let err = run(&fake, true).unwrap_err(); + assert!(err.to_string().contains("claude not found"), "{err:#}"); + assert!(err.to_string().contains(".local/bin/claude"), "{err:#}"); + + let fake = FakeSsh::new(); + fake.reply( + 0, + &format!("TP_HOME={HOME}\nTP_CLAUDE=/usr/bin/claude\nTP_TMUX=missing\n"), + ); + let err = run(&fake, true).unwrap_err(); + assert!(err.to_string().contains("tmux not found"), "{err:#}"); + } + + #[test] + fn remote_dir_defaults_to_home_swap_and_validates() { + assert_eq!( + remote_project_dir( + None, + HOME, + Some(Path::new("/home/local")), + Path::new("/home/local/a/b"), + ) + .unwrap(), + "/home/remote/a/b" + ); + assert_eq!( + remote_project_dir( + None, + HOME, + Some(Path::new("/home/local")), + Path::new("/home/local"), + ) + .unwrap(), + HOME + ); + let err = remote_project_dir( + None, + HOME, + Some(Path::new("/home/local")), + Path::new("/elsewhere"), + ) + .unwrap_err(); + assert!(err.to_string().contains("pass -C"), "{err:#}"); + assert_eq!( + remote_project_dir(Some("/x/y/"), HOME, None, Path::new("/")).unwrap(), + "/x/y" + ); + for bad in ["relative", "/a/../b", "/a/./b", "/a//b", "/a\nb"] { + assert!(remote_project_dir(Some(bad), HOME, None, Path::new("/")).is_err()); + } + } + + #[test] + fn tmux_name_is_path_plus_the_first_8_of_the_id() { + assert_eq!( + tmux_session_name("b7e1c0de-0000-4000-8000-000000000001"), + "path-b7e1c0de" + ); + } + + #[test] + fn slug_dir_uses_forward_slashes() { + assert_eq!( + remote_slug_dir("/home/remote", "/home/remote/a/b"), + "/home/remote/.claude/projects/-home-remote-a-b" + ); + assert_eq!( + remote_slug_dir("/home/remote/", "/home/remote"), + "/home/remote/.claude/projects/-home-remote" + ); + } + + #[cfg(windows)] + #[test] + fn windows_local_cwd_yields_a_slash_suffix() { + assert_eq!( + remote_project_dir( + None, + "/home/remote", + Some(Path::new(r"C:\Users\alex")), + Path::new(r"C:\Users\alex\proj\sub"), + ) + .unwrap(), + "/home/remote/proj/sub" + ); + } + + #[cfg(windows)] + #[test] + fn windows_slug_dir_matches_the_unix_slug() { + assert_eq!( + remote_slug_dir("/home/remote", "/home/remote/proj/sub"), + "/home/remote/.claude/projects/-home-remote-proj-sub" + ); + } +} diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 71d4f66e..8e3312fe 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -1,5 +1,7 @@ pub mod artifact; mod cache; +#[cfg(all(not(target_os = "emscripten"), feature = "resume-remote"))] +mod claude_session; #[cfg(not(target_os = "emscripten"))] mod cmd_auth; mod cmd_cache; @@ -44,6 +46,8 @@ mod schema; mod share_config; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; +#[cfg(all(not(target_os = "emscripten"), feature = "resume-remote"))] +mod ssh; mod sync; mod term; diff --git a/crates/path-cli/src/ssh.rs b/crates/path-cli/src/ssh.rs new file mode 100644 index 00000000..03cbbd98 --- /dev/null +++ b/crates/path-cli/src/ssh.rs @@ -0,0 +1,929 @@ +//! ssh transport for commands that act on a remote host. +//! +//! The transport is an in-process SSH client (`russh`); no `ssh` binary +//! is involved. The destination is `user@host`; the port is 22. The +//! caller passes the agent socket and the ssh directory; the module +//! reads no environment variable. The agent authenticates first, then +//! the default identity files in the ssh directory. `known_hosts` in +//! that directory verifies the host key: a changed key is an error, +//! and an unknown host is learned on first contact with a notice, as +//! `StrictHostKeyChecking=accept-new` does. +//! +//! [`Transport::run`] captures the output of an exec channel. +//! +//! A remote command is one string by the protocol: the exec request +//! carries it and the remote login shell parses it. [`RemoteCommand`] +//! is the only way to build that string. Its two forms are an argv, and +//! a constant `sh` script that reads its values as positional +//! parameters. Every value passes through shell quoting; no caller +//! interpolates into shell text. +//! +//! The remote login shell must be POSIX-compatible. A value that +//! contains a single quote renders double-quoted with backslash +//! escapes; csh and fish parse those escapes differently. + +use anyhow::{Context, Result, anyhow, bail}; +use russh::client; +use russh::keys::known_hosts::{check_known_hosts_path, learn_known_hosts_path}; +use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKeyOrCertificate}; +use russh::{ChannelMsg, Disconnect}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::Output; +use std::sync::Arc; +use std::time::Duration; + +/// An ssh destination: `user@host`. The port is 22; `~/.ssh/config` +/// is not read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Destination { + user: String, + host: String, +} + +impl Destination { + /// `user@host`, both non-empty. Each side starts with an + /// alphanumeric character (so the value cannot be an option) and + /// continues with `[A-Za-z0-9._-]`. Also usable as a clap + /// `value_parser`, hence the `String` error type. + pub fn parse(s: &str) -> std::result::Result { + let word = |part: &str| { + let mut chars = part.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + }; + match s.split_once('@') { + Some((user, host)) if word(user) && word(host) => Ok(Self { + user: user.to_string(), + host: host.to_string(), + }), + _ => Err(format!( + "expected an ssh destination of the form user@host (letters, \ + digits, and `._-` on each side, alphanumeric first); IPv6 \ + addresses and ports are not accepted, got {s:?}" + )), + } + } +} + +impl fmt::Display for Destination { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}@{}", self.user, self.host) + } +} + +/// A command for the remote login shell. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteCommand { + words: Vec, +} + +impl RemoteCommand { + /// An argv: `program` followed by the values given to [`Self::arg`]. + #[allow(dead_code)] + pub(crate) fn new(program: &str) -> Self { + Self { + words: vec![program.to_string()], + } + } + + /// A constant `sh` script. The values given to [`Self::arg`] reach + /// it as `$1`, `$2`, and so on. The text must not contain a single + /// quote, so shlex renders it as one single-quoted word (a test + /// pins the rendering), which the login shell passes to `sh` + /// verbatim. + pub(crate) fn script(text: &'static str) -> Self { + assert!( + !text.contains('\''), + "a remote script must not contain a single quote" + ); + Self { + words: vec!["sh".into(), "-c".into(), text.into(), "sh".into()], + } + } + + pub(crate) fn arg(mut self, value: impl Into) -> Self { + self.words.push(value.into()); + self + } + + pub(crate) fn args(self, values: impl IntoIterator>) -> Self { + values + .into_iter() + .fold(self, |command, value| command.arg(value)) + } + + /// The string the exec request carries: every word quoted for a + /// POSIX shell. + pub(crate) fn render(&self) -> Result { + let words: Vec = self.words.iter().map(|w| quote(w)).collect::>()?; + Ok(words.join(" ")) + } +} + +pub(crate) trait Transport { + /// Run `command` on `dest` without a terminal. stdin is fed from + /// `input` or closed; stdout and stderr are captured. The call + /// returns when the remote command exits; a command that exits + /// before it reads all of `input` yields its status and stderr. + /// When the command outlives `timeout`, the connection is dropped + /// and the call errors with the stderr received so far; sshd hangs + /// up the remote session, and a detached remote process survives. + fn run( + &self, + dest: &Destination, + command: &RemoteCommand, + input: Option<&[u8]>, + timeout: Duration, + ) -> Result; +} + +/// Bound on the TCP connect, the handshake, and authentication +/// together. Separate from the caller's per-command timeout, which +/// bounds the command alone. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Bound on the disconnect, so a stalled peer does not hold the caller +/// after its command is done. +const CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Keepalives on every session, so a peer that stops answering is +/// noticed within a minute. +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15); +const KEEPALIVE_MAX: usize = 3; + +/// The ssh port; a [`Destination`] carries none. +const PORT: u16 = 22; + +/// The in-process SSH client. Each call opens one connection. +pub(crate) struct Russh { + runtime: tokio::runtime::Runtime, + agent_socket: Option, + ssh_dir: PathBuf, +} + +impl Russh { + /// `agent_socket` is the ssh agent's socket, when there is one. + /// `ssh_dir` holds the identity files and `known_hosts`. + pub(crate) fn new(agent_socket: Option, ssh_dir: PathBuf) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("start the async runtime")?; + Ok(Self { + runtime, + agent_socket, + ssh_dir, + }) + } +} + +impl Transport for Russh { + fn run( + &self, + dest: &Destination, + command: &RemoteCommand, + input: Option<&[u8]>, + timeout: Duration, + ) -> Result { + let command = command.render()?; + self.runtime.block_on(async { + let session = connect(dest, self.agent_socket.as_deref(), &self.ssh_dir).await?; + let result = exec_captured(&session, &command, input, timeout).await; + session.close().await; + result + }) + } +} + +/// Verifies the server's host key against `known_hosts`. +struct HostKeyCheck { + host: String, + known_hosts: PathBuf, +} + +impl client::Handler for HostKeyCheck { + type Error = anyhow::Error; + + async fn check_server_key( + &mut self, + server_key: &PublicKeyOrCertificate, + ) -> Result { + let PublicKeyOrCertificate::PublicKey { key, .. } = server_key else { + bail!( + "{}:{PORT} presented a host certificate; only plain host keys are accepted", + self.host + ); + }; + match check_known_hosts_path(&self.host, PORT, key, &self.known_hosts) { + Ok(true) => Ok(true), + Ok(false) => { + learn_known_hosts_path(&self.host, PORT, key, &self.known_hosts).with_context( + || { + format!( + "record the host key of {}:{PORT} in {}", + self.host, + self.known_hosts.display() + ) + }, + )?; + eprintln!( + "Learned the host key of {}:{PORT} ({} {})", + self.host, + key.algorithm(), + key.fingerprint(HashAlg::Sha256) + ); + Ok(true) + } + Err(russh::keys::Error::KeyChanged { line }) => bail!( + "the host key of {}:{PORT} does not match {} line {line}; \ + refusing to connect", + self.host, + self.known_hosts.display() + ), + Err(e) => Err(e).with_context(|| format!("read {}", self.known_hosts.display())), + } + } +} + +struct Session { + handle: client::Handle, +} + +impl Session { + /// Sends the DISCONNECT and waits for the session task to end, + /// within [`CLOSE_TIMEOUT`]. `disconnect` only queues the message; + /// the task writes it, and the runtime polls the task only while + /// this future runs. + async fn close(self) { + let handle = self.handle; + let _ = tokio::time::timeout(CLOSE_TIMEOUT, async { + let _ = handle + .disconnect(Disconnect::ByApplication, "", "English") + .await; + let _ = handle.await; + }) + .await; + } +} + +/// Connect, verify the host key, and authenticate, within +/// [`CONNECT_TIMEOUT`]. +async fn connect( + dest: &Destination, + agent_socket: Option<&Path>, + ssh_dir: &Path, +) -> Result { + let connected = async { + let client_config = Arc::new(client::Config { + keepalive_interval: Some(KEEPALIVE_INTERVAL), + keepalive_max: KEEPALIVE_MAX, + ..Default::default() + }); + let handler = HostKeyCheck { + host: dest.host.clone(), + known_hosts: ssh_dir.join("known_hosts"), + }; + let mut handle = client::connect(client_config, (dest.host.as_str(), PORT), handler) + .await + .with_context(|| format!("connect to {}:{PORT}", dest.host))?; + authenticate(&mut handle, &dest.user, &dest.host, agent_socket, ssh_dir).await?; + Ok::<_, anyhow::Error>(Session { handle }) + }; + match tokio::time::timeout(CONNECT_TIMEOUT, connected).await { + Ok(result) => result, + Err(_) => bail!( + "connecting to {dest} did not finish within {}s", + CONNECT_TIMEOUT.as_secs() + ), + } +} + +/// The identities of the agent at `agent_socket` first, then the +/// identity files in `ssh_dir`. A failure on one identity is recorded +/// and the next one is tried; the error lists what was tried. +async fn authenticate( + handle: &mut client::Handle, + user: &str, + host: &str, + agent_socket: Option<&Path>, + ssh_dir: &Path, +) -> Result<()> { + let rsa_hash = handle + .best_supported_rsa_hash() + .await + .context("negotiate signature algorithms")? + .flatten(); + let mut tried: Vec = Vec::new(); + + #[cfg(unix)] + match agent_socket { + Some(socket) => match russh::keys::agent::client::AgentClient::connect_uds(socket).await { + Ok(mut agent) => match agent.request_identities().await { + Ok(identities) => { + for identity in identities { + let russh::keys::agent::AgentIdentity::PublicKey { key, comment } = + identity + else { + continue; + }; + let result = match handle + .authenticate_publickey_with(user, key, rsa_hash, &mut agent) + .await + { + Ok(result) => result, + Err(e) => { + tried.push(format!("agent key {comment:?} ({e})")); + continue; + } + }; + if result.success() { + return Ok(()); + } + tried.push(format!("agent key {comment:?}")); + } + } + Err(e) => tried.push(format!("agent identities ({e})")), + }, + Err(e) => tried.push(format!("agent at {} ({e})", socket.display())), + }, + None => tried.push("agent (no socket)".to_string()), + } + #[cfg(not(unix))] + let _ = agent_socket; + + for path in identity_file_candidates(ssh_dir) { + if !path.is_file() { + continue; + } + let key = match russh::keys::load_secret_key(&path, None) { + Ok(key) => key, + Err(russh::keys::Error::KeyIsEncrypted) => { + tried.push(format!( + "{} (encrypted; add it to the agent)", + path.display() + )); + continue; + } + Err(e) => { + tried.push(format!("{} ({e})", path.display())); + continue; + } + }; + let result = match handle + .authenticate_publickey(user, PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash)) + .await + { + Ok(result) => result, + Err(e) => { + tried.push(format!("{} ({e})", path.display())); + continue; + } + }; + if result.success() { + return Ok(()); + } + tried.push(path.display().to_string()); + } + + let tried = if tried.is_empty() { + "nothing: no agent and no identity file".to_string() + } else { + tried.join(", ") + }; + bail!("authentication as {user}@{host} failed; tried {tried}") +} + +/// The OpenSSH default identity files in `ssh_dir`. +fn identity_file_candidates(ssh_dir: &Path) -> Vec { + ["id_ed25519", "id_ecdsa", "id_rsa"] + .iter() + .map(|name| ssh_dir.join(name)) + .collect() +} + +/// One exec channel: feed `input`, collect stdout and stderr, and take +/// the exit status. `timeout` bounds the whole call, from the channel +/// open to the channel's close. The close ends the call, so a remote +/// that exits without reading all of `input` does not leave the feed +/// waiting on window space until `timeout`. A refused exec request +/// errors at once; sshd keeps the channel open after a refusal. +async fn exec_captured( + session: &Session, + command: &str, + input: Option<&[u8]>, + timeout: Duration, +) -> Result { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut status = None; + let work = async { + let channel = session + .handle + .channel_open_session() + .await + .context("open a session channel")?; + channel + .exec(true, command) + .await + .context("send the exec request")?; + let (mut reader, writer) = channel.split(); + let feed = async { + if let Some(bytes) = input { + writer.data(bytes).await.context("send stdin")?; + } + writer.eof().await.context("close stdin") + }; + let collect = async { + while let Some(msg) = reader.wait().await { + match msg { + ChannelMsg::Data { data } => stdout.extend_from_slice(&data), + ChannelMsg::ExtendedData { data, ext: 1 } => stderr.extend_from_slice(&data), + ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status), + ChannelMsg::ExitSignal { signal_name, .. } => { + status = Some(255); + stderr.extend_from_slice( + format!("\nterminated by signal {signal_name:?}").as_bytes(), + ); + } + ChannelMsg::Failure => bail!("the remote refused the exec request"), + ChannelMsg::Close => break, + _ => {} + } + } + Ok::<_, anyhow::Error>(()) + }; + let (collected, fed) = { + tokio::pin!(feed); + tokio::pin!(collect); + let mut fed: Option> = None; + loop { + tokio::select! { + result = &mut feed, if fed.is_none() => fed = Some(result), + result = &mut collect => break (result, fed), + } + } + }; + if collected.is_err() { + let _ = writer.close().await; + } + collected.map(|()| fed) + }; + let fed = match tokio::time::timeout(timeout, work).await { + Ok(result) => result?, + Err(_) => bail!( + "remote command did not finish within {}s; \ + the connection may be fine while the command hangs{}", + timeout.as_secs(), + stderr_note(&stderr) + ), + }; + if status.is_none() && session.handle.is_closed() { + bail!( + "the connection closed before the remote reported an exit status{}", + stderr_note(&stderr) + ); + } + if status.is_none() + && let Some(Err(e)) = fed + { + return Err(e); + } + Ok(Output { + status: exit_status(status.unwrap_or(255)), + stdout, + stderr, + }) +} + +/// The last 1000 characters of `stderr` behind a `(stderr)` marker, +/// or nothing when it is empty. +fn stderr_note(stderr: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr); + let stderr = stderr.trim_end(); + if stderr.is_empty() { + String::new() + } else { + format!("\n(stderr) {}", tail(stderr, 1000)) + } +} + +#[cfg(unix)] +fn exit_status(code: u32) -> std::process::ExitStatus { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw((code as i32 & 0xff) << 8) +} + +#[cfg(windows)] +fn exit_status(code: u32) -> std::process::ExitStatus { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(code) +} + +/// The last `n` characters of `s`. +fn tail(s: &str, n: usize) -> &str { + let start = s + .char_indices() + .rev() + .nth(n.saturating_sub(1)) + .map_or(0, |(i, _)| i); + &s[start..] +} + +/// POSIX shell quoting for one word of a remote command. +fn quote(s: &str) -> Result { + shlex::try_quote(s) + .map(|c| c.into_owned()) + .map_err(|_| anyhow!("cannot quote a value that contains a NUL byte: {s:?}")) +} + +/// Error unless `output` reports success. The message names `what`, +/// the destination, the exit status, and the remote stderr. +pub(crate) fn fail_unless_success(output: &Output, what: &str, dest: &Destination) -> Result<()> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim_end(); + if stderr.is_empty() { + bail!("{what} on {dest} failed ({})", output.status); + } + bail!("{what} on {dest} failed ({}):\n{stderr}", output.status); +} + +/// Parse `=` lines from stdout: exactly one line per tag, +/// in order. Any other shape (a login banner, a notice, a partial +/// run) errors with stdout, then stderr behind a `(stderr)` marker. +pub(crate) fn parse_facts(output: &Output, tags: [&str; N]) -> Result<[String; N]> { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + let values: Option> = (lines.len() == N) + .then(|| { + lines + .iter() + .zip(tags) + .map(|(line, tag)| line.strip_prefix(tag)?.strip_prefix('=')) + .collect() + }) + .flatten(); + let Some(values) = values else { + let stderr = String::from_utf8_lossy(&output.stderr); + let mut shown = stdout.trim_end().to_string(); + if !stderr.trim().is_empty() { + shown.push_str("\n(stderr) "); + shown.push_str(stderr.trim_end()); + } + bail!( + "unexpected output from the remote (a login banner or notice?); output was:\n{shown}" + ); + }; + Ok(std::array::from_fn(|i| values[i].to_string())) +} + +/// Scripted transport for tests: `reply` queues one `run` result; +/// every call is recorded with its rendered command. +#[cfg(test)] +pub(crate) mod fake { + use super::*; + use std::collections::VecDeque; + use std::sync::Mutex; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct Call { + pub(crate) dest: String, + pub(crate) command: String, + pub(crate) input: Option>, + } + + #[derive(Default)] + pub(crate) struct FakeSsh { + replies: Mutex>, + calls: Mutex>, + } + + pub(crate) fn output(status: u32, stdout: &str, stderr: &str) -> Output { + Output { + status: exit_status(status), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + } + } + + impl FakeSsh { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn reply(&self, status: u32, stdout: &str) -> &Self { + self.reply_with_stderr(status, stdout, "") + } + + pub(crate) fn reply_with_stderr(&self, status: u32, stdout: &str, stderr: &str) -> &Self { + self.replies + .lock() + .unwrap() + .push_back(output(status, stdout, stderr)); + self + } + + pub(crate) fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + } + + impl Transport for FakeSsh { + fn run( + &self, + dest: &Destination, + command: &RemoteCommand, + input: Option<&[u8]>, + _timeout: Duration, + ) -> Result { + let command = command.render()?; + self.calls.lock().unwrap().push(Call { + dest: dest.to_string(), + command: command.clone(), + input: input.map(|b| b.to_vec()), + }); + let reply = self.replies.lock().unwrap().pop_front(); + Ok(reply.unwrap_or_else(|| panic!("FakeSsh: no scripted reply for {command:?}"))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NASTY: &str = "$(rm -rf ~); `x`; $HOME 'quoted' \"double\" \\ * ? ; & | > <"; + + fn dest() -> Destination { + Destination::parse("user@host").unwrap() + } + + #[cfg(unix)] + fn sh(command: &str) -> String { + let out = std::process::Command::new("sh") + .arg("-c") + .arg(command) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() + } + + #[test] + fn destination_accepts_user_at_host() { + for s in [ + "user@host", + "exedev@vm.exe.xyz", + "a-b_c.d@e-f_g.h", + "u1@10.0.0.1", + ] { + assert_eq!(Destination::parse(s).unwrap().to_string(), s); + } + } + + #[test] + fn destination_rejects_missing_parts_option_shaped_and_shell_characters() { + for s in [ + "", + "host", + "user@", + "@host", + "-oProxyCommand=x", + "-", + "-u@host", + "user@-host", + "user@host;ls", + "a@b@c", + "a b", + "user@host$", + "u@[::1]", + "ssh://u@h", + ] { + assert!(Destination::parse(s).is_err(), "{s:?} must be rejected"); + } + } + + #[test] + fn destination_splits_user_and_host() { + let d = Destination::parse("exedev@vm.exe.xyz").unwrap(); + assert_eq!(d.user, "exedev"); + assert_eq!(d.host, "vm.exe.xyz"); + } + + #[test] + fn quote_leaves_plain_words_and_quotes_the_rest() { + assert_eq!(quote("/a/b").unwrap(), "/a/b"); + assert_eq!(quote("=path-abc").unwrap(), "'=path-abc'"); + assert_eq!(quote("").unwrap(), "''"); + assert_eq!(quote("a b").unwrap(), "'a b'"); + assert_eq!(quote("a'b").unwrap(), "\"a'b\""); + assert!(quote("a\0b").is_err()); + } + + #[cfg(unix)] + #[test] + fn quote_round_trips_through_sh() { + assert_eq!(sh(&format!("printf %s {}", quote(NASTY).unwrap())), NASTY); + } + + #[test] + fn remote_command_renders_program_then_quoted_args() { + let cmd = RemoteCommand::new("tmux") + .arg("new-session") + .arg("-c") + .arg("/it's here") + .arg(""); + assert_eq!( + cmd.render().unwrap(), + "tmux new-session -c \"/it's here\" ''" + ); + } + + #[test] + fn script_renders_sh_dash_c_then_positional_args() { + let cmd = RemoteCommand::script("cd \"$1\" && pwd -P").arg("/a b"); + assert_eq!( + cmd.render().unwrap(), + "sh -c 'cd \"$1\" && pwd -P' sh '/a b'" + ); + } + + #[test] + #[should_panic(expected = "single quote")] + fn script_rejects_a_single_quote() { + RemoteCommand::script("printf '%s'"); + } + + #[test] + fn render_rejects_a_nul_byte() { + assert!(RemoteCommand::new("x").arg("a\0b").render().is_err()); + } + + #[cfg(unix)] + #[test] + fn remote_command_round_trips_hostile_args_through_sh() { + let argv = RemoteCommand::new("printf") + .arg("%s\\n") + .arg(NASTY) + .arg("$HOME"); + assert_eq!(sh(&argv.render().unwrap()), format!("{NASTY}\n$HOME\n")); + + let script = RemoteCommand::script("printf \"%s\\n\" \"$1\" \"$2\"") + .arg(NASTY) + .arg("$HOME"); + assert_eq!(sh(&script.render().unwrap()), format!("{NASTY}\n$HOME\n")); + } + + #[test] + fn exit_status_maps_the_remote_code() { + assert!(exit_status(0).success()); + assert_eq!(exit_status(3).code(), Some(3)); + assert_eq!(exit_status(255).code(), Some(255)); + } + + #[test] + fn tail_keeps_the_last_n_characters() { + assert_eq!(tail("abcdef", 3), "def"); + assert_eq!(tail("ab", 3), "ab"); + assert_eq!(tail("", 3), ""); + assert_eq!(tail("héllo", 2), "lo"); + } + + /// Needs a reachable host with agent or key auth: + /// `PATH_TEST_SSH_DEST=user@host cargo test -p path-cli -- --ignored live_`. + #[test] + #[ignore = "connects to $PATH_TEST_SSH_DEST"] + fn live_run_captures_feeds_stdin_times_out_and_maps_the_status() { + let Ok(dest) = std::env::var("PATH_TEST_SSH_DEST") else { + return; + }; + let dest = Destination::parse(&dest).unwrap(); + let ssh = Russh::new( + std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from), + crate::config::home_dir().unwrap().join(".ssh"), + ) + .unwrap(); + + let script = + RemoteCommand::script("printf \"TP_A=%s\\n\" \"$1\"; printf err >&2; cat").arg("x y"); + let out = ssh + .run(&dest, &script, Some(b"fed"), Duration::from_secs(30)) + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout), "TP_A=x y\nfed"); + assert_eq!(String::from_utf8_lossy(&out.stderr), "err"); + + let out = ssh + .run( + &dest, + &RemoteCommand::new("sh").arg("-c").arg("exit 3"), + None, + Duration::from_secs(30), + ) + .unwrap(); + assert_eq!(out.status.code(), Some(3)); + + // stdin larger than the initial channel window (2 MiB on + // OpenSSH) and a remote that exits without reading it. + let unread = vec![b'x'; 4 << 20]; + let out = ssh + .run( + &dest, + &RemoteCommand::script("echo unread >&2; exit 4"), + Some(&unread), + Duration::from_secs(30), + ) + .unwrap(); + assert_eq!(out.status.code(), Some(4)); + assert_eq!(String::from_utf8_lossy(&out.stderr), "unread\n"); + + let err = ssh + .run( + &dest, + &RemoteCommand::script("echo hanging on a lock >&2; sleep 30"), + None, + Duration::from_millis(1500), + ) + .unwrap_err(); + assert!(err.to_string().contains("did not finish within"), "{err:#}"); + assert!( + err.to_string().ends_with("(stderr) hanging on a lock"), + "{err:#}" + ); + } + + mod with_fake_output { + use super::super::fake::{Call, FakeSsh, output}; + use super::*; + + #[test] + fn parse_facts_reads_values_in_order() { + let out = output(0, "A=1\nB=\nC=/x y\n", ""); + assert_eq!( + parse_facts(&out, ["A", "B", "C"]).unwrap(), + ["1", "", "/x y"] + ); + } + + #[test] + fn parse_facts_rejects_banner_missing_and_extra_lines() { + let banner = output(0, "Welcome!\nA=1\n", ""); + let err = parse_facts(&banner, ["A"]).unwrap_err().to_string(); + assert!(err.contains("login banner"), "{err}"); + assert!(err.contains("Welcome!"), "{err}"); + assert!(parse_facts(&output(0, "A=1\n", ""), ["A", "B"]).is_err()); + assert!(parse_facts(&output(0, "A=1\nB=2\n", ""), ["A"]).is_err()); + assert!(parse_facts(&output(0, "B=2\nA=1\n", ""), ["A", "B"]).is_err()); + assert!(parse_facts(&output(0, "", ""), ["A"]).is_err()); + let noisy = output(0, "Welcome!\n", "warning: x\n"); + let err = parse_facts(&noisy, ["A"]).unwrap_err().to_string(); + assert!(err.ends_with("Welcome!\n(stderr) warning: x"), "{err}"); + } + + #[test] + fn fail_unless_success_names_what_destination_status_and_stderr() { + let dest = Destination::parse("u@h").unwrap(); + assert!(fail_unless_success(&output(0, "", ""), "x", &dest).is_ok()); + let err = + fail_unless_success(&output(255, "", "Connection refused\n"), "preflight", &dest) + .unwrap_err() + .to_string(); + assert!(err.starts_with("preflight on u@h failed ("), "{err}"); + assert!(err.ends_with("Connection refused"), "{err}"); + let quiet = fail_unless_success(&output(1, "", ""), "launch", &dest) + .unwrap_err() + .to_string(); + assert!(quiet.ends_with(')'), "no trailing colon: {quiet}"); + } + + #[test] + fn fake_records_rendered_commands_and_replays_replies() { + let fake = FakeSsh::new(); + fake.reply(0, "A=1\n"); + let cmd = RemoteCommand::script("printf A=1").arg("x y"); + let out = fake + .run(&dest(), &cmd, Some(b"body"), Duration::from_secs(1)) + .unwrap(); + assert_eq!(out.stdout, b"A=1\n"); + assert_eq!( + fake.calls(), + [Call { + dest: "user@host".into(), + command: "sh -c 'printf A=1' sh 'x y'".into(), + input: Some(b"body".to_vec()), + }] + ); + } + } +} diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..5a738f15 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -262,9 +262,7 @@ fn cache_id_input_loads_and_projects() { input: cache_id.to_string(), cwd: Some(cwd.path().to_path_buf()), harness: Some(Harness::Claude), - no_cache: false, - force: false, - url: None, + ..Default::default() }; let recorder = RecordingExec::default(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index bf7597ba..5bc191ae 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -184,9 +184,7 @@ 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), - no_cache: false, - force: false, - url: None, + ..Default::default() } } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 26f89db8..ae359343 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.13.2" +version = "0.13.3" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index a3ae20d9..255142e9 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -20,7 +20,7 @@ pub mod writer; pub use async_watcher::{AsyncConversationWatcher, WatcherConfig, WatcherHandle}; pub use error::{ConvoError, Result}; pub use io::ConvoIO; -pub use paths::PathResolver; +pub use paths::{PathResolver, sanitize_project_path}; pub use project::ClaudeProjector; pub use query::{ConversationQuery, HistoryQuery}; pub use reader::ConversationReader; diff --git a/crates/toolpath-claude/src/paths.rs b/crates/toolpath-claude/src/paths.rs index de3c1b3c..93f1a56b 100644 --- a/crates/toolpath-claude/src/paths.rs +++ b/crates/toolpath-claude/src/paths.rs @@ -110,10 +110,11 @@ impl PathResolver { } } -fn sanitize_project_path(path: &str) -> String { - // Claude Code maps '/', '_', and '.' to '-' when creating project - // directories. Notably, paths under dotdirs like `.claude/worktrees/…` - // double-up the dash (the leading `/.` becomes `--`). +/// The directory name under `~/.claude/projects` for a project path. +/// Claude Code maps `/`, `_`, and `.` to `-`, so a path under a dotdir +/// like `.claude/worktrees/…` carries a double dash (the `/.` becomes +/// `--`). +pub fn sanitize_project_path(path: &str) -> String { path.replace(['/', '_', '.'], "-") } diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 8bcdff2d..59bc605c 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.20.0" +version = "0.21.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.20.0" } +path-cli = { path = "../path-cli", version = "0.21.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index c72580a4..c40ce854 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.13.2", + "version": "0.13.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.20.0", + "version": "0.21.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.20.0", + "version": "0.21.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli",