diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecd9e2..8cc3585f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ All notable changes to the Toolpath workspace are documented here. +## path-cli 0.21.0 — 2026-08-26 + +- **`path-cli`** (0.21.0): `path resume` takes `--remote ` (Claude + only), `--dry-run`, and, with `--remote`, `-C ` for the + remote project directory (default: the local cwd with the local home + swapped for the remote home). The command resumes the session on an + ssh host under tmux. Two read-only probes report the remote home, the + claude path, tmux, the physical project directory, the tmux session + state, and the session file's existence, and the plan prints. Then a + live tmux session is attached to as is, a present session file is + launched as is, and an absent file is shipped over ssh stdin (0600; + written to a temporary name and renamed into place after a byte-count + check, so a present file is a complete file), launched, and attached + to. The session ID is the one `p export claude --derive-session-id` + derives. `--dry-run` stops after the plan. The transport is a new + internal ssh module, an in-process SSH client (`russh`); every remote + call has a wall-clock timeout. `scripts/resume-remote.sh` hands off to the + command after its bootstrap steps (VM creation, `--setup` seeding, + working-tree sync). +- **`toolpath-cli`** (0.21.0): lockstep bump of the deprecated shim. + +## path-cli 0.20.0 — 2026-08-25 + +- **`path-cli`** (0.20.0): `p export claude` takes `--derive-session-id`. + The flag renames the session to an ID derived from the input + document: a v4-shaped UUID from the first 128 bits of the SHA-256 of + the key-sorted compact JSON. The same document yields the same ID on + every run, so a second export of it into the same project is refused + instead of duplicated. It conflicts with `--session-id`. The + `--output` message names the session ID. `scripts/resume-remote.sh` + exports with the flag and reads the remote session ID back from the + JSONL. +- **`toolpath-cli`** (0.20.0): lockstep bump of the deprecated shim. + +## path-cli 0.19.0 — 2026-08-24 + +- **`path-cli`** (0.19.0): `p export claude` takes `--session-id ` + and `--cwd `. `--session-id` renames the session: the ID becomes + the conversation's session ID, every entry's `sessionId`, and every + `sessionId` key in every preamble line, nested keys included (Claude + Code copies the ID into `worktreeSession.sessionId` on + `worktree-state` lines); any UUID form is accepted and the output + uses the hyphenated lowercase form; with `--project` it names the + session file. `--cwd` roots the session: the directory becomes the + `cwd` of every entry that carries one; it must be an absolute POSIX + path in normalized form (no `.`, `..`, or empty component; one + trailing `/` is dropped), does not have to exist on this machine, and + conflicts with `--project`. Message content and tool results are not + touched. `scripts/resume-remote.sh` passes both flags, checks that + the output carries the remote cwd and ID, and ships it as is. +- **`toolpath-cli`** (0.19.0): lockstep bump of the deprecated shim. + ## toolpath-claude 0.13.0 — 2026-08-23 - **Breaking:** `Conversation.segment_ids` replaces `Conversation.session_ids`. diff --git a/Cargo.lock b/Cargo.lock index 77131a28..6b8552aa 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,14 +3107,24 @@ 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.18.0" +version = "0.21.0" dependencies = [ "anyhow", "assert_cmd", "chrono", "clap", + "crossterm", "figment", "git2", "hex", @@ -2508,9 +3140,11 @@ dependencies = [ "regex", "reqwest", "rusqlite", + "russh", "serde", "serde_json", - "sha2", + "sha2 0.10.9", + "shlex", "similar", "skim", "tempfile", @@ -2550,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" @@ -2573,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" @@ -2616,10 +3269,20 @@ dependencies = [ name = "pest_meta" version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "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 = [ - "pest", - "sha2", + "base64ct", + "ctutils", ] [[package]] @@ -2680,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" @@ -2692,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" @@ -2792,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" @@ -2998,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" @@ -3023,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" @@ -3288,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" @@ -3298,7 +4077,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3322,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" @@ -3336,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" @@ -3442,7 +4336,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3457,6 +4351,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[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" @@ -3507,6 +4411,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" @@ -3646,6 +4576,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" @@ -3664,8 +4604,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]] @@ -3675,8 +4626,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]] @@ -3750,6 +4733,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" @@ -3841,6 +4840,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" @@ -4014,7 +5088,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -4292,7 +5366,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "toolpath", @@ -4314,7 +5388,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "toolpath", @@ -4363,7 +5437,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "thiserror 1.0.69", "toolpath", @@ -4626,12 +5700,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" @@ -4946,7 +6036,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -5046,6 +6136,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" @@ -5059,6 +6170,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" @@ -5087,6 +6209,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" @@ -5176,6 +6308,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" @@ -5381,6 +6522,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" @@ -5459,9 +6611,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 5944d9e9..04cd95fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.18.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 27bda67b..e62e5da7 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.18.0" +version = "0.21.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" @@ -41,6 +41,9 @@ jaq-core = "3.1.0" jaq-std = "3.0.1" jaq-json = "2.0.1" figment = { version = "0.10", features = ["env"] } +shlex = "1.3" +russh = "0.63" +crossterm = "0.29" [target.'cfg(not(target_os = "emscripten"))'.dependencies] toolpath-claude = { workspace = true, features = ["watcher"] } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 68805fd5..6ce8821b 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -44,6 +44,34 @@ pub enum ExportTarget { #[arg(short, long, conflicts_with = "project")] output: Option, + /// Rename the session: this ID becomes every entry's `sessionId` + /// and every `sessionId` key in every preamble line, nested keys + /// included. Any UUID form is accepted; the output uses the + /// hyphenated lowercase form. With --project it names the + /// session file. + #[arg(long, value_name = "UUID")] + session_id: Option, + + /// Rename the session to an ID derived from the document: a + /// v4-shaped UUID from the first 128 bits of the SHA-256 of the + /// key-sorted compact JSON. The same document yields the same ID + /// on every run, so a second export of it into the same project + /// is refused instead of duplicated. --cwd does not change the + /// ID. Mutually exclusive with --session-id. + #[arg(long, conflicts_with = "session_id")] + derive_session_id: bool, + + /// Root the session at this directory: it becomes the `cwd` of + /// every entry that carries one. Absolute POSIX path in + /// normalized form; it does not have to exist on this machine. + /// Mutually exclusive with --project. + // `--project` files the session under the slug of the project + // directory, and Claude Code reads every entry's `cwd` as that + // directory. A second directory value can only repeat it or + // contradict it. + #[arg(long, value_name = "DIR", conflicts_with = "project")] + cwd: Option, + /// Overwrite the session file if this session id already exists in /// the target project. Without it the export refuses rather than /// clobbering local history. @@ -208,8 +236,19 @@ pub fn run(target: ExportTarget) -> Result<()> { input, project, output, + session_id, + derive_session_id, + cwd, force, - } => run_claude(input, project, output, force), + } => run_claude( + input, + project, + output, + session_id, + derive_session_id, + cwd, + force, + ), ExportTarget::Gemini { input, project, @@ -636,18 +675,47 @@ fn run_claude( input: String, project: Option, output: Option, + session_id: Option, + derive_session_id: bool, + cwd: Option, force: bool, ) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output, force); + let _ = ( + input, + project, + output, + session_id, + derive_session_id, + cwd, + force, + ); anyhow::bail!("'path export claude' requires a native environment"); } #[cfg(not(target_os = "emscripten"))] { - let path = load_path_doc(&input)?; - let conversation = build_claude_conversation(&path)?; + let cwd = cwd.as_deref().map(parse_cwd_arg).transpose()?; + let json = read_doc_json(&input)?; + let path = parse_path_doc(&json)?; + let session_id = match session_id.as_deref() { + Some(raw) => Some( + uuid::Uuid::parse_str(raw) + .with_context(|| format!("--session-id must be a UUID (got {raw:?})"))? + .hyphenated() + .to_string(), + ), + None if derive_session_id => Some(session_id_from_document_hash(&json)?), + None => None, + }; + let mut conversation = build_claude_conversation(&path)?; + if let Some(id) = &session_id { + rename_session(&mut conversation, id); + } + if let Some(dir) = &cwd { + reroot_cwd(&mut conversation, dir); + } let jsonl = serialize_jsonl(&conversation)?; match (project, output) { @@ -668,7 +736,12 @@ fn run_claude( (None, Some(out_path)) => { std::fs::write(&out_path, &jsonl) .with_context(|| format!("write {}", out_path.display()))?; - eprintln!("Wrote {} bytes to {}", jsonl.len(), out_path.display()); + eprintln!( + "Wrote session {} ({} bytes) to {}", + conversation.session_id, + jsonl.len(), + out_path.display() + ); } (None, None) => { println!("{}", jsonl); @@ -682,10 +755,18 @@ fn run_claude( #[cfg(not(target_os = "emscripten"))] fn load_path_doc(input: &str) -> Result { + parse_path_doc(&read_doc_json(input)?) +} + +#[cfg(not(target_os = "emscripten"))] +fn read_doc_json(input: &str) -> Result { let file = cache_ref(input)?; - let json = std::fs::read_to_string(&file) - .with_context(|| format!("Failed to read {}", file.display()))?; - let doc = toolpath::v1::Graph::from_json(&json) + std::fs::read_to_string(&file).with_context(|| format!("Failed to read {}", file.display())) +} + +#[cfg(not(target_os = "emscripten"))] +fn parse_path_doc(json: &str) -> Result { + let doc = toolpath::v1::Graph::from_json(json) .map_err(|e| anyhow::anyhow!("Failed to parse toolpath document: {}", e))?; doc.into_single_path().ok_or_else(|| { anyhow::anyhow!( @@ -694,8 +775,104 @@ fn load_path_doc(input: &str) -> Result { }) } +/// The session ID derived from the document `json`: a v4-shaped UUID +/// from the first 128 bits of the SHA-256 of the key-sorted compact +/// JSON. Key order and whitespace in `json` do not change the ID. +#[cfg(not(target_os = "emscripten"))] +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::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() + .hyphenated() + .to_string()) +} + +/// Renames the session: `id` becomes the conversation's session ID, +/// every entry's `sessionId`, and every `sessionId` key in every +/// preamble line, nested keys included. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn rename_session(conversation: &mut toolpath_claude::Conversation, id: &str) { + conversation.session_id = id.to_string(); + for slot in conversation + .entries + .iter_mut() + .filter_map(|e| e.session_id.as_mut()) + { + *slot = id.to_string(); + } + for raw in &mut conversation.preamble { + set_session_id_keys(raw, id); + } +} + +/// Roots the session at `dir`: it becomes the conversation's project +/// path and the `cwd` of every entry that carries one. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn reroot_cwd(conversation: &mut toolpath_claude::Conversation, dir: &str) { + conversation.project_path = Some(dir.to_string()); + for slot in conversation + .entries + .iter_mut() + .filter_map(|e| e.cwd.as_mut()) + { + *slot = dir.to_string(); + } +} + +/// Sets every `sessionId` key in `value`, at any depth, to `id`. +#[cfg(not(target_os = "emscripten"))] +fn set_session_id_keys(value: &mut serde_json::Value, id: &str) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map.iter_mut() { + if key == "sessionId" { + *child = serde_json::Value::String(id.to_string()); + } else { + set_session_id_keys(child, id); + } + } + } + serde_json::Value::Array(items) => { + for child in items { + set_session_id_keys(child, id); + } + } + _ => {} + } +} + +/// 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. #[cfg(not(target_os = "emscripten"))] -fn build_claude_conversation(path: &toolpath::v1::Path) -> Result { +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(not(target_os = "emscripten"))] +pub(crate) fn build_claude_conversation( + path: &toolpath::v1::Path, +) -> Result { use toolpath_convo::ConversationProjector; let view = toolpath_convo::extract_conversation(path); let projector = toolpath_claude::ClaudeProjector; @@ -705,7 +882,7 @@ fn build_claude_conversation(path: &toolpath::v1::Path) -> Result Result { +pub(crate) fn serialize_jsonl(conv: &toolpath_claude::Conversation) -> Result { let mut buf = Vec::new(); toolpath_claude::ConversationWriter::write_conversation(conv, &mut buf)?; Ok(String::from_utf8(buf).expect("serde_json emits UTF-8")) @@ -2146,6 +2323,9 @@ mod tests { input_path.to_string_lossy().to_string(), None, Some(output_path.clone()), + None, + false, + None, false, ) .unwrap(); @@ -2157,6 +2337,258 @@ mod tests { } } + /// `make_path_doc` with `cwd` recorded on every step. + fn make_path_doc_with_cwd(cwd: &str) -> toolpath::v1::Graph { + let mut path = make_path_doc().into_single_path().unwrap(); + for step in &mut path.steps { + for change in step.change.values_mut() { + if let Some(structural) = change.structural.as_mut() { + structural + .extra + .insert("cwd".to_string(), serde_json::json!(cwd)); + } + } + } + toolpath::v1::Graph::from_path(path) + } + + /// Runs `p export claude --output` on `doc` and parses the lines. + fn export_claude_lines( + doc: &toolpath::v1::Graph, + session_id: Option<&str>, + derive_session_id: bool, + cwd: Option<&str>, + ) -> Vec { + let temp = tempfile::tempdir().unwrap(); + let input_path = temp.path().join("input.json"); + let output_path = temp.path().join("out.jsonl"); + std::fs::write(&input_path, serde_json::to_string(doc).unwrap()).unwrap(); + run_claude( + input_path.to_string_lossy().to_string(), + None, + Some(output_path.clone()), + session_id.map(str::to_string), + derive_session_id, + cwd.map(str::to_string), + false, + ) + .unwrap(); + std::fs::read_to_string(&output_path) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } + + fn values_of<'a>(lines: &'a [serde_json::Value], key: &str) -> Vec<&'a str> { + lines.iter().filter_map(|v| v.get(key)?.as_str()).collect() + } + + #[test] + fn claude_cwd_flag_rewrites_every_cwd() { + let doc = make_path_doc_with_cwd("/old/project"); + let plain = export_claude_lines(&doc, None, false, None); + let old = values_of(&plain, "cwd"); + assert!(!old.is_empty()); + assert!(old.iter().all(|c| *c == "/old/project")); + + let rooted = export_claude_lines(&doc, None, false, Some("/new/dir/")); + assert_eq!(rooted.len(), plain.len()); + let new = values_of(&rooted, "cwd"); + assert_eq!(new.len(), old.len()); + assert!(new.iter().all(|c| *c == "/new/dir")); + } + + #[test] + fn claude_session_id_flag_rewrites_every_session_id() { + let doc = make_path_doc(); + let plain = export_claude_lines(&doc, None, false, None); + let old = values_of(&plain, "sessionId"); + assert_eq!(old.len(), plain.len(), "every line carries a sessionId"); + + let id = "B7E1C0DE-0000-4000-8000-000000000001"; + let renamed = export_claude_lines(&doc, Some(id), false, None); + assert_eq!(renamed.len(), plain.len()); + let new = values_of(&renamed, "sessionId"); + assert_eq!(new.len(), old.len()); + assert!(new.iter().all(|s| *s == id.to_ascii_lowercase())); + } + + #[test] + fn claude_session_id_flag_rewrites_nested_session_id() { + let mut path = make_path_doc().into_single_path().unwrap(); + let artifact_key = path.steps[0].change.keys().next().unwrap().clone(); + let mut extra = HashMap::new(); + extra.insert( + "entry_type".to_string(), + serde_json::json!("worktree-state"), + ); + extra.insert( + "raw".to_string(), + serde_json::json!({ + "type": "worktree-state", + "sessionId": "test-session", + "worktreeSession": {"sessionId": "test-session", "worktreePath": "/wt"} + }), + ); + path.steps.push(Step { + step: StepIdentity { + id: "step-003".to_string(), + parents: vec!["step-002".to_string()], + actor: "tool:claude-code".to_string(), + timestamp: "2024-01-01T00:00:02Z".to_string(), + }, + change: HashMap::from([( + artifact_key, + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.event".to_string(), + extra, + }), + }, + )]), + meta: None, + }); + path.path.head = "step-003".to_string(); + let doc = toolpath::v1::Graph::from_path(path); + + let id = "b7e1c0de-0000-4000-8000-000000000001"; + let lines = export_claude_lines(&doc, Some(id), false, None); + let line = lines + .iter() + .find(|v| v["type"] == "worktree-state") + .expect("worktree-state line survives export"); + assert_eq!(line["sessionId"], id); + assert_eq!(line["worktreeSession"]["sessionId"], id); + assert_eq!(line["worktreeSession"]["worktreePath"], "/wt"); + } + + /// 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()); + } + + #[test] + fn claude_derive_session_id_flag_stamps_the_derived_id() { + let doc = make_path_doc(); + let plain = export_claude_lines(&doc, None, false, None); + let source_ids = values_of(&plain, "sessionId"); + assert_eq!( + source_ids.len(), + plain.len(), + "every line carries a sessionId" + ); + + let expected = + session_id_from_document_hash(&serde_json::to_string(&doc).unwrap()).unwrap(); + assert!(!source_ids.contains(&expected.as_str())); + let derived = export_claude_lines(&doc, None, true, None); + assert_eq!(derived.len(), plain.len()); + let ids = values_of(&derived, "sessionId"); + assert_eq!(ids.len(), source_ids.len()); + assert!(ids.iter().all(|s| *s == expected)); + } + + #[test] + fn claude_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(), "/"); + } + + #[test] + fn claude_session_id_flag_rejects_non_uuids() { + let temp = tempfile::tempdir().unwrap(); + let input_path = temp.path().join("input.json"); + std::fs::write( + &input_path, + serde_json::to_string(&make_path_doc()).unwrap(), + ) + .unwrap(); + let err = run_claude( + input_path.to_string_lossy().to_string(), + None, + Some(temp.path().join("out.jsonl")), + Some("not-a-uuid".to_string()), + false, + None, + false, + ) + .unwrap_err(); + assert!(err.to_string().contains("--session-id must be a UUID")); + } + + #[test] + fn claude_session_id_with_project_names_the_session_file() { + let temp = tempfile::tempdir().unwrap(); + let fake_home = temp.path().join("home"); + let project_dir = temp.path().join("project"); + std::fs::create_dir_all(&fake_home).unwrap(); + std::fs::create_dir_all(&project_dir).unwrap(); + let input_path = temp.path().join("input.json"); + std::fs::write( + &input_path, + serde_json::to_string(&make_path_doc()).unwrap(), + ) + .unwrap(); + let id = "b7e1c0de-0000-4000-8000-000000000001"; + + let _g = crate::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let prior_home = std::env::var_os("HOME"); + unsafe { + std::env::set_var("HOME", &fake_home); + } + let result = run_claude( + input_path.to_string_lossy().to_string(), + Some(project_dir.clone()), + None, + Some(id.to_string()), + false, + None, + false, + ); + unsafe { + match prior_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + result.expect("export claude"); + + let canon = std::fs::canonicalize(&project_dir).unwrap(); + let file = toolpath_claude::PathResolver::new() + .with_home(&fake_home) + .project_dir(canon.to_str().unwrap()) + .unwrap() + .join(format!("{id}.jsonl")); + assert!(file.is_file(), "{}", file.display()); + } + #[test] fn claude_rejects_multi_path_graph() { let temp = tempfile::tempdir().unwrap(); @@ -2190,8 +2622,16 @@ mod tests { }; std::fs::write(&input_path, serde_json::to_string(&multi).unwrap()).unwrap(); - let err = - run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err(); + let err = run_claude( + input_path.to_string_lossy().to_string(), + None, + None, + None, + false, + None, + false, + ) + .unwrap_err(); assert!(err.to_string().contains("single-path graph")); } @@ -2200,8 +2640,16 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let input_path = temp.path().join("input.json"); std::fs::write(&input_path, "not json").unwrap(); - let err = - run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err(); + let err = run_claude( + input_path.to_string_lossy().to_string(), + None, + None, + None, + false, + None, + false, + ) + .unwrap_err(); assert!(err.to_string().contains("parse") || err.to_string().contains("Failed")); } @@ -3274,9 +3722,43 @@ mod tests { unsafe { std::env::set_var("HOME", &fake_home); } - let first = run_claude(input.clone(), Some(cwd.clone()), None, false); - let second = run_claude(input.clone(), Some(cwd.clone()), None, false); - let forced = run_claude(input, Some(cwd.clone()), None, true); + let first = run_claude( + input.clone(), + Some(cwd.clone()), + None, + None, + false, + None, + false, + ); + let second = run_claude( + input.clone(), + Some(cwd.clone()), + None, + None, + false, + None, + false, + ); + let forced = run_claude( + input.clone(), + Some(cwd.clone()), + None, + None, + false, + None, + true, + ); + let derived_first = run_claude( + input.clone(), + Some(cwd.clone()), + None, + None, + true, + None, + false, + ); + let derived_second = run_claude(input, Some(cwd.clone()), None, None, true, None, false); unsafe { match prior_home { Some(v) => std::env::set_var("HOME", v), @@ -3291,6 +3773,12 @@ mod tests { "unhelpful error: {err}" ); forced.expect("re-export with --force should succeed"); + derived_first.expect("first derived export should succeed"); + let err = derived_second.expect_err("derived re-export without --force must fail"); + assert!( + err.to_string().contains("--force"), + "unhelpful error: {err}" + ); } #[test] diff --git a/crates/path-cli/src/cmd_incept.rs b/crates/path-cli/src/cmd_incept.rs index 6edcc7db..9a87409d 100644 --- a/crates/path-cli/src/cmd_incept.rs +++ b/crates/path-cli/src/cmd_incept.rs @@ -58,6 +58,9 @@ pub fn run(target: InceptTarget) -> Result<()> { input, project, output, + session_id: None, + derive_session_id: false, + cwd: None, force: false, }) } diff --git a/crates/path-cli/src/cmd_project.rs b/crates/path-cli/src/cmd_project.rs index f96db7a8..511be3b7 100644 --- a/crates/path-cli/src/cmd_project.rs +++ b/crates/path-cli/src/cmd_project.rs @@ -31,6 +31,9 @@ pub fn run(target: ProjectTarget) -> Result<()> { input, project: None, output, + session_id: None, + derive_session_id: false, + cwd: None, force: false, }) } diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 4b0b6e3d..ddfddafa 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -40,7 +40,7 @@ #![cfg(not(target_os = "emscripten"))] -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use clap::Args; use std::path::PathBuf; @@ -80,6 +80,19 @@ pub struct ResumeArgs { /// then `$PATHBASE_URL`, then `https://pathbase.dev`. #[arg(long)] pub url: Option, + + /// Plan the resume on this ssh destination instead of this + /// machine (`host`, `user@host`, or an ssh config alias; 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, + + /// Stop after printing the plan. Only with --remote. + #[arg(long, requires = "remote")] + pub dry_run: bool, } pub fn run(args: ResumeArgs) -> Result<()> { @@ -89,7 +102,19 @@ 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)?; + if args.remote.is_some() { + let status = run_remote_with_transport(&args, &resolved, &crate::ssh::Russh::new()?)?; + if status != 0 { + std::process::exit(status as i32); + } + return Ok(()); + } + let ResolvedInput { + graph, + source_harness, + .. + } = resolved; let path = ensure_path_with_agent(&graph)?; let cwd = match args.cwd.as_ref() { @@ -117,6 +142,62 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<() use toolpath::v1::{Graph, Path as TPath, PathOrRef}; +/// `--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, + resolved: &ResolvedInput, + transport: &dyn crate::ssh::Transport, +) -> Result { + let dest = args.remote.as_ref().expect("caller checked --remote"); + { + use std::io::IsTerminal; + if !args.dry_run && !std::io::stdin().is_terminal() { + bail!( + "`path resume --remote` needs an interactive terminal for the \ + tmux attach: stdin is not a TTY (pass --dry-run to stop at the plan)" + ); + } + } + let document = ensure_path_with_agent(&resolved.graph)?; + if let Some(h) = args.harness + && h != Harness::Claude + { + bail!( + "remote resume supports claude only (got --harness {})", + h.name() + ); + } + if args.harness.is_none() && resolved.source_harness != Some(Harness::Claude) { + bail!( + "remote resume supports claude only; the document's source is {}. \ + Pass `--harness claude` to force a Claude projection.", + resolved + .source_harness + .map(|h| h.name()) + .unwrap_or("unknown") + ); + } + let remote_dir_flag = match args.cwd.as_deref() { + Some(p) => Some(p.to_str().context("-C must be valid UTF-8")?.to_string()), + None => None, + }; + let local_home = crate::config::home_dir(); + let local_cwd = std::env::current_dir()?; + crate::cmd_resume_remote::run_remote( + &crate::cmd_resume_remote::RemoteResume { + document, + document_json: &resolved.json, + dest, + remote_dir_flag: remote_dir_flag.as_deref(), + dry_run: args.dry_run, + local_home: local_home.as_deref(), + local_cwd: &local_cwd, + }, + transport, + ) +} + /// 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. @@ -196,7 +277,18 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { /// 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, + pub(crate) json: String, +} + +pub(crate) fn resolve_input(args: &ResumeArgs) -> Result { let raw = args.input.as_str(); enum Shape<'a> { @@ -216,7 +308,7 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option Shape::CacheId(raw) }; - let graph: Graph = match shape { + let json: String = 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 +325,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 } else { let derived = crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref())?; if !args.no_cache { @@ -244,14 +335,10 @@ 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 + derived.doc.to_json().map_err(|e| anyhow::anyhow!("{e}"))? } } - 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))?, Shape::CacheId(id) => { let file = crate::cache::cache_ref(id).map_err(|e| { anyhow::anyhow!( @@ -260,15 +347,18 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option e ) })?; - 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))? + std::fs::read_to_string(&file).with_context(|| format!("read {}", 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: {}", e))?; + let source_harness = graph.single_path().and_then(infer_source_harness); + Ok(ResolvedInput { + graph, + source_harness, + json, + }) } /// Probe `$PATH` (or `path_override`, for tests) for a given binary name. @@ -588,6 +678,42 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { mod tests { use super::*; + #[cfg(unix)] + #[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(), + cwd: None, + harness, + no_cache: false, + force: false, + url: None, + remote: Some(crate::ssh::Destination::parse("user@host").unwrap()), + dry_run: true, + }; + let fake = crate::ssh::fake::FakeSsh::new(); + + let err = run_remote_with_transport(&args(None), &resolved, &fake).unwrap_err(); + assert!(err.to_string().contains("supports claude only"), "{err:#}"); + assert!(err.to_string().contains("--harness claude"), "{err:#}"); + + let err = + run_remote_with_transport(&args(Some(Harness::Codex)), &resolved, &fake).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 @@ -615,6 +741,8 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; let recorder = RecordingExec::default(); @@ -751,8 +879,14 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; - 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)); } @@ -785,8 +919,14 @@ mod tests { no_cache: true, // skip cache write in tests force: false, url: None, + remote: None, + dry_run: false, }; - let (g, harness) = resolve_input(&args).unwrap(); + let ResolvedInput { + graph: g, + source_harness: harness, + .. + } = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); assert_eq!(harness, Some(Harness::Codex)); } @@ -846,8 +986,10 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; - 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 { @@ -874,6 +1016,8 @@ mod tests { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/src/cmd_resume_remote.rs b/crates/path-cli/src/cmd_resume_remote.rs new file mode 100644 index 00000000..3cd17a73 --- /dev/null +++ b/crates/path-cli/src/cmd_resume_remote.rs @@ -0,0 +1,671 @@ +//! `path resume --remote`: resume a Claude session on a remote host +//! under tmux. +//! +//! The local host does all toolpath work: it projects the +//! conversation in memory, renames it to the derived session ID, +//! roots it at the remote project directory, and ships the JSONL +//! over ssh stdin. The remote runs no `path`. +//! +//! The remote wins once it exists: a live tmux session is attached +//! to as is, a present session file is launched as is, and only an +//! absent file is shipped. Two read-only probe calls decide which; +//! the first remote write is the ship step. +//! +//! The probes are constant `sh` scripts 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 a probe or the launch. Each runs a few shell +/// builtins; the launch returns as soon as tmux has forked. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); + +/// Wall-clock bound on the ship: 60s plus one second per 64 KiB, the +/// time a 512 kbit/s uplink needs. +fn ship_timeout(bytes: usize) -> Duration { + Duration::from_secs(60 + (bytes / (64 * 1024)) as u64) +} + +/// 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, +} + +/// The remote resume request: the document, where it goes, and the +/// local context. `local_home` and `local_cwd` are fields so tests +/// control them; production passes the real ones. +pub(crate) struct RemoteResume<'a> { + pub(crate) document: &'a toolpath::v1::Path, + pub(crate) document_json: &'a str, + pub(crate) dest: &'a Destination, + pub(crate) remote_dir_flag: Option<&'a str>, + pub(crate) dry_run: bool, + pub(crate) local_home: Option<&'a Path>, + pub(crate) local_cwd: &'a Path, +} + +/// Entry point. +/// Returns the exit status of the attached command, 0 for a dry run. +pub(crate) fn run_remote(request: &RemoteResume, transport: &dyn Transport) -> Result { + let RemoteResume { + document, + document_json, + dest, + remote_dir_flag, + dry_run, + local_home, + local_cwd, + } = *request; + let session_id = crate::cmd_export::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 resolver = toolpath_claude::PathResolver::new().with_home(facts.home.as_str()); + let session_file = utf8_path(&resolver.conversation_file(&project_dir, &session_id)?)?; + + 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(0); + } + + if plan.action == RunAction::Ship { + let mut conversation = crate::cmd_export::build_claude_conversation(document)?; + crate::cmd_export::rename_session(&mut conversation, &plan.session_id); + crate::cmd_export::reroot_cwd(&mut conversation, &plan.project_dir); + let jsonl = crate::cmd_export::serialize_jsonl(&conversation)?; + + eprintln!( + "Shipping session {} to {dest}:{}", + plan.session_id, plan.session_file + ); + let slug_dir = utf8_path(&resolver.project_dir(&plan.project_dir)?)?; + let ship = RemoteCommand::script( + r#"umask 077 +mkdir -p "$1" && cat > "$2.tmp" && [ "$(wc -c < "$2.tmp")" -eq "$3" ] && mv "$2.tmp" "$2" || { rm -f "$2.tmp"; exit 1; } +"#, + ) + .arg(slug_dir) + .arg(plan.session_file.as_str()) + .arg(jsonl.len().to_string()); + let output = transport.run( + dest, + &ship, + Some(jsonl.as_bytes()), + ship_timeout(jsonl.len()), + )?; + fail_unless_success(&output, "shipping the session", dest)?; + } + + if plan.action != RunAction::Attach { + eprintln!("Launching {} in {}", plan.tmux_name, plan.project_dir); + // The inner string is the command tmux hands to `sh -c`; its + // values are quoted for that inner shell by the remote script. + let launch = RemoteCommand::script( + r#"exec tmux new-session -d -s "$1" -c "$2" "env LANG=C.UTF-8 \"$3\" -r \"$4\"" +"#, + ) + .arg(plan.tmux_name.as_str()) + .arg(plan.project_dir.as_str()) + .arg(inner_shell_safe(&plan.claude_path)?) + .arg(plan.session_id.as_str()); + let output = transport.run(dest, &launch, None, COMMAND_TIMEOUT)?; + fail_unless_success(&output, "launching the tmux session", dest)?; + } + + eprintln!( + "Attaching to {} on {dest} (detach with ctrl-b d)", + plan.tmux_name + ); + // `=` pins the exact session name; `-d` detaches a stale client. + let attach = RemoteCommand::new("tmux") + .arg("attach-session") + .arg("-d") + .arg("-t") + .arg(format!("={}", plan.tmux_name)); + transport.attach(dest, &attach) +} + +/// The launch script embeds `$3` in escaped double quotes for the +/// shell tmux starts, so the value must not contain a character that +/// shell reads inside double quotes. The session ID is a UUID; the +/// claude path is the one probed value that could. +fn inner_shell_safe(value: &str) -> Result<&str> { + if value.contains(['"', '$', '`', '\\', '\'']) { + bail!( + "the remote claude path must not contain a quote, `$`, \ + backtick, or backslash (got {value:?})" + ); + } + Ok(value) +} + +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( + r#"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" +"#, + ); + let command = CLAUDE_PROBE_LOCATIONS + .iter() + .fold(command, |c, probe| c.arg(*probe)); + let output = transport.run(dest, &command, None, COMMAND_TIMEOUT)?; + fail_unless_success(&output, "host probe", dest)?; + let values = parse_facts(&output, &["TP_HOME", "TP_CLAUDE", "TP_TMUX"])?; + let [home, claude, tmux] = values.try_into().expect("parse_facts returns one per tag"); + + 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 { + // The `=` prefix pins tmux to an exact session-name match; a bare + // `-t ` also matches any session whose name extends it. + let command = RemoteCommand::script( + r#"set -u +if cd "$1" 2>/dev/null; then p=$(pwd -P); else p=""; fi +printf "TP_PWD=%s\n" "$p" +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" +"#, + ) + .arg(project_dir) + .arg(tmux_name) + .arg(session_file); + let output = transport.run(dest, &command, None, COMMAND_TIMEOUT)?; + fail_unless_success(&output, "project directory probe", dest)?; + let values = parse_facts(&output, &["TP_PWD", "TP_SESSION", "TP_TARGET"])?; + let [pwd, session, target] = values.try_into().expect("parse_facts returns one per tag"); + 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` verbatim, else the local cwd with the local home swapped for +/// the remote home. +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 { + let dir = dir.trim_end_matches('/'); + let dir = if dir.is_empty() { "/" } else { dir }; + validate_remote_dir(dir)?; + return Ok(dir.to_string()); + } + 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(|rel| rel.to_str()) + .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) + }; + validate_remote_dir(&dir)?; + Ok(dir) +} + +fn validate_remote_dir(dir: &str) -> Result<()> { + if !dir.starts_with('/') { + bail!("the remote project directory must be absolute (got {dir})"); + } + if dir.contains('\n') { + bail!("the remote project directory must be a single line"); + } + if dir.split('/').any(|component| component == "..") { + bail!("the remote project directory must not contain `..` (got {dir})"); + } + Ok(()) +} + +// ── Small pure helpers ─────────────────────────────────────────────── + +/// 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]) +} + +fn utf8_path(path: &Path) -> Result { + Ok(path + .to_str() + .context("remote path is not valid UTF-8")? + .to_string()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::ssh::fake::{Call, 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 { + let json = doc_json(); + let graph = toolpath::v1::Graph::from_json(&json).unwrap(); + let document = graph.single_path().unwrap(); + run_remote( + &RemoteResume { + document, + document_json: &json, + dest: &dest(), + remote_dir_flag: Some(DIR), + dry_run, + local_home: Some(Path::new("/home/local")), + local_cwd: Path::new("/home/local/work"), + }, + fake, + ) + } + + /// The expected derived session ID for [`doc_json`]. + fn session_id() -> String { + crate::cmd_export::session_id_from_document_hash(&doc_json()).unwrap() + } + + #[test] + fn file_absent_ships_launches_and_attaches() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, DIR, "none", "no"); + fake.reply(0, ""); // ship + fake.reply(0, ""); // launch + run(&fake, false).unwrap(); + + let calls = fake.calls(); + assert_eq!(calls.len(), 5); + // Probes are read-only. + for call in &calls[..2] { + let Call::Run { input, .. } = call else { + panic!("probes are runs"); + }; + assert!(input.is_none()); + } + let id = session_id(); + let Call::Run { command, input, .. } = &calls[2] else { + panic!("ship is a run"); + }; + assert!(command.contains("umask 077"), "{command}"); + assert!(command.contains(&format!("{id}.jsonl")), "{command}"); + let jsonl = String::from_utf8(input.clone().expect("ship feeds stdin")).unwrap(); + let last = jsonl.lines().last().unwrap(); + let line: serde_json::Value = serde_json::from_str(last).unwrap(); + assert_eq!(line["sessionId"], id.as_str()); + let Call::Run { command, input, .. } = &calls[3] else { + panic!("launch is a run"); + }; + assert!(command.contains("tmux new-session"), "{command}"); + assert!(command.contains(&id), "{command}"); + assert!(input.is_none()); + let Call::Attach { command, .. } = &calls[4] else { + panic!("the last call attaches"); + }; + assert!( + command.contains(&format!("=path-{}", &id[..8])), + "{command}" + ); + } + + #[test] + fn file_present_launches_and_attaches_without_shipping() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, DIR, "none", "yes"); + fake.reply(0, ""); // launch + run(&fake, false).unwrap(); + let calls = fake.calls(); + assert_eq!(calls.len(), 4); + assert!( + matches!(&calls[2], Call::Run { command, .. } if command.contains("tmux new-session")) + ); + assert!(matches!(&calls[3], Call::Attach { .. })); + } + + #[test] + fn live_session_attaches_without_shipping_or_launching() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, DIR, "live", "yes"); + run(&fake, false).unwrap(); + let calls = fake.calls(); + assert_eq!(calls.len(), 3); + assert!(matches!(&calls[2], Call::Attach { .. })); + } + + #[test] + fn a_failed_ship_stops_before_launch_and_attach() { + let fake = FakeSsh::new(); + reply_host_ok(&fake); + reply_dir(&fake, DIR, "none", "no"); + fake.reply_with_stderr(1, "", "disk full"); + let err = run(&fake, false).unwrap_err(); + assert!(err.to_string().contains("shipping the session"), "{err:#}"); + assert!(err.to_string().contains("disk full"), "{err:#}"); + assert_eq!(fake.calls().len(), 3); + } + + #[test] + fn an_unsafe_claude_path_errors_before_launch() { + let fake = FakeSsh::new(); + fake.reply( + 0, + &format!("TP_HOME={HOME}\nTP_CLAUDE=/opt/o'brien/claude\nTP_TMUX=ok\n"), + ); + reply_dir(&fake, DIR, "none", "yes"); + let err = run(&fake, false).unwrap_err(); + assert!(err.to_string().contains("must not contain"), "{err:#}"); + assert_eq!(fake.calls().len(), 2); + } + + #[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::cmd_export::session_id_from_document_hash(&doc_json()).unwrap(); + let Call::Run { command, .. } = &fake.calls()[1] else { + panic!("call 2 is a run"); + }; + 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\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" + ); + } +} diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 71d4f66e..99f7caf0 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -23,6 +23,8 @@ mod cmd_render; #[cfg(not(target_os = "emscripten"))] pub mod cmd_resume; #[cfg(not(target_os = "emscripten"))] +mod cmd_resume_remote; +#[cfg(not(target_os = "emscripten"))] mod cmd_share; #[cfg(not(target_os = "emscripten"))] mod cmd_show; @@ -44,6 +46,8 @@ mod schema; mod share_config; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; +#[cfg(not(target_os = "emscripten"))] +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..0cd22589 --- /dev/null +++ b/crates/path-cli/src/ssh.rs @@ -0,0 +1,1009 @@ +//! 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 names the host and, before `@`, the +//! user; the port is 22. The agent (`SSH_AUTH_SOCK`) authenticates +//! first, then the default identity files under `~/.ssh`. +//! `~/.ssh/known_hosts` 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. +//! [`Transport::attach`] bridges the local terminal to a PTY 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::learn_known_hosts; +use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKeyOrCertificate, check_known_hosts}; +use russh::{ChannelMsg, Disconnect}; +use std::fmt; +use std::path::PathBuf; +use std::process::Output; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// An ssh destination: `host`, `user@host`, or an ssh config alias. +/// `pub` because it is the type of a `pub` field of `ResumeArgs`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Destination(String); + +impl Destination { + /// The first character is alphanumeric, so a value cannot be an + /// option. Every other character is one of `[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 mut chars = s.chars(); + let ok = match chars.next() { + Some(c) if c.is_ascii_alphanumeric() => { + chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '@' | '.' | '_' | '-')) + } + _ => false, + }; + if !ok { + return Err(format!( + "expected an ssh destination such as user@host \ + (letters, digits, and `@._-`); use an ssh config alias \ + for an IPv6 address or a port, got {s:?}" + )); + } + Ok(Self(s.to_string())) + } + + /// The user before `@`, when given. + fn user(&self) -> Option<&str> { + self.0.split_once('@').map(|(user, _)| user) + } + + /// The host or alias after `@`, else the whole value. + fn host(&self) -> &str { + self.0.split_once('@').map_or(&self.0, |(_, host)| host) + } +} + +impl fmt::Display for Destination { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// 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`]. + 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 + } + + /// 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. 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; + + /// Run `command` on `dest` in a PTY with this terminal attached to + /// it. Returns the command's exit status once it exits, and errors + /// only when the session cannot be set up. + fn attach(&self, dest: &Destination, command: &RemoteCommand) -> 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); + +/// Keepalives on every session, so a peer that stops answering is +/// noticed within a minute during an attach, where no other bound +/// applies. +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15); +const KEEPALIVE_MAX: usize = 3; + +/// The in-process SSH client. Each call opens one connection. +pub(crate) struct Russh { + runtime: Option, +} + +impl Russh { + pub(crate) fn new() -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("start the async runtime")?; + Ok(Self { + runtime: Some(runtime), + }) + } + + fn runtime(&self) -> &tokio::runtime::Runtime { + self.runtime.as_ref().expect("the runtime lives until drop") + } +} + +impl Drop for Russh { + // The terminal reader of `attach` is a blocking task that ends only + // on the next keypress; a plain drop of the runtime would wait for + // it. + fn drop(&mut self) { + if let Some(runtime) = self.runtime.take() { + runtime.shutdown_background(); + } + } +} + +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).await?; + let result = exec_captured(&session, &command, input, timeout).await; + session.close().await; + result + }) + } + + fn attach(&self, dest: &Destination, command: &RemoteCommand) -> Result { + let command = command.render()?; + self.runtime().block_on(async { + let session = connect(dest).await?; + let result = attach_pty(&session, &command).await; + session.close().await; + result + }) + } +} + +/// Verifies the server's host key against `~/.ssh/known_hosts`. +struct HostKeyCheck { + host: String, + port: u16, +} + +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!( + "{}:{} presented a host certificate; only plain host keys are accepted", + self.host, + self.port + ); + }; + match check_known_hosts(&self.host, self.port, key) { + Ok(true) => Ok(true), + Ok(false) => { + learn_known_hosts(&self.host, self.port, key).with_context(|| { + format!("record the host key of {}:{}", self.host, self.port) + })?; + eprintln!( + "Learned the host key of {}:{} ({} {})", + self.host, + self.port, + key.algorithm(), + key.fingerprint(HashAlg::Sha256) + ); + Ok(true) + } + Err(russh::keys::Error::KeyChanged { line }) => bail!( + "the host key of {}:{} does not match ~/.ssh/known_hosts line {line}; \ + refusing to connect", + self.host, + self.port + ), + Err(e) => Err(e).context("read ~/.ssh/known_hosts"), + } + } +} + +struct Session { + handle: client::Handle, +} + +impl Session { + async fn close(self) { + let _ = self + .handle + .disconnect(Disconnect::ByApplication, "", "English") + .await; + } +} + +/// Connect, verify the host key, and authenticate, within +/// [`CONNECT_TIMEOUT`]. +async fn connect(dest: &Destination) -> Result { + let host = dest.host().to_string(); + let port = 22; + let user = match dest.user() { + Some(user) => user.to_string(), + None => local_username() + .context("no user in the destination and no local username; pass user@host")?, + }; + + 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: host.clone(), + port, + }; + let mut handle = client::connect(client_config, (host.as_str(), port), handler) + .await + .with_context(|| format!("connect to {host}:{port}"))?; + authenticate(&mut handle, &user, &host).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() + ), + } +} + +/// `$USER`, else `$USERNAME`. +fn local_username() -> Option { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .ok() + .filter(|user| !user.is_empty()) +} + +/// The agent's identities first, then the identity files. The error +/// lists what was tried. +async fn authenticate( + handle: &mut client::Handle, + user: &str, + host: &str, +) -> Result<()> { + let rsa_hash = handle + .best_supported_rsa_hash() + .await + .context("negotiate signature algorithms")? + .flatten(); + let mut tried: Vec = Vec::new(); + + #[cfg(unix)] + match russh::keys::agent::client::AgentClient::connect_env().await { + Ok(mut agent) => { + let identities = agent + .request_identities() + .await + .context("list the agent's identities")?; + for identity in identities { + let russh::keys::agent::AgentIdentity::PublicKey { key, comment } = identity else { + continue; + }; + let result = handle + .authenticate_publickey_with(user, key, rsa_hash, &mut agent) + .await + .map_err(|e| anyhow!("agent signature for {comment:?}: {e}"))?; + if result.success() { + return Ok(()); + } + tried.push(format!("agent key {comment:?}")); + } + } + Err(e) => tried.push(format!("agent ({e})")), + } + + for path in identity_file_candidates() { + 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 = handle + .authenticate_publickey(user, PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash)) + .await + .with_context(|| format!("authenticate with {}", path.display()))?; + 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 under `~/.ssh`. +fn identity_file_candidates() -> Vec { + let Some(home) = crate::config::home_dir() else { + return Vec::new(); + }; + ["id_ed25519", "id_ecdsa", "id_rsa"] + .iter() + .map(|name| home.join(".ssh").join(name)) + .collect() +} + +/// One exec channel: feed `input`, collect stdout and stderr, and take +/// the exit status, all within `timeout`. +async fn exec_captured( + session: &Session, + command: &str, + input: Option<&[u8]>, + timeout: Duration, +) -> Result { + 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 mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut status = None; + 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::Close => break, + _ => {} + } + } + Ok::<(), anyhow::Error>(()) + }; + let outcome = tokio::time::timeout(timeout, async { tokio::try_join!(feed, collect) }).await; + match outcome { + Ok(Ok(((), ()))) => {} + Ok(Err(e)) => return Err(e), + Err(_) => { + let stderr = String::from_utf8_lossy(&stderr).trim_end().to_string(); + bail!( + "remote command did not finish within {}s; \ + the connection may be fine while the command hangs{}", + timeout.as_secs(), + if stderr.is_empty() { + String::new() + } else { + format!("\n(stderr) {}", tail(&stderr, 1000)) + } + ); + } + } + Ok(Output { + status: exit_status(status.unwrap_or(255)), + stdout, + stderr, + }) +} + +#[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 terminal in raw mode until dropped. +struct RawMode; + +impl RawMode { + fn enable() -> Result { + crossterm::terminal::enable_raw_mode().context("switch the terminal to raw mode")?; + Ok(Self) + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } +} + +#[cfg(unix)] +type ResizeSignal = tokio::signal::unix::Signal; +#[cfg(not(unix))] +type ResizeSignal = (); + +#[cfg(unix)] +fn watch_resizes() -> Option { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()).ok() +} +#[cfg(not(unix))] +fn watch_resizes() -> Option { + None +} + +/// Resolves on the next terminal resize; never, without a signal. +async fn resized(signal: &mut Option) { + #[cfg(unix)] + if let Some(signal) = signal { + signal.recv().await; + return; + } + let _ = signal; + std::future::pending::<()>().await +} + +/// One PTY channel with this terminal in raw mode: keystrokes go to +/// the channel, channel output goes to stdout, resizes are forwarded. +async fn attach_pty(session: &Session, command: &str) -> Result { + let channel = session + .handle + .channel_open_session() + .await + .context("open a session channel")?; + let (cols, rows) = crossterm::terminal::size().context("read the terminal size")?; + let term = std::env::var("TERM").unwrap_or_else(|_| "xterm".to_string()); + channel + .request_pty(true, &term, u32::from(cols), u32::from(rows), 0, 0, &[]) + .await + .context("request a pty")?; + channel + .exec(true, command) + .await + .context("send the exec request")?; + let (mut reader, writer) = channel.split(); + + let _raw = RawMode::enable()?; + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut buf = vec![0u8; 8192]; + let mut stdin_open = true; + let mut resizes = watch_resizes(); + let mut status = None; + loop { + tokio::select! { + read = stdin.read(&mut buf), if stdin_open => match read { + Ok(0) => { + stdin_open = false; + writer.eof().await.context("close stdin")?; + } + Ok(n) => writer.data(&buf[..n]).await.context("send keystrokes")?, + Err(e) => return Err(e).context("read the terminal"), + }, + msg = reader.wait() => match msg { + Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => { + stdout.write_all(&data).await.context("write to the terminal")?; + stdout.flush().await.context("write to the terminal")?; + } + Some(ChannelMsg::ExitStatus { exit_status }) => status = Some(exit_status), + Some(ChannelMsg::ExitSignal { .. }) => status = Some(255), + Some(ChannelMsg::Close) | None => break, + Some(_) => {} + }, + () = resized(&mut resizes) => { + if let Ok((cols, rows)) = crossterm::terminal::size() { + let _ = writer.window_change(u32::from(cols), u32::from(rows), 0, 0).await; + } + } + } + } + Ok(status.unwrap_or(255)) +} + +/// 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]) -> Result> { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + let prefixes: Vec = tags.iter().map(|t| format!("{t}=")).collect(); + if lines.len() != prefixes.len() + || lines + .iter() + .zip(&prefixes) + .any(|(l, p)| !l.starts_with(p.as_str())) + { + 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(lines + .iter() + .zip(&prefixes) + .map(|(l, p)| l[p.len()..].to_string()) + .collect()) +} + +/// Scripted transport for tests: `reply` queues one `run` result; +/// every call is recorded with its rendered command. +#[cfg(all(test, unix))] +pub(crate) mod fake { + use super::*; + use std::collections::VecDeque; + use std::sync::Mutex; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) enum Call { + Run { + dest: String, + command: String, + input: Option>, + }, + Attach { + dest: String, + command: String, + }, + } + + #[derive(Default)] + pub(crate) struct FakeSsh { + replies: Mutex>, + calls: Mutex>, + } + + pub(crate) fn output(status: i32, stdout: &str, stderr: &str) -> Output { + use std::os::unix::process::ExitStatusExt; + Output { + status: std::process::ExitStatus::from_raw(status << 8), + 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: i32, stdout: &str) -> &Self { + self.reply_with_stderr(status, stdout, "") + } + + pub(crate) fn reply_with_stderr(&self, status: i32, 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::Run { + 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:?}"))) + } + + fn attach(&self, dest: &Destination, command: &RemoteCommand) -> Result { + self.calls.lock().unwrap().push(Call::Attach { + dest: dest.to_string(), + command: command.render()?, + }); + Ok(0) + } + } +} + +#[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_host_user_at_host_and_alias() { + for s in [ + "host", + "user@host", + "exedev@vm.exe.xyz", + "exe.dev", + "a-b_c.d", + ] { + assert_eq!(Destination::parse(s).unwrap().to_string(), s); + } + } + + #[test] + fn destination_rejects_empty_option_shaped_and_shell_characters() { + for s in [ + "", + "-oProxyCommand=x", + "-", + "user@host;ls", + "a b", + "host$", + "[::1]", + "ssh://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(), Some("exedev")); + assert_eq!(d.host(), "vm.exe.xyz"); + let d = Destination::parse("alias").unwrap(); + assert_eq!(d.user(), None); + assert_eq!(d.host(), "alias"); + } + + #[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 identity_files_are_the_openssh_defaults() { + let defaults = identity_file_candidates(); + assert!( + defaults + .iter() + .all(|p| p.parent().is_some_and(|d| d.ends_with(".ssh"))) + ); + assert!(defaults.iter().any(|p| p.ends_with("id_ed25519"))); + } + + #[cfg(unix)] + #[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().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)); + + 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:#}" + ); + } + + #[cfg(unix)] + 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("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 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.attach(&dest(), &RemoteCommand::new("tmux")).unwrap(), + 0 + ); + assert_eq!( + fake.calls(), + [ + Call::Run { + dest: "user@host".into(), + command: "sh -c 'printf A=1' sh 'x y'".into(), + input: Some(b"body".to_vec()), + }, + Call::Attach { + dest: "user@host".into(), + command: "tmux".into(), + }, + ] + ); + } + } +} diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..864d255d 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -265,6 +265,8 @@ fn cache_id_input_loads_and_projects() { no_cache: false, force: false, url: None, + remote: None, + dry_run: false, }; let recorder = RecordingExec::default(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index bf7597ba..07f81b62 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -187,6 +187,8 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs no_cache: false, force: false, url: None, + remote: None, + dry_run: false, } } diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index a3aa6782..59bc605c 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.18.0" +version = "0.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.18.0" } +path-cli = { path = "../path-cli", version = "0.21.0" } anyhow = "1.0" [workspace] diff --git a/scripts/resume-remote.sh b/scripts/resume-remote.sh index 3ef86cc7..3e438136 100755 --- a/scripts/resume-remote.sh +++ b/scripts/resume-remote.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # Resume a Claude Code session on a remote host. Builds `path` -# from this checkout, projects one local Claude session to JSONL, ships -# the JSONL to an ssh host, launches claude under tmux there, and -# attaches. +# from this checkout, seeds and syncs the host, and hands off to +# `path resume --remote`, which plans, ships, launches claude under +# tmux, and attaches. # -# `path` does the local projection (`p import claude`, `p export -# claude`). This script does every other remote-resume step in shell, -# behind strict preconditions. Each step marked [shell] is a candidate -# to move into `path`. +# `path` does the resume (`p import claude`, `resume --remote`). This +# script does the host bootstrap in shell: VM creation, credential +# seeding, and the working-tree sync. Each step marked [shell] is a +# candidate to move into `path`. # # Usage: # scripts/resume-remote.sh [options] @@ -34,30 +34,16 @@ # project directory. Idempotent. # --no-sync Do not push the working tree to the remote. # --no-pause Do not wait for Enter between steps. -# --force Re-ship over an existing remote session file. The -# remote state decides what a run does: -# fresh no session file, no tmux session: -# ship, launch, attach. --force has no -# effect. -# existing session file present, no tmux -# session: launch on the remote file, -# attach. --force ships the local -# projection first and overwrites the -# remote turns. -# live tmux session running: attach. -# --force is an error; stop the -# session first (the error prints the -# command). -# --dry-run Print the create, setup, and sync commands instead -# of running them, and stop after the plan. Every -# remote call is read-only. With --create, the run -# stops after printing the create command unless the -# VM already exists. +# --dry-run Print the setup and sync commands instead of +# running them, and stop after the `path resume` +# plan. Every remote call is read-only. With +# --create, the run stops after printing the +# create command unless the VM already exists. # # Preconditions. Each one is checked before the first remote write. A # failed check exits 1 with a message. # Local: -# - cargo, git, ssh, jq, sha256sum are on PATH. rsync is on PATH +# - cargo, git, ssh, jq are on PATH. rsync is on PATH # unless --no-sync. scp is on PATH with --setup. # - stdin is a terminal unless --dry-run (tmux attach needs one). # - matches [A-Za-z0-9][A-Za-z0-9@._-]*. @@ -68,46 +54,38 @@ # the script quotes nothing and escapes nothing. # - --session is a UUID. At least one Claude session exists for # --project. -# - The projected JSONL records --project as its cwd. -# Remote (two read-only ssh calls): -# - Each reply is exactly the TP_* lines the probe prints. A login +# Remote (one read-only ssh call before any remote write; `path +# resume --remote` runs its own read-only probes after it): +# - The reply is exactly the TP_* lines the probe prints. A login # banner or a registration notice fails the run verbatim. -# - Call 1, before any remote write: $HOME is absolute, claude is on -# PATH or in a probed location, tmux is on PATH. -# - Call 2, after setup and sync: exists and is physical -# (pwd -P returns it); the tmux session state and whether the -# target session file exists are known. A live tmux session with -# --force is an error. +# - The probe reports $HOME and whether the target session file +# exists. The file's absence means the run ships, which gates the +# sync. # # Steps (always in this order): # 1. cargo build -p path-cli; the script runs target/debug/path and does # not touch any installed `path`. # 2. Resolve the session. `path p import claude --no-cache` writes -# the document to $TMPDIR/path-resume-remote/. -# [shell] Mint the remote session id from the key-sorted document -# (jq -S | sha256sum, formatted as a v4 UUID). +# the document to $TMPDIR/path-resume-remote/. The remote session +# ID comes from `p export claude --derive-session-id`: the same +# document yields the same ID on every run. # 3. Optional VM creation (--create). -# 4. [shell] Call 1: remote home, claude path, tmux presence. Derive -# from the remote home unless -C is given. -# 5. `path p export claude` projects the document to JSONL. -# [shell] Rewrite the cwd and sessionId keys to the remote values -# (sed). -# [shell] Compute the remote Claude project slug (/, _, and . -# become -). -# 6. Optional remote seeding (--setup). rsync the working tree -# (tracked, untracked, and uncommitted files, plus .git; minus -# target/ and anything .gitignore lists) into the remote project -# dir. --delete makes the remote mirror the local tree. The remote -# has no Rust toolchain. -# 7. [shell] Call 2: the physical project dir, whether the tmux -# session is live, and whether the target session file exists. -# These decide the state: fresh, existing, or live (see --force). -# 8. Print the plan, including the state. --dry-run stops here. -# 9. [shell] fresh: ship the JSONL over ssh stdin (0600 via umask -# 077), launch `claude -r ` in a detached tmux session, attach. -# existing: launch on the remote file, attach. live: attach. -# Detach with ctrl-b d. -# 10. Print the reattach command. +# 4. [shell] The probe: remote home and whether the target session +# file exists. Derive from the remote home unless +# -C is given. An absent file means the run ships. +# 5. Optional remote seeding (--setup). When the run ships, rsync +# the working tree (tracked, untracked, and uncommitted files, +# plus .git; minus target/ and anything .gitignore lists) into the +# remote project dir. --delete makes the remote mirror the local +# tree. The remote has no Rust toolchain. --dry-run prints these +# commands instead of running them. +# 6. Hand off: `path resume --remote -C `. +# It re-probes read-only, prints the plan, and does what the +# remote state asks: a live tmux session is attached to as is, a +# present session file is launched as is, an absent file is +# shipped first. To reset a remote session, delete its file on +# the remote and re-run. --dry-run stops after its plan. Detach +# with ctrl-b d; re-run the script to reattach. set -euo pipefail @@ -135,7 +113,6 @@ SETUP=0 SYNC=1 PAUSE=1 DRY_RUN=0 -FORCE=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -147,7 +124,6 @@ while [[ $# -gt 0 ]]; do --no-sync) SYNC=0; shift ;; --no-pause) PAUSE=0; shift ;; --dry-run) DRY_RUN=1; shift ;; - --force) FORCE=1; shift ;; -h|--help) usage 0 ;; *) echo "unknown option: $1" >&2; usage ;; esac @@ -191,15 +167,6 @@ check_plain_path() { esac } -# mint_uuid: stdin is the document bytes; stdout is a v4-shaped UUID -# built from the first 128 bits of their SHA-256. -mint_uuid() { - local h - h="$(sha256sum | cut -c1-32)" - printf '%s-%s-4%s-%x%s-%s\n' "${h:0:8}" "${h:8:4}" "${h:13:3}" \ - $(( (16#${h:16:1} & 3) | 8 )) "${h:17:3}" "${h:20:12}" -} - # remote_facts