diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a9cf60..6c1c3d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,35 +35,49 @@ jobs: - name: cargo clippy --workspace --all-targets run: cargo clippy --workspace --all-targets -- -D warnings + # SRS §2.2 (as amended): Windows is a supported platform — the full test + # suite runs on {ubuntu, macos, windows} × {stable, MSRV}. On the MSRV + # toolchain the check is build-only (the MSRV promise is "compiles on + # exactly rust-version"; the behavioral suite runs on stable), pinned to + # the workspace `rust-version` so the two cannot drift apart silently. test: - name: test (${{ matrix.os }}) + name: ${{ matrix.rust == 'stable' && 'test' || 'build (MSRV)' }} (${{ matrix.os }}, ${{ matrix.rust }}) strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] + rust: [stable, "1.75"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: cargo test --workspace + - name: Check MSRV pin matches Cargo.toml rust-version + if: matrix.rust != 'stable' + shell: bash + run: | + msrv=$(sed -n 's/^rust-version *= *"\(.*\)"/\1/p' Cargo.toml) + if [ "$msrv" != "${{ matrix.rust }}" ]; then + echo "CI matrix pins MSRV ${{ matrix.rust }} but Cargo.toml declares rust-version $msrv" >&2 + exit 1 + fi + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - name: cargo test --workspace (stable) + if: matrix.rust == 'stable' run: cargo test --workspace --locked + - name: cargo build --workspace (MSRV, build-only) + if: matrix.rust != 'stable' + run: cargo build --workspace --locked # NFR-2 enforcement: the binary must link no HTTP client crate. We list # the tree and grep for known offenders; failure here means someone added - # a network dependency. + # a network dependency. One OS is enough — the resolved graph is the + # same lockfile everywhere. - name: no-network-crate check + if: matrix.rust == 'stable' && matrix.os == 'ubuntu-latest' run: | cargo tree --workspace -e features --depth 1 > tree.txt ! grep -Ei '^(ureq|reqwest|hyper|isahc|attohttpc|minreq|surf) ' tree.txt - build-windows: - name: build (windows, build-only per SRS §2.2) - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: cargo build --workspace - run: cargo build --workspace --locked - deny: name: cargo-deny runs-on: ubuntu-latest @@ -80,4 +94,4 @@ jobs: - uses: EmbarkStudios/cargo-deny-action@v2 with: command: check - command-arguments: advisories licenses bans sources \ No newline at end of file + command-arguments: advisories licenses bans sources diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d134b..0467fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,36 @@ in CI rather than tracking `latest` until 1.0. ## [Unreleased] +### Added +- Windows is a supported platform (amends SRS §2.2's build-only status): the + full test suite runs on `windows-latest` in CI, exercising portable-pty's + ConPTY backend end-to-end with Python and PowerShell fixture-agent + variants. Platform behavior, permission semantics (Windows ACL note), and + honest limitations are documented in `docs/platforms.md`; genuinely missing + pieces are tracked in issues #11 (resize forwarding) and #12 (interactive + stdin CI coverage). +- CI matrix is now `{ubuntu, macos, windows} × {stable, MSRV}`; the MSRV leg + builds `--locked` on exactly the `rust-version` toolchain (1.75) declared + in `Cargo.toml`, with a guard that the pin cannot drift from the manifest. + +### Changed +- Recorded relative paths (`file_changes.path`, event summaries) are now + always `/`-separated on every platform (previously platform-native, which + would have stored `sub\file.txt` on Windows). Recordings made on one OS + now query and render identically on another (DR-2). +- The Claude Code adapter's project-dir slug also maps `:` to `-`, so a + Windows cwd like `C:\Users\me\proj` resolves to `C--Users-me-proj` before + falling back to the cwd scan. +- `hh` enables Windows virtual terminal processing via crossterm at startup; + when the console cannot support ANSI, colored output degrades to plain + text instead of escape garbage. +- `Cargo.lock` is kept MSRV(1.75)-compatible; `lru` is held on the 0.16 line + (0.17+ needs edition-2024 tooling) and several transitive deps resolved to + MSRV-compatible releases. `time` is held at 0.3.41 (the fix for + RUSTSEC-2026-0009 requires rustc 1.88); the advisory is ignored in + `deny.toml` with a written exposure justification and a revisit note tied + to the next MSRV bump. + ## [0.1.0-beta.1] — 2026-07-07 First public beta. Local-first CLI flight recorder for AI agents: records an diff --git a/Cargo.lock b/Cargo.lock index 2e97a05..49c612b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,16 +117,15 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake3" -version = "1.8.5" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "b17679a8d69b6d7fd9cd9801a536cec9fa5e5970b69f9d4747f70b39b031f5e7" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures", ] [[package]] @@ -162,9 +161,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -180,9 +179,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.1" +version = "4.5.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "52fa72306bb30daf11bc97773431628e5b4916e97aaa74b7d3f625d4d495da02" dependencies = [ "clap_builder", "clap_derive", @@ -190,9 +189,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.5.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "2071365c5c56eae7d77414029dde2f4f4ba151cf68d5a3261c9a40de428ace93" dependencies = [ "anstream", "anstyle", @@ -202,9 +201,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.5.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "dec5be1eea072311774b7b84ded287adbd9f293f9d23456817605c6042f4f5e0" dependencies = [ "heck", "proc-macro2", @@ -214,9 +213,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "0e78417baa3b3114dc0e95e7357389a249c4da97c3c2b540700079db6171bfd7" [[package]] name = "colorchoice" @@ -250,18 +249,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.4.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] name = "crossbeam-channel" @@ -284,9 +274,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.19" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4279b0f31df31b4add8aeae57d40f3b5e53aad2b531e89b982bd75ed1898851d" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -324,9 +314,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] [[package]] name = "directories" @@ -449,30 +442,6 @@ dependencies = [ "libc", ] -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -486,20 +455,21 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", + "wasip2", ] [[package]] name = "globset" -version = "0.4.18" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" dependencies = [ "aho-corasick", "bstr", @@ -518,7 +488,7 @@ dependencies = [ "hh-core", "hh-record", "insta", - "lru 0.18.0", + "lru 0.16.4", "owo-colors", "ratatui", "rusqlite", @@ -549,9 +519,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -608,9 +578,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.27" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" dependencies = [ "crossbeam-deque", "globset", @@ -624,12 +594,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown 0.16.1", ] [[package]] @@ -645,9 +615,9 @@ dependencies = [ [[package]] name = "inotify-sys" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea94e891b3606826e9c998be69ddca42247dad8ad50b1649a5cb7e1c9ae06fd" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -705,22 +675,21 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ - "cfg-if", - "futures-util", + "once_cell", "wasm-bindgen", ] @@ -808,18 +777,18 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.0" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.17.1", + "hashbrown 0.16.1", ] [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -877,9 +846,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.2" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "once_cell" @@ -901,9 +870,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "owo-colors" -version = "4.3.0" +version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" [[package]] name = "parking_lot" @@ -934,12 +903,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - [[package]] name = "pin-utils" version = "0.1.0" @@ -999,9 +962,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "ratatui" @@ -1045,9 +1008,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -1089,9 +1052,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1267,12 +1230,6 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.15.2" @@ -1341,7 +1298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1378,29 +1335,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", @@ -1455,9 +1413,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" @@ -1484,11 +1442,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "js-sys", "wasm-bindgen", ] @@ -1521,11 +1479,20 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -1536,9 +1503,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1546,9 +1513,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -1559,9 +1526,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -1588,7 +1555,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1696,20 +1663,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 3055053..57b8e00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ members = ["hh-core", "hh-record", "hh"] [workspace.package] version = "0.1.0-beta.1" edition = "2021" +# MSRV policy: the workspace (and its Cargo.lock) must build on exactly this +# toolchain; CI enforces it on {ubuntu, macos, windows}. Bump deliberately — +# keep locked dependency versions resolvable on this rustc (see the lru pin in +# hh/Cargo.toml for an example of holding a dep back for MSRV). rust-version = "1.75" license = "Apache-2.0" authors = ["Saadman Rafat"] diff --git a/README.md b/README.md index 66a973a..84ad5bb 100644 --- a/README.md +++ b/README.md @@ -198,12 +198,15 @@ binary). See [`ARCHITECTURE.md`](ARCHITECTURE.md) and the This is the **v0.1.0-beta.1** release. `hh run`, `hh mcp-proxy`, `hh replay`, `hh inspect`, `hh list`, and `hh delete` are all implemented and tested on -Linux and macOS. +Linux, macOS, and Windows (see [docs/platforms.md](docs/platforms.md) for +what differs per platform). Known limitations: -- **Windows PTY is best-effort.** CI builds on Windows but does not run the - test suite there; PTY recording on Windows is not validated. +- **Windows has documented gaps.** The full test suite runs on Windows in CI + (ConPTY recording included), but window resizes are not forwarded to the + child and interactive stdin round-trips are verified manually — see + [docs/platforms.md](docs/platforms.md) for the honest list. - **Single structured adapter.** Only Claude Code is auto-detected; other agents record as `generic`. More adapters are planned for v0.2. - **Replay is faithful playback, not deterministic re-execution** (SRS §1.4). diff --git a/deny.toml b/deny.toml index 8cbea4e..0c2dc0c 100644 --- a/deny.toml +++ b/deny.toml @@ -17,7 +17,17 @@ yanked = "deny" # advisory). Pulled in transitively by `ratatui` 0.26.3 (FR-3's TUI engine) # for its macro-generated widget code; we never depend on `paste` directly. # Tracked for revisit on the next ratatui upgrade. -ignore = ["RUSTSEC-2017-0008", "RUSTSEC-2024-0436"] +# +# RUSTSEC-2026-0009: `time` <0.3.47 can exhaust the stack when *parsing* +# attacker-controlled input of unbounded length. The fixed release (0.3.47) +# declares rust-version 1.88, which conflicts with the workspace MSRV of 1.75 +# (Cargo.toml `rust-version`; CI builds --locked on exactly that toolchain), +# so `time` is held at 0.3.41 — the newest MSRV-compatible release. Exposure +# is minimal: hh parses RFC3339 timestamps only from newline-bounded JSONL +# transcript lines written locally by Claude Code (adapter::ts_ms_from_iso); +# there is no network input (NFR-2) and no unbounded parse. Revisit when the +# MSRV policy is next bumped (>=1.88 lets `cargo update -p time` close this). +ignore = ["RUSTSEC-2017-0008", "RUSTSEC-2024-0436", "RUSTSEC-2026-0009"] [licenses] # Halfhand is Apache-2.0 (SRS §1). Allow permissive licenses compatible with diff --git a/docs/manual-qa.md b/docs/manual-qa.md index 2cbca39..4befe5f 100644 --- a/docs/manual-qa.md +++ b/docs/manual-qa.md @@ -23,7 +23,7 @@ discharged — by an automated test, by this manual checklist, or both. | AC-3 | `hh mcp-proxy` records correlated JSON-RPC traffic, spills ≥256 KiB payloads to the blob store, attaches to a parent session via `HH_SESSION_ID`, and records round-trip `latency_ms`. | `mcp_proxy_*` tests (FR-2) — `hh/tests/cli.rs`. | | AC-4 | Crash-safety: SIGKILL of `hh` mid-recording leaves a readable session that the next invocation reconciles to `interrupted`. | `sigkill_of_hh_mid_run_leaves_interrupted_session` (FR-1.7) — `hh/tests/cli.rs`. | | AC-5 | `hh inspect --json` / `hh list --json` emit output `jq` can parse and that conforms to `docs/json.md` (schema:1, documented fields). | `inspect_json_is_valid_against_jq`, `list_shows_aligned_table_and_json` (FR-4 / FR-5) — `hh/tests/cli.rs`. | -| AC-6 | A fresh machine can install `hh` and complete the 30-second quickstart. | Manual checklist below. CI already builds from a clean checkout on Ubuntu, macOS, and Windows (build-only) on every push — the automatable part of "fresh-machine". | +| AC-6 | A fresh machine can install `hh` and complete the 30-second quickstart. | Manual checklist below. CI already builds and tests from a clean checkout on Ubuntu, macOS, and Windows on every push — the automatable part of "fresh-machine". | If a row above has no automated test listed, it is in the manual checklists below. Anything not in CI is run by hand for each release and the result @@ -88,9 +88,9 @@ The only thing left to a human is the live-API-key smoke against the genuine ## AC-6 manual checklist — fresh-machine experience The automatable part (build from a clean checkout on each OS) already runs in -`ci.yml` on every push: `cargo build/test --workspace --locked` on -`ubuntu-latest` and `macos-latest`, plus a build-only job on `windows-latest` -(Windows PTY is best-effort per SRS §2.2). The release workflow +`ci.yml` on every push: `cargo test --workspace --locked` on `ubuntu-latest`, +`macos-latest`, and `windows-latest`, plus a build-only MSRV leg on all three +(see `docs/platforms.md`). The release workflow (`.github/workflows/release.yml`) additionally builds release binaries for x86_64/aarch64 on macOS and Linux (musl where feasible) from a clean checkout. @@ -116,4 +116,22 @@ checkout: secrets and points at `hh delete`. Record the result (pass/fail per line) in the release announcement. A failure -on any line blocks the release. \ No newline at end of file +on any line blocks the release. + +--- + +## Windows interactive-stdin checklist (ConPTY) + +CI on Windows covers ConPTY output capture, file changes, exit codes, and +console geometry, but not an interactive stdin round-trip (tracked in +[#12](https://github.com/halfhandorg/halfhand/issues/12); background in +`docs/platforms.md`). Run by hand on a Windows machine per release: + +- [ ] **Interactive echo.** In Windows Terminal, `hh run -- python -i` + (or any REPL): typed input reaches the child, the child's echo renders, + and Ctrl-C/`exit()` ends the session with an epilogue. +- [ ] **Keystroke capture opt-in.** `hh run --record-input -- python -i`, + type a line, exit; `hh inspect last --json` shows a `terminal_output` + event with `"direction": "input"` carrying the typed bytes. +- [ ] **Raw-mode restore.** After the session ends, the console echoes + normally (no stuck raw mode) and colors still render. \ No newline at end of file diff --git a/docs/platforms.md b/docs/platforms.md new file mode 100644 index 0000000..b95cdbc --- /dev/null +++ b/docs/platforms.md @@ -0,0 +1,104 @@ +# Platform support + +Halfhand is developed and released for Linux, macOS, and Windows. This page +records what "supported" means per platform, where behavior differs, and what +is known not to work. It amends SRS §2.2, which originally listed Windows as +build-only: as of this change Windows runs the full test suite in CI +(`{ubuntu, macos, windows} × {stable, MSRV}`; the MSRV leg is a build-only +check on exactly the `rust-version` toolchain declared in `Cargo.toml`). + +| Platform | CI | PTY backend | FS watcher backend | +| -------- | ------------- | ---------------------- | ------------------------ | +| Linux | full tests | Unix PTY (`openpty`) | inotify | +| macOS | full tests | Unix PTY (`openpty`) | FSEvents | +| Windows | full tests | ConPTY (portable-pty) | ReadDirectoryChangesW | + +## Where files live + +Paths come from the [`directories`](https://crates.io/crates/directories) +crate (SRS §2.3): + +| | Linux | macOS | Windows | +| --- | --- | --- | --- | +| data (`hh.db`, blobs) | `$XDG_DATA_HOME/halfhand` (default `~/.local/share/halfhand`) | `~/Library/Application Support/halfhand` | `%APPDATA%\halfhand\data` | +| config | `$XDG_CONFIG_HOME/halfhand/config.toml` | `~/Library/Application Support/halfhand/config.toml` | `%APPDATA%\halfhand\config\config.toml` | + +`HH_DATA_DIR` overrides the data directory on every platform. + +Relative paths stored in recordings (`file_changes.path`, event summaries) +are always `/`-separated, on every platform, so a recording made on Windows +inspects and replays identically elsewhere (DR-2: the schema is a public +interface). Absolute paths (`sessions.cwd`) are stored as the OS reports +them. + +## File permissions (NFR-4) + +On Unix, the data directory, blob shard directories, and blob files are +created with `0700`/`0600` and a test asserts it. Those mode bits do not +exist on Windows; the `chmod` calls are compiled only on Unix +(`#[cfg(unix)]`). + +**Windows note:** Halfhand relies on the default ACLs of the profile +directory instead. `%APPDATA%` inherits an ACL that grants access to the +owning user (plus `SYSTEM` and `Administrators`) and no other interactive +user, which matches the intent of `0700`. Halfhand does not set explicit +ACLs. If you point `HH_DATA_DIR` somewhere outside your user profile (a +shared folder, a FAT/exFAT volume), nothing restricts access to the +recordings — choose the location accordingly. + +## PTY recording on Windows (ConPTY) + +`hh run` uses portable-pty's ConPTY backend. CI exercises it end-to-end with +the fixture agent in two variants — Python (`fixture_agent.py`) and +PowerShell (`fixture_agent.ps1`) — covering terminal-output capture, +file-change capture, exit-code propagation, and console-geometry visibility +(`pty_probe.py`). + +Known differences and limitations, stated plainly: + +- **Recorded bytes are ConPTY's rendering, not the child's raw output.** + ConPTY interprets the child's console API calls and re-emits VT sequences. + Text content survives, but the exact escape-sequence stream can differ + from what the same program would emit on Unix, and may include cursor + positioning the child never wrote. Replay output is faithful to what the + terminal showed, not byte-identical to the child's writes. +- **Window resizes are not forwarded to the child.** Resize forwarding is + driven by `SIGWINCH`, which does not exist on Windows; the child keeps its + spawn-time geometry. Tracked in + [#11](https://github.com/halfhandorg/halfhand/issues/11). +- **Interactive stdin round-trips are verified manually, not in CI.** The + stdin proxy compiles and runs on Windows, but the Unix echo test has no + ConPTY counterpart yet (console input semantics make an un-flaky test + nontrivial). Tracked in + [#12](https://github.com/halfhandorg/halfhand/issues/12). +- **ANSI in hh's own output** (epilogue, `hh list`, errors) requires virtual + terminal processing. hh enables it via crossterm at startup; on consoles + where that fails (very old conhost), hh falls back to plain, uncolored + output rather than printing escape garbage. +- **Ctrl-C/signal semantics differ.** On Unix, `SIGTERM`/`SIGINT` to hh mark + the session `interrupted`. On Windows, signal-hook maps these to console + ctrl handlers; a hard kill (`taskkill /F`) behaves like the SIGKILL case — + the session is reconciled to `interrupted` on the next `hh` invocation + (FR-1.7), which is covered by tests on all platforms. + +## Claude Code adapter on Windows + +The adapter locates transcripts under `%USERPROFILE%\.claude\projects` +(`HOME` is honored first if set, which is what the tests use). The project +slug is computed from the session cwd with `/`, `\`, `.`, and `:` each +mapped to `-`, so `C:\Users\me\proj` becomes `C--Users-me-proj`. The slug is +inferred from observed transcripts, not a documented Claude Code contract — +on any mismatch the adapter falls back to scanning all project directories +and matching the first record's `cwd` field, so a wrong slug degrades to a +slower lookup, not a miss. Unit tests cover Windows-style cwds with typed +`PathBuf` fixtures; the end-to-end shim test is Unix-only (it relies on a +shebang shim on `PATH`), while the degraded-mode path (no projects +directory) is tested on every platform. + +## MSRV + +`rust-version = "1.75"` in the workspace `Cargo.toml` is the policy line. +CI builds the workspace with `--locked` on exactly that toolchain for all +three OSes, so both the code and the committed `Cargo.lock` must stay +1.75-compatible. Some dependencies are deliberately held back for this (see +the `lru` pin in `hh/Cargo.toml`). diff --git a/hh-core/src/adapter.rs b/hh-core/src/adapter.rs index 398de28..378791b 100644 --- a/hh-core/src/adapter.rs +++ b/hh-core/src/adapter.rs @@ -791,18 +791,28 @@ pub(crate) fn ts_ms_from_iso(iso: &str, started_at_unix_ms: i64) -> Option #[must_use] fn claude_projects_dir() -> Option { let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?; - Some(PathBuf::from(home).join(".claude").join("projects")) + Some(projects_dir_under(&PathBuf::from(home))) } -/// Encode `cwd` as a Claude slug: every `/` (and `\`, `.`) becomes `-`. This is -/// inferred from a sample transcript (the SRS is absent); it is a *hint only* — +/// The projects directory under a given home directory. Split from +/// [`claude_projects_dir`] so the path shape is unit-testable without mutating +/// process-global env vars (`HOME`/`USERPROFILE`) across parallel tests. +#[must_use] +pub(crate) fn projects_dir_under(home: &Path) -> PathBuf { + home.join(".claude").join("projects") +} + +/// Encode `cwd` as a Claude slug: every `/`, `\`, `.` — and `:`, so a Windows +/// drive-letter cwd like `C:\Users\me` yields `C--Users-me`, matching how +/// Claude Code names project dirs for Windows paths — becomes `-`. This is +/// inferred from sample transcripts (the SRS is absent); it is a *hint only* — /// the tailer falls back to a cwd-based scan when the slug dir misses. #[must_use] pub(crate) fn slugify(cwd: &Path) -> String { cwd.to_string_lossy() .chars() .map(|c| match c { - '/' | '\\' | '.' => '-', + '/' | '\\' | '.' | ':' => '-', other => other, }) .collect() @@ -1004,7 +1014,40 @@ mod tests { slugify(Path::new("/home/saadman/switch/.claude/worktrees/x")), "-home-saadman-switch--claude-worktrees-x" ); - assert_eq!(slugify(Path::new("C:\\Users\\me")), "C:-Users-me"); + } + + #[test] + fn slugify_windows_style_cwds() { + // Typed PathBuf fixtures (not string munging): Windows-style cwds are + // constructed as PathBuf values and slugified. `slugify` operates on + // the lossy string form, so `\` components behave identically on every + // host platform — these assertions hold on Unix CI too. + let drive_root = PathBuf::from(r"C:\Users\me"); + assert_eq!(slugify(&drive_root), "C--Users-me"); + let nested = PathBuf::from(r"C:\Users\me\my.project\sub"); + assert_eq!(slugify(&nested), "C--Users-me-my-project-sub"); + // A UNC-style path: every separator collapses to `-`. + let unc = PathBuf::from(r"\\server\share\proj"); + assert_eq!(slugify(&unc), "--server-share-proj"); + } + + #[test] + fn projects_dir_shape_per_platform_home() { + // Typed PathBuf fixtures for both home layouts. Built with join() so + // the expected value uses the platform-native separator — this is a + // path-shape test, not a string test. + let unix_home = PathBuf::from("/home/me"); + assert_eq!( + projects_dir_under(&unix_home), + PathBuf::from("/home/me").join(".claude").join("projects") + ); + let win_home = PathBuf::from(r"C:\Users\me"); + assert_eq!( + projects_dir_under(&win_home), + PathBuf::from(r"C:\Users\me") + .join(".claude") + .join("projects") + ); } #[test] diff --git a/hh-core/src/config.rs b/hh-core/src/config.rs index 92a57d8..67f0986 100644 --- a/hh-core/src/config.rs +++ b/hh-core/src/config.rs @@ -429,11 +429,16 @@ feature = \"x\" #[test] fn paths_precedence_default_file_env() { - // default: no file override, no env. + // default: no file override, no env. The platform default always has a + // `halfhand` component, but not always as the last one (`directories` + // on Windows yields `...\AppData\Roaming\halfhand\data`). std::env::remove_var("HH_DATA_DIR"); let cfg = Config::default(); let default_paths = Paths::resolve(&cfg).unwrap(); - assert!(default_paths.data_dir.ends_with("halfhand")); + assert!(default_paths + .data_dir + .components() + .any(|c| c.as_os_str() == "halfhand")); // file: storage.data_dir set in config. let cfg_file = Config { diff --git a/hh-core/src/lib.rs b/hh-core/src/lib.rs index 36205fd..6403dad 100644 --- a/hh-core/src/lib.rs +++ b/hh-core/src/lib.rs @@ -8,6 +8,10 @@ //! here) and `clippy::pedantic` is enabled via the workspace lint table. #![deny(missing_docs)] +// Justification: fires only when clippy targets Windows, where the SQLite and +// TOML error payloads push `error::Error` past the lint's 128-byte budget; +// errors are cold paths here and boxing every variant is not worth the churn. +#![allow(clippy::result_large_err)] pub mod adapter; pub mod blob; diff --git a/hh-core/src/store.rs b/hh-core/src/store.rs index d2bc0d9..2f56b52 100644 --- a/hh-core/src/store.rs +++ b/hh-core/src/store.rs @@ -1039,7 +1039,13 @@ fn secure_create_dir(path: &Path) -> Result<()> { } #[cfg(not(unix))] { - let _ = std::fs::create_dir_all(path); + // No mode bits off Unix: rely on the profile-private default ACLs of + // %LOCALAPPDATA%/%APPDATA% (see docs/platforms.md) and surface real + // create failures instead of swallowing them. + std::fs::create_dir_all(path).map_err(|e| StorageError::Open { + path: path.to_path_buf(), + source: e, + })?; } Ok(()) } diff --git a/hh-record/src/lib.rs b/hh-record/src/lib.rs index e245b3c..10675db 100644 --- a/hh-record/src/lib.rs +++ b/hh-record/src/lib.rs @@ -11,6 +11,10 @@ //! `docs/adr/0001-threads-vs-tokio.md`. #![deny(missing_docs)] +// Justification: fires only when clippy targets Windows, where the wrapped +// `hh_core::Error` payloads exceed the lint's 128-byte budget (see the same +// allow in hh-core); errors are cold paths in the recorder. +#![allow(clippy::result_large_err)] mod agent; mod error; diff --git a/hh-record/src/runner.rs b/hh-record/src/runner.rs index 59fd832..1e945f1 100644 --- a/hh-record/src/runner.rs +++ b/hh-record/src/runner.rs @@ -241,6 +241,15 @@ pub fn run(store: &Store, opts: &RunOptions) -> crate::Result { .map_err(|e| crate::RecordError::Pty(e.to_string()))?; let master = pty_pair.master; + // --- ANSI passthrough (Windows) --------------------------------------- + // The reader thread copies the child's raw ANSI bytes to our stdout. On + // Windows the console renders them only with virtual terminal processing + // enabled; crossterm's probe enables it as a side effect (no-op on modern + // Windows Terminal, best-effort on legacy conhost). Unix consoles need + // nothing. + #[cfg(windows)] + let _ = crossterm::ansi_support::supports_ansi(); + // --- Raw mode on stdin TTY (transparent proxy) ------------------------ let raw_guard = if std::io::stdin().is_terminal() { match crossterm::terminal::enable_raw_mode() { diff --git a/hh-record/src/watcher.rs b/hh-record/src/watcher.rs index 354816d..8ff7c6a 100644 --- a/hh-record/src/watcher.rs +++ b/hh-record/src/watcher.rs @@ -506,7 +506,7 @@ fn process( return Ok(()); }; let ts_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX); - let rel_str = rel.to_string_lossy().to_string(); + let rel_str = rel_to_slash(rel); let (before_hash, after_hash, blob_hash, blob_size, is_binary) = if change_kind == ChangeKind::Deleted { @@ -580,6 +580,22 @@ fn process( Ok(()) } +/// Render a relative path with `/` separators regardless of platform, so +/// `file_changes.path` (DR-2: a public, documented schema) stores one canonical +/// form. On Windows `Path::to_string_lossy` would yield `sub\nested.txt`, +/// breaking cross-platform queries like `path LIKE 'target/%'` and making the +/// same recording render differently per OS. +fn rel_to_slash(rel: &Path) -> String { + let mut out = String::new(); + for component in rel.components() { + if !out.is_empty() { + out.push('/'); + } + out.push_str(&component.as_os_str().to_string_lossy()); + } + out +} + /// Binary heuristic (FR-1.4): a NUL byte in the first 8 KiB. fn is_binary(content: &[u8]) -> bool { content.iter().take(8192).any(|&b| b == 0) @@ -599,6 +615,18 @@ fn truncate_summary(s: &str) -> String { mod tests { use super::*; + #[test] + fn rel_to_slash_joins_components_with_forward_slashes() { + // Typed PathBuf fixtures: built with join() so the intermediate + // separator is the platform-native one, yet the stored form must + // always be `/`-separated. + let nested = PathBuf::from("sub").join("dir").join("nested.txt"); + assert_eq!(rel_to_slash(&nested), "sub/dir/nested.txt"); + let flat = PathBuf::from("fixture_output.txt"); + assert_eq!(rel_to_slash(&flat), "fixture_output.txt"); + assert_eq!(rel_to_slash(Path::new("")), ""); + } + #[test] fn nul_byte_is_binary() { assert!(is_binary(b"hello\x00world")); diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 7b179d4..74ead21 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -46,8 +46,10 @@ similar = "2.7" # event bodies, so scrolling the timeline does not re-hit SQLite/blob storage # for rows already visited. Pinned >=0.16.3 (RUSTSEC-2026-0002: 0.12's # `IterMut` unsound; fixed upstream, we never use `iter_mut` but take the fix -# anyway since it's a free version bump). -lru = "0.18" +# anyway since it's a free version bump). Held on the 0.16 line: 0.17+ pulls +# hashbrown 0.17 (edition 2024), which cannot build on the workspace MSRV +# (rust-version 1.75). +lru = "0.16.3" [dev-dependencies] # Justification: integration tests create throwaway data dirs (CLAUDE.md: diff --git a/hh/src/main.rs b/hh/src/main.rs index 10154ae..d51b507 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -4,6 +4,11 @@ //! recorder (`hh_record`). `hh run` is fully implemented (FR-1); the other //! subcommands return a structured "not implemented" error for now. +// Justification: fires only when clippy targets Windows, where the propagated +// `hh_core::Error` payloads exceed the lint's 128-byte budget (see the same +// allow in hh-core); errors are cold paths in the CLI. +#![allow(clippy::result_large_err)] + include!(concat!(env!("OUT_DIR"), "/hh_version.rs")); mod cli; @@ -268,7 +273,7 @@ fn delete_command(args: &cli::DeleteArgs) -> anyhow::Result { /// non-TTY stdin (piped/redirected) is refused with an actionable error /// pointing at `--yes`, so deletion can never happen by accident in a script. fn confirm_delete(session: &hh_core::SessionRow) -> anyhow::Result<()> { - use std::io::{IsTerminal, Write}; + use std::io::Write; if !std::io::stdin().is_terminal() { anyhow::bail!( "refusing to delete without confirmation\n \ diff --git a/hh/src/render.rs b/hh/src/render.rs index 20d745c..53550b9 100644 --- a/hh/src/render.rs +++ b/hh/src/render.rs @@ -11,16 +11,32 @@ use std::io::IsTerminal; use hh_core::SessionStatus; use owo_colors::OwoColorize; -/// Whether to emit ANSI color on stdout: disabled by `NO_COLOR` or non-TTY -/// output (CLAUDE.md: plain, pipe-safe). +/// Whether the terminal can render ANSI escapes. Always true on Unix; on +/// Windows this asks crossterm, which enables virtual terminal processing on +/// the console as a side effect (and caches the result). Modern terminals +/// (Windows Terminal) have it on already; legacy conhost needs the enable +/// call, and if even that fails we fall back to plain output. +fn ansi_supported() -> bool { + #[cfg(windows)] + { + crossterm::ansi_support::supports_ansi() + } + #[cfg(not(windows))] + { + true + } +} + +/// Whether to emit ANSI color on stdout: disabled by `NO_COLOR`, non-TTY +/// output (CLAUDE.md: plain, pipe-safe), or a console without ANSI support. pub(crate) fn use_color() -> bool { - std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() + std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() && ansi_supported() } /// Whether to emit ANSI color on stderr (error/hint rendering): disabled by -/// `NO_COLOR` or a non-TTY stderr. +/// `NO_COLOR`, a non-TTY stderr, or a console without ANSI support. pub(crate) fn use_color_stderr() -> bool { - std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() + std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() && ansi_supported() } /// The status glyph (CLAUDE.md: `✓ ✗ ●`). diff --git a/hh/tests/cli.rs b/hh/tests/cli.rs index 99edd73..b6792db 100644 --- a/hh/tests/cli.rs +++ b/hh/tests/cli.rs @@ -20,10 +20,47 @@ fn hh() -> Command { /// Absolute path to a fixture under `tests/fixtures/`. fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures") + .join("tests") + .join("fixtures") .join(name) } +/// The Python interpreter on PATH for this platform. GitHub's Windows runners +/// (and the python.org installer) ship `python.exe` without a `python3` alias; +/// Unix systems conventionally expose `python3`. +fn python() -> &'static str { + if cfg!(windows) { + "python" + } else { + "python3" + } +} + +/// A trivial argv that exits 0 (`true` has no Windows counterpart). +fn true_argv() -> Vec { + if cfg!(windows) { + ["cmd", "/C", "exit", "0"].map(String::from).to_vec() + } else { + vec!["true".to_string()] + } +} + +/// The fixture-agent argv for this platform: POSIX `sh` where available, the +/// behavior-identical Python port on Windows (exercising ConPTY, FR-1.1). +fn fixture_agent_argv() -> Vec { + if cfg!(windows) { + vec![ + python().to_string(), + fixture("fixture_agent.py").to_string_lossy().into_owned(), + ] + } else { + vec![ + "sh".to_string(), + fixture("fixture_agent.sh").to_string_lossy().into_owned(), + ] + } +} + #[test] fn version_prints_pkg_version() { let out = hh().arg("--version").output().unwrap(); @@ -135,9 +172,9 @@ impl Temp { } /// Run `hh run -- ` in `work` with `HH_DATA_DIR=data`, returning the -/// captured output. stdin is /dev/null so the stdin proxy exits immediately +/// captured output. stdin is null so the stdin proxy exits immediately /// and the test never blocks on a TTY read. -fn run_fixture(temp: &Temp, args: &[&str]) -> std::process::Output { +fn run_fixture>(temp: &Temp, args: &[S]) -> std::process::Output { hh().args(["run", "--"]) .args(args) .env("HH_DATA_DIR", temp.data.path()) @@ -149,9 +186,12 @@ fn run_fixture(temp: &Temp, args: &[&str]) -> std::process::Output { #[test] fn run_records_generic_session_with_terminal_and_file_changes() { + if cfg!(windows) && !python_available() { + eprintln!("skipping fixture-agent test: {} not on PATH", python()); + return; + } let temp = Temp::new(); - let fx = fixture("fixture_agent.sh").to_string_lossy().to_string(); - let out = run_fixture(&temp, &["sh", &fx]); + let out = run_fixture(&temp, &fixture_agent_argv()); // The fixture exits 3; hh propagates the child exit code (FR-1.6). assert_eq!( @@ -239,6 +279,60 @@ fn run_records_generic_session_with_terminal_and_file_changes() { assert!(blob_refcount >= 1, "blob should be referenced"); } +/// The PowerShell variant of the fixture agent (Windows only): the same +/// record-terminal-output + write-file + exit-3 flow through ConPTY with a +/// native Windows shell as the child, complementing the Python variant used +/// by [`run_records_generic_session_with_terminal_and_file_changes`]. +#[cfg(windows)] +#[test] +fn run_records_powershell_fixture_agent() { + let temp = Temp::new(); + let fx = fixture("fixture_agent.ps1").to_string_lossy().into_owned(); + let out = run_fixture( + &temp, + &[ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + &fx, + ], + ); + assert_eq!( + out.status.code(), + Some(3), + "hh run should propagate the PowerShell fixture's exit code; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("✓ Recorded session"), + "missing epilogue: {stdout}" + ); + + let conn = open_db(temp.data.path()); + let (status, exit_code): (String, i64) = conn + .query_row( + "SELECT status, exit_code FROM sessions ORDER BY started_at DESC LIMIT 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(status, "error"); + assert_eq!(exit_code, 3); + + // The file write was captured with a stored (slash-normalized) path. + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM file_changes WHERE path = 'fixture_output.txt'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(n >= 1, "expected a file_change row for fixture_output.txt"); +} + #[test] fn run_marks_stale_recording_sessions_interrupted_on_next_open() { // FR-1.7: a session left in 'recording' (e.g. hh crashed) must be marked @@ -261,8 +355,8 @@ fn run_marks_stale_recording_sessions_interrupted_on_next_open() { } // Run a real (very short) session to trigger mark_stale_interrupted on open. - let out = run_fixture(&temp, &["true"]); - assert!(out.status.success(), "true should exit 0"); + let out = run_fixture(&temp, &true_argv()); + assert!(out.status.success(), "trivial command should exit 0"); let conn = open_db(temp.data.path()); let stale_status: String = conn @@ -287,7 +381,8 @@ fn open_db_after_init(data_dir: &Path) -> Connection { // Apply migrations by running a throwaway recording, then delete its row // so it does not pollute the stale test. The schema persists. let temp_work = tempfile::tempdir().unwrap(); - hh().args(["run", "--", "true"]) + hh().args(["run", "--"]) + .args(true_argv()) .env("HH_DATA_DIR", data_dir) .current_dir(temp_work.path()) .stdin(Stdio::null()) @@ -300,10 +395,11 @@ fn open_db_after_init(data_dir: &Path) -> Connection { conn } -/// True if `python3` runs on PATH (the fake_agent fixture needs it). Tests that -/// need it skip gracefully when absent rather than failing the suite. -fn python3_available() -> bool { - std::process::Command::new("python3") +/// True if the platform's Python (see [`python`]) runs on PATH (the fake_agent +/// and mcp fixtures need it). Tests that need it skip gracefully when absent +/// rather than failing the suite. +fn python_available() -> bool { + std::process::Command::new(python()) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -407,8 +503,8 @@ fn assert_lifecycle_changes(conn: &Connection, blobs: &hh_core::BlobStore) { /// ignored paths producing no events, and `exit_code=3`/`status=error`. #[test] fn run_records_fake_agent_with_full_capture() { - if !python3_available() { - eprintln!("skipping fake_agent test: python3 not on PATH"); + if !python_available() { + eprintln!("skipping fake_agent test: python not on PATH"); return; } let temp = Temp::new(); @@ -417,7 +513,7 @@ fn run_records_fake_agent_with_full_capture() { std::fs::write(temp.work.path().join("modified.txt"), "orig\n").unwrap(); let fx = fixture("fake_agent.py").to_string_lossy().to_string(); - let out = run_fixture(&temp, &["python3", &fx]); + let out = run_fixture(&temp, &[python(), &fx]); assert_eq!( out.status.code(), Some(3), @@ -479,9 +575,48 @@ fn run_records_fake_agent_with_full_capture() { ); } +/// FR-1.1 transparency (Windows/ConPTY): the wrapped program runs inside a +/// real pseudo console, so `os.get_terminal_size()` reports a nonzero width +/// even when hh's own stdout is piped, and an ANSI-colored line passes +/// through. Stdin round-trips through ConPTY are not asserted here — see +/// docs/platforms.md for the manually-verified interactive story. +#[cfg(windows)] +#[test] +fn run_provides_a_real_conpty_to_the_child() { + if !python_available() { + eprintln!("skipping ConPTY probe test: python not on PATH"); + return; + } + let temp = Temp::new(); + let fx = fixture("pty_probe.py").to_string_lossy().into_owned(); + let out = run_fixture(&temp, &[python(), &fx]); + assert!( + out.status.success(), + "pty probe should exit 0; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let cols_line = stdout + .lines() + .find(|l| l.contains("cols=")) + .unwrap_or_default() + .to_string(); + assert!( + !cols_line.contains("cols=0") && cols_line.contains("cols="), + "child should see a real console geometry through ConPTY: {stdout}" + ); + assert!( + stdout.contains("green-line"), + "colored output should pass through: {stdout}" + ); +} + /// FR-1.1 transparency: the wrapped program runs in a real PTY, so `tput cols` /// succeeds (80 columns, the PTY default), ANSI bytes pass through verbatim, and -/// stdin is forwarded to the child. +/// stdin is forwarded to the child. Unix-only: the fixture is POSIX sh and the +/// raw stdin round-trip is exercised through the PTY line discipline; the +/// Windows counterpart is [`run_provides_a_real_conpty_to_the_child`]. +#[cfg(unix)] #[test] fn run_provides_a_real_pty_to_the_child() { use std::io::Write; @@ -594,7 +729,7 @@ fn sigkill_of_hh_mid_run_leaves_interrupted_session() { #[test] fn list_shows_aligned_table_and_json() { let temp = Temp::new(); - let out = run_fixture(&temp, &["true"]); + let out = run_fixture(&temp, &true_argv()); assert!(out.status.success()); // Table output (pipe → non-TTY → plain, no ANSI). @@ -683,10 +818,10 @@ fn run_mcp_proxy(temp: &Temp, stdin_bytes: &[u8], server_args: &[&str]) -> std:: .expect("hh mcp-proxy should exit after stdin EOF") } -/// The echo server argv for a given fixture path (`python3 `). +/// The echo server argv for a given fixture path (` `). fn echo_server_argv() -> Vec { let fx = fixture("mcp_echo_server.py").to_string_lossy().into_owned(); - vec!["python3".into(), fx] + vec![python().into(), fx] } /// Count events of a given kind for the most recently started session. @@ -706,8 +841,8 @@ fn count_events(conn: &Connection, kind: &str) -> i64 { /// `correlates` + `latency_ms`, and records notifications. Finalized `ok`. #[test] fn mcp_proxy_standalone_creates_mcp_only_session() { - if !python3_available() { - eprintln!("skipping mcp-proxy test: python3 not on PATH"); + if !python_available() { + eprintln!("skipping mcp-proxy test: python not on PATH"); return; } let temp = Temp::new(); @@ -799,8 +934,8 @@ fn mcp_proxy_standalone_creates_mcp_only_session() { /// `blob_hash` is set (mirrors the adapter + PTY capture contract). #[test] fn mcp_proxy_over_256kib_spills_to_blob() { - if !python3_available() { - eprintln!("skipping mcp-proxy spill test: python3 not on PATH"); + if !python_available() { + eprintln!("skipping mcp-proxy spill test: python not on PATH"); return; } let temp = Temp::new(); @@ -855,8 +990,8 @@ fn mcp_proxy_over_256kib_spills_to_blob() { /// stays NULL; status is not `ok`/`error`). The parent `hh run` owns lifecycle. #[test] fn mcp_proxy_attaches_to_existing_session() { - if !python3_available() { - eprintln!("skipping mcp-proxy attach test: python3 not on PATH"); + if !python_available() { + eprintln!("skipping mcp-proxy attach test: python not on PATH"); return; } let temp = Temp::new(); @@ -954,8 +1089,8 @@ fn mcp_proxy_attaches_to_existing_session() { /// create an orphan session. Nonzero exit + a message naming the missing id. #[test] fn mcp_proxy_attached_missing_session_errors() { - if !python3_available() { - eprintln!("skipping mcp-proxy missing-session test: python3 not on PATH"); + if !python_available() { + eprintln!("skipping mcp-proxy missing-session test: python not on PATH"); return; } let temp = Temp::new(); @@ -996,8 +1131,8 @@ fn mcp_proxy_attached_missing_session_errors() { #[test] fn mcp_proxy_latency_within_bound() { use std::fmt::Write as _; - if !python3_available() { - eprintln!("skipping mcp-proxy latency test: python3 not on PATH"); + if !python_available() { + eprintln!("skipping mcp-proxy latency test: python not on PATH"); return; } let temp = Temp::new(); @@ -1190,8 +1325,8 @@ fn record_claude_shim_session(temp: &Temp) -> std::process::Output { #[cfg(unix)] #[test] fn claude_adapter_e2e() { - if !python3_available() { - eprintln!("skipping claude adapter e2e: python3 not on PATH"); + if !python_available() { + eprintln!("skipping claude adapter e2e: python not on PATH"); return; } let temp = Temp::new(); @@ -1272,8 +1407,8 @@ fn claude_adapter_e2e() { #[cfg(unix)] #[test] fn inspect_and_list_on_claude_code_session() { - if !python3_available() { - eprintln!("skipping AC-1 read-side test: python3 not on PATH"); + if !python_available() { + eprintln!("skipping AC-1 read-side test: python not on PATH"); return; } let temp = Temp::new(); @@ -1341,8 +1476,11 @@ fn claude_adapter_degrades_on_missing_projects_dir() { let home = tempfile::tempdir().expect("temp HOME"); // empty — no .claude/projects let out = hh() - .args(["run", "--adapter", "claude-code", "--", "true"]) + .args(["run", "--adapter", "claude-code", "--"]) + .args(true_argv()) .env("HH_DATA_DIR", temp.data.path()) + // The adapter resolves the projects dir from HOME first (USERPROFILE + // is only the fallback), so overriding HOME isolates both platforms. .env("HOME", home.path()) .current_dir(temp.work.path()) .stdin(Stdio::null()) @@ -1350,7 +1488,7 @@ fn claude_adapter_degrades_on_missing_projects_dir() { .expect("hh run should execute"); assert!( out.status.success(), - "true should exit 0; stderr: {}", + "trivial command should exit 0; stderr: {}", String::from_utf8_lossy(&out.stderr) ); let stderr = String::from_utf8_lossy(&out.stderr); @@ -1397,10 +1535,7 @@ fn jq_available() -> bool { #[test] fn inspect_summary_lists_steps_and_step_detail_shows_body() { let temp = Temp::new(); - let out = run_fixture( - &temp, - &["sh", &fixture("fixture_agent.sh").to_string_lossy()], - ); + let out = run_fixture(&temp, &fixture_agent_argv()); assert_eq!(out.status.code(), Some(3)); // Summary view: header + a STEP/KIND/SUMMARY/TIME table. @@ -1457,10 +1592,7 @@ fn inspect_json_is_valid_against_jq() { return; } let temp = Temp::new(); - let out = run_fixture( - &temp, - &["sh", &fixture("fixture_agent.sh").to_string_lossy()], - ); + let out = run_fixture(&temp, &fixture_agent_argv()); assert_eq!(out.status.code(), Some(3)); // NDJSON stream: jq -s slurps every line into an array; every object has @@ -1525,10 +1657,7 @@ fn inspect_json_is_valid_against_jq() { #[test] fn inspect_diff_prints_unified_diff() { let temp = Temp::new(); - let out = run_fixture( - &temp, - &["sh", &fixture("fixture_agent.sh").to_string_lossy()], - ); + let out = run_fixture(&temp, &fixture_agent_argv()); assert_eq!(out.status.code(), Some(3)); let diff = hh() @@ -1552,7 +1681,7 @@ fn inspect_diff_prints_unified_diff() { #[test] fn inspect_json_and_diff_are_mutually_exclusive() { let temp = Temp::new(); - run_fixture(&temp, &["true"]); + run_fixture(&temp, &true_argv()); let out = hh() .args(["inspect", "last", "--json", "--diff"]) .env("HH_DATA_DIR", temp.data.path()) @@ -1660,7 +1789,7 @@ fn blob_refcount(conn: &Connection, hash: &str) -> i64 { #[test] fn delete_with_yes_removes_session() { let temp = Temp::new(); - run_fixture(&temp, &["true"]); + run_fixture(&temp, &true_argv()); // One session exists. let before = hh() .args(["list", "--json"]) @@ -1705,7 +1834,7 @@ fn delete_with_yes_removes_session() { #[test] fn delete_refuses_without_yes_on_piped_stdin() { let temp = Temp::new(); - run_fixture(&temp, &["true"]); + run_fixture(&temp, &true_argv()); let out = hh() .args(["delete", "last"]) diff --git a/hh/tests/fixtures/fixture_agent.ps1 b/hh/tests/fixtures/fixture_agent.ps1 new file mode 100644 index 0000000..4e5148e --- /dev/null +++ b/hh/tests/fixtures/fixture_agent.ps1 @@ -0,0 +1,12 @@ +# Fixture fake agent for `hh run` integration tests (SRS acceptance #2). +# +# PowerShell variant, exercising portable-pty's ConPTY backend with a native +# Windows shell (the .py variant covers the same flow through a Python child). +# Behavior must stay in lockstep with fixture_agent.sh and fixture_agent.py. +Write-Output "agent starting" +Write-Output "writing a file" +Set-Content -Path "fixture_output.txt" -Value "hello from fixture" -Encoding utf8 +Write-Output "writing again" +Set-Content -Path "fixture_output.txt" -Value "hello v2 from fixture" -Encoding utf8 +Write-Output "now exiting with error" +exit 3 diff --git a/hh/tests/fixtures/fixture_agent.py b/hh/tests/fixtures/fixture_agent.py new file mode 100644 index 0000000..a44b17d --- /dev/null +++ b/hh/tests/fixtures/fixture_agent.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Fixture fake agent for `hh run` integration tests (SRS acceptance #2). + +Python port of fixture_agent.sh for platforms without a POSIX `sh` (Windows, +where hh spawns it through portable-pty's ConPTY backend). Prints to stdout, +writes a file to its cwd, and exits nonzero -- enough to exercise +terminal-output capture, file-change capture, and the error status path. +Behavior must stay in lockstep with fixture_agent.sh and fixture_agent.ps1. +""" +import sys + +print("agent starting") +print("writing a file") +with open("fixture_output.txt", "w") as f: + f.write("hello from fixture\n") +print("writing again") +with open("fixture_output.txt", "w") as f: + f.write("hello v2 from fixture\n") +print("now exiting with error") +sys.stdout.flush() +sys.exit(3) diff --git a/hh/tests/fixtures/pty_probe.py b/hh/tests/fixtures/pty_probe.py new file mode 100644 index 0000000..86ec2b4 --- /dev/null +++ b/hh/tests/fixtures/pty_probe.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""PTY transparency probe (FR-1.1) for platforms without `tput` (Windows). + +Proves the wrapped program runs inside a real (Con)PTY even when hh's own +stdout is piped: os.get_terminal_size() only reports a nonzero geometry when +stdout is a console, and the ANSI-colored line must pass through hh verbatim. +Unlike interactive.sh this does not read stdin -- raw stdin round-trips +through ConPTY are covered manually (see docs/platforms.md). +""" +import os +import sys + +try: + cols = os.get_terminal_size().columns +except OSError: + cols = 0 +print(f"cols={cols}") +print("\033[32mgreen-line\033[0m") +sys.stdout.flush()