diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0905c1f..debdc82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,20 @@ jobs: - name: Check formatting run: cargo fmt --all --check + # gobby-core: full clippy + tests + - name: Clippy (gobby-core) + run: cargo clippy -p gobby-core --all-targets -- -D warnings + + - name: Test (gobby-core) + run: cargo test -p gobby-core + + # ghook: full clippy + tests + - name: Clippy (ghook) + run: cargo clippy -p gobby-hooks --all-targets -- -D warnings + + - name: Test (ghook) + run: cargo test -p gobby-hooks + # gsqz: full clippy + tests - name: Clippy (gsqz) run: cargo clippy -p gobby-squeeze -- -D warnings diff --git a/.github/workflows/release-gcode.yml b/.github/workflows/release-gcode.yml index aa99f93..064af25 100644 --- a/.github/workflows/release-gcode.yml +++ b/.github/workflows/release-gcode.yml @@ -110,7 +110,7 @@ jobs: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} release: - needs: build + needs: [build, publish] runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-gcore.yml b/.github/workflows/release-gcore.yml new file mode 100644 index 0000000..bb40aee --- /dev/null +++ b/.github/workflows/release-gcore.yml @@ -0,0 +1,42 @@ +name: Release gobby-core + +on: + push: + tags: + - "gobby-core-v*" + +permissions: + contents: read + +jobs: + test: + name: Test before release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Clippy + run: cargo clippy -p gobby-core --all-targets -- -D warnings + + - name: Run tests + run: cargo test -p gobby-core + + publish: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Publish gobby-core to crates.io + run: cargo publish -p gobby-core + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release-ghook.yml b/.github/workflows/release-ghook.yml new file mode 100644 index 0000000..a8004fa --- /dev/null +++ b/.github/workflows/release-ghook.yml @@ -0,0 +1,125 @@ +name: Release ghook + +on: + push: + tags: + - "gobby-hooks-v*" + +permissions: + contents: write + +jobs: + test: + name: Test before release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Clippy + run: cargo clippy -p gobby-hooks --all-targets -- -D warnings + + - name: Run tests + run: cargo test -p gobby-hooks + + build: + needs: test + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + cross: false + - target: x86_64-apple-darwin + os: macos-latest + cross: false + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + cross: false + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + cross: true + - target: x86_64-pc-windows-msvc + os: windows-latest + cross: false + + runs-on: ${{ matrix.os }} + name: ${{ matrix.target }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM) + if: matrix.cross && startsWith(matrix.os, 'ubuntu') + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - name: Build ghook + run: cargo build --release --target ${{ matrix.target }} -p gobby-hooks + + - name: Package (Unix) + if: "!startsWith(matrix.os, 'windows')" + run: | + cd target/${{ matrix.target }}/release + tar czf ../../../ghook-${{ matrix.target }}.tar.gz ghook + cd ../../.. + + - name: Package (Windows) + if: startsWith(matrix.os, 'windows') + run: | + cd target/${{ matrix.target }}/release + Compress-Archive -Path ghook.exe -DestinationPath ../../../ghook-${{ matrix.target }}.zip + cd ../../.. + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ghook-${{ matrix.target }} + path: | + ghook-${{ matrix.target }}.tar.gz + ghook-${{ matrix.target }}.zip + + publish: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Publish gobby-hooks to crates.io + run: cargo publish -p gobby-hooks + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + release: + needs: [build, publish] + runs-on: ubuntu-latest + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + ghook-*.tar.gz + ghook-*.zip + generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8f5bd..605657c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,64 @@ All notable changes to gobby-cli are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] — gcode + +### Changed + +#### gcode + +- **gobby-core integration** — Migrated to consume the new `gobby-core` crate for project-root walk-up, bootstrap-config resolution, and daemon-URL construction. Inline implementations in `src/project.rs` removed (-109 lines); `src/config.rs` and `src/commands/index.rs` now use the shared helpers. No user-visible behavior change. (#115) + +### Fixed + +#### gcode + +- **FTS LIKE escape** — Hardened FTS5 LIKE-clause escape in `src/search/fts.rs` against patterns containing `%`, `_`, or `\`. Prevents pathological queries from matching unintended rows. (#118) +- **graph.rs dedup** — Deduplicated unresolved-symbol response building in `src/commands/graph.rs` (-63 lines net). No behavior change. (#118) + +#### CI/CD + +- **Binary-specific tag prefixes** — Release workflows now trigger on `gcode-v*`, `gsqz-v*`, `gloc-v*`, `gcore-v*`, and `ghook-v*` tags so each crate releases independently. (#110) +- **Release gating** — Added `release-gobby-core` workflow; GitHub releases for binary crates are gated on successful crates.io publish. (#116) + +## [0.4.1] — gsqz + +### Fixed + +#### gsqz + +- **Low-savings marker** — Suppress `[gsqz:low-savings]` marker when prepending it would grow the output beyond the original. The marker now only annotates when the annotation itself doesn't make things worse. (#111) +- **Outer compression header for `/no-op` strategy** — When the low-savings marker is suppressed (above), the resulting `{pipeline}/no-op` strategy now also skips the outer `[Output compressed by gsqz — …, 0% reduction]` header and the daemon savings report. The user sees the original output verbatim. `CompressionResult::is_passthrough()` classifies `passthrough`, `excluded`, and `*/no-op` together so both call sites stay in sync. (#121) + +## [0.1.0] — gobby-hooks + +### Added + +#### gobby-hooks + +- **Initial release** — Sandbox-tolerant hook dispatcher binary `ghook`. Spools envelopes to `~/.gobby/hooks/inbox/` *before* POSTing to the local Gobby daemon, so the daemon's drain worker can replay deliveries lost to sandbox FS-read denials, network blips, or daemon restarts. (#114) +- **Dispatch mode** — `ghook --gobby-owned --cli= --type= [--critical] [--detach]` reads stdin, enriches with terminal context where applicable, atomically writes the envelope, then attempts the daemon POST. +- **Diagnose mode** — `ghook --diagnose --cli= --type=` prints a JSON snapshot of what would happen — daemon URL, project root/id, recognized CLI, critical flag, terminal-context preview. No network, no envelope write. Output validated against `schemas/diagnose-output.v1.schema.json`. +- **Version mode** — `ghook --version` prints the version and writes `~/.gobby/bin/.ghook-compatibility` so the daemon can detect schema-version mismatches. +- **Exit codes** — `0` for delivered or non-critical failure (envelope still enqueued); `2` for critical failure (envelope enqueued; signals the host CLI to abort). +- **Schemas** — `inbox-envelope.v1.schema.json` and `diagnose-output.v1.schema.json`, both validated against the Rust types in unit tests. +- **Host CLI registry** — Out-of-the-box support for `claude`, `codex`, `gemini`, `qwen` (per-CLI critical-hooks set + terminal-context-hooks set). Unknown CLIs are tolerated — envelope still spools, just without enrichment. +- **Quarantine** — Malformed stdin lands in `~/.gobby/hooks/inbox/quarantine/` as a body+meta pair. The drain never replays quarantined envelopes; they surface via `gobby status` and daemon logs. +- **Atomic spool writes** — Envelopes use write-temp + `fsync` + rename, so the drain only ever sees fully-written files. +- **Renamed for consistency** — Crate renamed from `gobby-hook` to `gobby-hooks`; binary stays `ghook`. (#117) + +## [0.1.0] — gobby-core + +### Added + +#### gobby-core + +- **Initial release** — Shared-primitives crate consumed by `gcode`, `gsqz`, `gloc`, and `ghook`. Three modules: + - `project` — walk up from cwd to find `.gobby/project.json` (or legacy `.gobby/gcode.json`), read `id`/`project_id`. + - `bootstrap` — resolve `~/.gobby/bootstrap.yaml` into a `DaemonEndpoint` (host + port). Falls back to `127.0.0.1:60887` on any failure. + - `daemon_url` — compose a dial URL from a `DaemonEndpoint`, normalizing wildcard listen addresses (`0.0.0.0`, `::`, `::0`) to `127.0.0.1`. +- **Extracted from inline implementations** in the binary crates so behavior changes propagate with one PR instead of four. (#112, #113, #117) + ## [0.4.0] — gsqz ### Added diff --git a/Cargo.lock b/Cargo.lock index 2c6842e..3531ed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -29,6 +31,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -91,6 +102,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -103,6 +126,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "2.11.0" @@ -134,6 +172,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -168,6 +212,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -214,6 +269,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -267,6 +328,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -323,7 +393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -338,6 +408,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -350,7 +430,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66b725fe9483b9ee72ccaec072b15eb8ad95a3ae63a8c798d5748883b72fd33" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "getrandom 0.2.17", "openssl", @@ -403,6 +483,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3027ae1df8d41b4bed2241c8fdad4acc1e7af60c8e17743534b545e77182d678" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -534,14 +624,15 @@ dependencies = [ [[package]] name = "gobby-code" -version = "0.5.3" +version = "0.6.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "clap", "dirs", "fernet", "glob", + "gobby-core", "hmac", "ignore", "openssl", @@ -578,6 +669,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "gobby-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "serde_json", + "serde_yaml", + "tempfile", +] + +[[package]] +name = "gobby-hooks" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "clap", + "dirs", + "gobby-core", + "jsonschema", + "libc", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "ureq", + "uuid", +] + [[package]] name = "gobby-local" version = "0.1.1" @@ -592,7 +714,7 @@ dependencies = [ [[package]] name = "gobby-squeeze" -version = "0.4.0" +version = "0.4.1" dependencies = [ "clap", "dirs", @@ -734,7 +856,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -751,6 +873,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -909,6 +1055,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom", +] + [[package]] name = "itoa" version = "1.0.18" @@ -927,6 +1082,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a071f4f7efc9a9118dfb627a0a94ef247986e1ab8606a4c806ae2b3aa3b6978" +dependencies = [ + "ahash", + "anyhow", + "base64 0.21.7", + "bytecount", + "fancy-regex", + "fraction", + "getrandom 0.2.17", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1019,6 +1208,100 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1164,6 +1447,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1358,7 +1647,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1436,7 +1725,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1733,7 +2022,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1756,6 +2045,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2120,7 +2439,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "once_cell", @@ -2162,6 +2481,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ + "getrandom 0.4.2", "js-sys", "sha1_smol", "wasm-bindgen", @@ -2355,7 +2675,42 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2364,6 +2719,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index ac41df3..4eeb00b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/gcode", "crates/gsqz", "crates/gloc"] +members = ["crates/gcode", "crates/gcore", "crates/ghook", "crates/gsqz", "crates/gloc"] resolver = "3" [profile.release] @@ -14,3 +14,6 @@ opt-level = "z" [profile.release.package.gobby-local] opt-level = "z" + +[profile.release.package.gobby-hooks] +opt-level = "z" diff --git a/README.md b/README.md index c4f20e6..ede5571 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## What's Inside -This workspace contains three Gobby CLI tools: +This workspace contains four Gobby CLI tools plus a shared library: ### gcode — Code Search & Navigation @@ -35,11 +35,18 @@ Squeezes CLI output before it eats your context window. 28 built-in pipelines fo One command to launch Claude Code or Codex against a local LLM backend. Auto-detects LM Studio and Ollama, manages Ollama model lifecycle (pull, load, warmup), sets the right env vars, and `exec`s into your CLI of choice. YAML-configurable with aliases, per-client env templates, and ordered backend priority. +### ghook — Hook Dispatcher + +Sandbox-tolerant hook dispatcher invoked by host AI CLIs (Claude Code, Codex, Gemini CLI, Qwen CLI) on lifecycle and tool-use events. Spools envelopes to `~/.gobby/hooks/inbox/` *before* POSTing to the local Gobby daemon, so the daemon's drain worker can replay any delivery lost to a sandbox FS-read denial, network blip, or daemon restart. You don't usually invoke it directly — Gobby wires it into your AI CLI for you. + +`gobby-core` underpins them all — a small shared-primitives library (project root walk-up, bootstrap config, daemon URL). Not a standalone tool. + ## Documentation - [gcode User Guide](docs/guides/gcode-user-guide.md) — search, symbols, dependency graphs, project management - [gsqz User Guide](docs/guides/gsqz-user-guide.md) — pipelines, step types, configuration, debugging - [gloc User Guide](docs/guides/gloc-user-guide.md) — backends, clients, model management, configuration +- [ghook User Guide](docs/guides/ghook-user-guide.md) — hook wiring, diagnose mode, inbox/replay, troubleshooting - [Changelog](CHANGELOG.md) — release history - [gcode README](crates/gcode/README.md) — architecture and build details - [gsqz README](crates/gsqz/README.md) — architecture and build details @@ -69,6 +76,9 @@ cargo install gobby-squeeze # gloc cargo install gobby-local + +# ghook +cargo install gobby-hooks ``` On macOS, Metal GPU acceleration is enabled automatically. On Linux/Windows, embeddings use CPU inference by default — add a GPU feature flag for hardware acceleration. @@ -81,6 +91,7 @@ cd gobby-cli cargo install --path crates/gcode cargo install --path crates/gsqz cargo install --path crates/gloc +cargo install --path crates/ghook ``` ## Development diff --git a/crates/gcode/Cargo.toml b/crates/gcode/Cargo.toml index 4378b58..745612d 100644 --- a/crates/gcode/Cargo.toml +++ b/crates/gcode/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gobby-code" -version = "0.5.3" +version = "0.6.0" edition = "2024" rust-version = "1.85" authors = ["Josh Wilhelmi "] @@ -17,6 +17,9 @@ name = "gcode" path = "src/main.rs" [dependencies] +# Internal +gobby-core = { path = "../gcore", version = "0.1" } + # CLI clap = { version = "4", features = ["derive"] } diff --git a/crates/gcode/src/commands/graph.rs b/crates/gcode/src/commands/graph.rs index c522a33..4a4a63c 100644 --- a/crates/gcode/src/commands/graph.rs +++ b/crates/gcode/src/commands/graph.rs @@ -22,6 +22,20 @@ fn print_graph_hint_text(ctx: &Context) { } } +fn empty_response_for_unresolved(ctx: &Context, format: Format) -> anyhow::Result<()> { + match format { + Format::Json => output::print_json(&PagedResponse::> { + project_id: ctx.project_id.clone(), + total: 0, + offset: 0, + limit: 0, + results: vec![], + hint: hint_for(ctx), + }), + Format::Text => Ok(()), + } +} + /// Resolve user input to a symbol name, printing suggestions on ambiguity. /// Returns None and prints an error message if no match found. fn resolve_name(ctx: &Context, input: &str) -> Option { @@ -34,12 +48,7 @@ fn resolve_name(ctx: &Context, input: &str) -> Option { Some(name) if !suggestions.is_empty() => { eprintln!( "Resolved '{input}' to '{name}' (also matched: {})", - suggestions - .iter() - .filter(|s| s != &name) - .cloned() - .collect::>() - .join(", ") + suggestions.join(", ") ); } None => { @@ -59,19 +68,7 @@ pub fn callers( ) -> anyhow::Result<()> { let name = match resolve_name(ctx, symbol_name) { Some(n) => n, - None => { - return match format { - Format::Json => output::print_json(&PagedResponse::> { - project_id: ctx.project_id.clone(), - total: 0, - offset: 0, - limit: 0, - results: vec![], - hint: hint_for(ctx), - }), - Format::Text => Ok(()), - }; - } + None => return empty_response_for_unresolved(ctx, format), }; let total = neo4j::count_callers(ctx, &name)?; let results = neo4j::find_callers(ctx, &name, offset, limit)?; @@ -118,19 +115,7 @@ pub fn usages( ) -> anyhow::Result<()> { let name = match resolve_name(ctx, symbol_name) { Some(n) => n, - None => { - return match format { - Format::Json => output::print_json(&PagedResponse::> { - project_id: ctx.project_id.clone(), - total: 0, - offset: 0, - limit: 0, - results: vec![], - hint: hint_for(ctx), - }), - Format::Text => Ok(()), - }; - } + None => return empty_response_for_unresolved(ctx, format), }; let total = neo4j::count_usages(ctx, &name)?; let results = neo4j::find_usages(ctx, &name, offset, limit)?; @@ -206,19 +191,7 @@ pub fn blast_radius( ) -> anyhow::Result<()> { let name = match resolve_name(ctx, target) { Some(n) => n, - None => { - return match format { - Format::Json => output::print_json(&PagedResponse::> { - project_id: ctx.project_id.clone(), - total: 0, - offset: 0, - limit: 0, - results: vec![], - hint: hint_for(ctx), - }), - Format::Text => Ok(()), - }; - } + None => return empty_response_for_unresolved(ctx, format), }; let results = neo4j::blast_radius(ctx, &name, depth)?; let total = results.len(); diff --git a/crates/gcode/src/commands/index.rs b/crates/gcode/src/commands/index.rs index 7a4c9d1..59005a8 100644 --- a/crates/gcode/src/commands/index.rs +++ b/crates/gcode/src/commands/index.rs @@ -1,3 +1,5 @@ +use gobby_core::project::{find_project_root, read_project_id}; + use crate::config::Context; use crate::db; use crate::index::indexer; @@ -13,12 +15,11 @@ pub fn run( let (root, project_id, conn) = match path.as_deref() { Some(p) => { let target = std::path::PathBuf::from(p); - let target_root = - crate::project::find_project_root(&target).unwrap_or_else(|| target.clone()); + let target_root = find_project_root(&target).unwrap_or_else(|| target.clone()); if target_root != ctx.project_root { // Path belongs to a different project — re-resolve everything let db_path = crate::config::resolve_db_path(&target_root)?; - let project_id = crate::project::read_project_id(&target_root) + let project_id = read_project_id(&target_root) .or_else(|_| crate::project::read_gcode_json(&target_root)) .unwrap_or_else(|_| crate::project::generate_project_id(&target_root)); if !ctx.quiet { diff --git a/crates/gcode/src/commands/init.rs b/crates/gcode/src/commands/init.rs index 54cf61a..56ef29c 100644 --- a/crates/gcode/src/commands/init.rs +++ b/crates/gcode/src/commands/init.rs @@ -30,10 +30,8 @@ pub fn run(project_root: &Path, format: Format, quiet: bool) -> anyhow::Result<( } installed_skills.push(cli.name.to_string()); } - Err(e) => { - if !quiet { - eprintln!("Warning: failed to install skill for {}: {}", cli.name, e); - } + Err(e) if !quiet => { + eprintln!("Warning: failed to install skill for {}: {}", cli.name, e); } _ => {} } diff --git a/crates/gcode/src/config.rs b/crates/gcode/src/config.rs index db21228..7883344 100644 --- a/crates/gcode/src/config.rs +++ b/crates/gcode/src/config.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use anyhow::Context as _; +use gobby_core::project::{find_project_root, read_project_id}; use crate::secrets; @@ -196,7 +197,7 @@ pub fn detect_project_root() -> anyhow::Result { let cwd = std::env::current_dir()?; // First: look for an identity file (.gobby/project.json or .gobby/gcode.json) - if let Some(root) = crate::project::find_project_root(&cwd) { + if let Some(root) = find_project_root(&cwd) { return Ok(root); } @@ -255,7 +256,7 @@ fn resolve_project_id(project_root: &Path) -> anyhow::Result { let gobby_dir = project_root.join(".gobby"); if gobby_dir.join("project.json").exists() { - return crate::project::read_project_id(project_root); + return read_project_id(project_root); } if gobby_dir.join("gcode.json").exists() { return crate::project::read_gcode_json(project_root); diff --git a/crates/gcode/src/project.rs b/crates/gcode/src/project.rs index 9e112be..60c7935 100644 --- a/crates/gcode/src/project.rs +++ b/crates/gcode/src/project.rs @@ -3,43 +3,14 @@ //! Resolution order: .gobby/project.json (gobby) > .gobby/gcode.json (standalone) > generate on-the-fly. //! gcode never writes to project.json — that's gobby's file. -use std::path::{Path, PathBuf}; +use std::path::Path; use anyhow::Context as _; +use gobby_core::project::read_project_id; use uuid::Uuid; use crate::models::CODE_INDEX_UUID_NAMESPACE; -/// Walk up from `start` looking for `.gobby/project.json` or `.gobby/gcode.json`. -/// Returns the project root (parent of `.gobby/`) if found. -pub fn find_project_root(start: &Path) -> Option { - let mut dir = start; - loop { - let gobby_dir = dir.join(".gobby"); - if gobby_dir.join("project.json").exists() || gobby_dir.join("gcode.json").exists() { - return Some(dir.to_path_buf()); - } - match dir.parent() { - Some(parent) => dir = parent, - None => return None, - } - } -} - -/// Read project ID from `.gobby/project.json`. -/// Reads `"id"` field first, falls back to `"project_id"` for backwards compat. -pub fn read_project_id(project_root: &Path) -> anyhow::Result { - let path = project_root.join(".gobby").join("project.json"); - let contents = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - let json: serde_json::Value = serde_json::from_str(&contents)?; - json.get("id") - .or_else(|| json.get("project_id")) - .and_then(|v| v.as_str()) - .map(String::from) - .context("'id' field not found in .gobby/project.json") -} - /// Read project ID from `.gobby/gcode.json`. pub fn read_gcode_json(project_root: &Path) -> anyhow::Result { let path = project_root.join(".gobby").join("gcode.json"); @@ -239,82 +210,6 @@ mod tests { assert_eq!(original_bytes, after_bytes); } - #[test] - fn test_read_project_id_uses_id_field() { - let dir = tempfile::tempdir().unwrap(); - let gobby_dir = dir.path().join(".gobby"); - std::fs::create_dir_all(&gobby_dir).unwrap(); - - let json = serde_json::json!({ - "id": "correct-id", - "name": "test" - }); - std::fs::write( - gobby_dir.join("project.json"), - serde_json::to_string(&json).unwrap(), - ) - .unwrap(); - - let id = read_project_id(dir.path()).unwrap(); - assert_eq!(id, "correct-id"); - } - - #[test] - fn test_read_project_id_falls_back_to_project_id_key() { - let dir = tempfile::tempdir().unwrap(); - let gobby_dir = dir.path().join(".gobby"); - std::fs::create_dir_all(&gobby_dir).unwrap(); - - // Old format with "project_id" instead of "id" - let json = serde_json::json!({ - "project_id": "legacy-id", - "name": "test" - }); - std::fs::write( - gobby_dir.join("project.json"), - serde_json::to_string(&json).unwrap(), - ) - .unwrap(); - - let id = read_project_id(dir.path()).unwrap(); - assert_eq!(id, "legacy-id"); - } - - #[test] - fn test_find_project_root_finds_project_json() { - let dir = tempfile::tempdir().unwrap(); - let nested = dir.path().join("a").join("b").join("c"); - std::fs::create_dir_all(&nested).unwrap(); - - let gobby_dir = dir.path().join(".gobby"); - std::fs::create_dir_all(&gobby_dir).unwrap(); - std::fs::write(gobby_dir.join("project.json"), "{}").unwrap(); - - let found = find_project_root(&nested); - assert_eq!(found, Some(dir.path().to_path_buf())); - } - - #[test] - fn test_find_project_root_finds_gcode_json() { - let dir = tempfile::tempdir().unwrap(); - let nested = dir.path().join("a").join("b"); - std::fs::create_dir_all(&nested).unwrap(); - - let gobby_dir = dir.path().join(".gobby"); - std::fs::create_dir_all(&gobby_dir).unwrap(); - std::fs::write(gobby_dir.join("gcode.json"), "{}").unwrap(); - - let found = find_project_root(&nested); - assert_eq!(found, Some(dir.path().to_path_buf())); - } - - #[test] - fn test_find_project_root_returns_none() { - let dir = tempfile::tempdir().unwrap(); - let found = find_project_root(dir.path()); - assert!(found.is_none()); - } - #[test] fn test_now_iso8601_format() { let ts = now_iso8601(); diff --git a/crates/gcode/src/search/fts.rs b/crates/gcode/src/search/fts.rs index 320b715..c9936c3 100644 --- a/crates/gcode/src/search/fts.rs +++ b/crates/gcode/src/search/fts.rs @@ -5,6 +5,19 @@ use rusqlite::Connection; use crate::models::{ContentSearchHit, SearchResult, Symbol}; +/// Escape LIKE wildcards (`%`, `_`) and the backslash escape char itself. +/// Must be paired with `ESCAPE '\'` in the SQL for SQLite to honor it. +fn escape_like(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '\\' | '%' | '_') { + out.push('\\'); + } + out.push(c); + } + out +} + /// Extract a SQL LIKE prefix from a glob pattern for index-assisted pre-filtering. /// Returns the literal prefix before the first wildcard character, or None if empty. fn glob_to_like_prefix(pattern: &str) -> Option { @@ -15,10 +28,7 @@ fn glob_to_like_prefix(pattern: &str) -> Option { if prefix.is_empty() { None } else { - Some(format!( - "{}%", - prefix.replace('%', r"\%").replace('_', r"\_") - )) + Some(format!("{}%", escape_like(&prefix))) } } @@ -153,11 +163,13 @@ pub fn resolve_symbol_name( return (Some(name), vec![]); } - // 2. LIKE fallback — collect top matches as suggestions - let pattern = format!("%{input}%"); + // 2. LIKE fallback — collect top matches as suggestions. + // Escape %/_/\ in input and use ESCAPE clause so symbol names containing + // underscores (common in snake_case) match literally, not as wildcards. + let pattern = format!("%{}%", escape_like(input)); let mut stmt = match conn.prepare( "SELECT DISTINCT name FROM code_symbols \ - WHERE project_id = ? AND (name LIKE ? OR qualified_name LIKE ?) \ + WHERE project_id = ? AND (name LIKE ? ESCAPE '\\' OR qualified_name LIKE ? ESCAPE '\\') \ ORDER BY name LIMIT 5", ) { Ok(s) => s, @@ -174,7 +186,8 @@ pub fn resolve_symbol_name( if names.len() == 1 { return (Some(names[0].clone()), vec![]); } else if !names.is_empty() { - return (Some(names[0].clone()), names); + let first = names[0].clone(); + return (Some(first), names[1..].to_vec()); } // 3. FTS5 fallback — search across names, signatures, docstrings @@ -196,7 +209,8 @@ pub fn resolve_symbol_name( } else if fts_names.is_empty() { (None, vec![]) } else { - (Some(fts_names[0].clone()), fts_names) + let first = fts_names[0].clone(); + (Some(first), fts_names[1..].to_vec()) } } diff --git a/crates/gcore/Cargo.toml b/crates/gcore/Cargo.toml new file mode 100644 index 0000000..0fae970 --- /dev/null +++ b/crates/gcore/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gobby-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +authors = ["Josh Wilhelmi "] +description = "Shared primitives for Gobby CLI tools — project root resolution, bootstrap config, daemon URL helpers" +license = "Apache-2.0" +repository = "https://github.com/GobbyAI/gobby-cli" +homepage = "https://gobby.ai" +keywords = ["gobby", "cli"] +categories = ["development-tools"] + +[dependencies] +anyhow = "1" +dirs = "6" +serde_json = "1" +serde_yaml = "0.9" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/gcore/src/bootstrap.rs b/crates/gcore/src/bootstrap.rs new file mode 100644 index 0000000..af3c6e9 --- /dev/null +++ b/crates/gcore/src/bootstrap.rs @@ -0,0 +1,156 @@ +//! Bootstrap config resolution. +//! +//! Reads `~/.gobby/bootstrap.yaml` to discover how the Gobby daemon is +//! reachable: its TCP port and bind host. Falls back to loopback defaults +//! when the file is missing, unreadable, or malformed — clients should +//! always get *something* usable rather than error on startup. +//! +//! The daemon advertises `bind_host` as a listen address. `0.0.0.0` and +//! `::` are valid listen addresses but invalid dial addresses — a user who +//! sets `bind_host: 0.0.0.0` to expose the daemon on their LAN must still +//! connect to `127.0.0.1` locally. Normalization lives in [`daemon_url`] +//! (the caller concerned with dialing), not here; this module returns the +//! raw endpoint as written. +//! +//! [`daemon_url`]: crate::daemon_url + +use std::path::{Path, PathBuf}; + +/// Default daemon port when bootstrap.yaml is missing or malformed. +pub const DEFAULT_DAEMON_PORT: u16 = 60887; + +/// Default bind host when bootstrap.yaml is missing or malformed. +pub const DEFAULT_BIND_HOST: &str = "127.0.0.1"; + +const BOOTSTRAP_RELATIVE_PATH: &str = ".gobby/bootstrap.yaml"; + +/// A daemon endpoint as advertised by bootstrap.yaml. +/// +/// `host` is returned verbatim from the config (or [`DEFAULT_BIND_HOST`]); +/// callers that dial should apply [`crate::daemon_url`] to normalize +/// unroutable listen addresses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DaemonEndpoint { + pub host: String, + pub port: u16, +} + +impl Default for DaemonEndpoint { + fn default() -> Self { + Self { + host: DEFAULT_BIND_HOST.to_string(), + port: DEFAULT_DAEMON_PORT, + } + } +} + +/// Resolve the path to `~/.gobby/bootstrap.yaml`. +/// +/// Returns `None` when the home directory cannot be determined. +pub fn bootstrap_path() -> Option { + dirs::home_dir().map(|h| h.join(BOOTSTRAP_RELATIVE_PATH)) +} + +/// Read the daemon endpoint from the default bootstrap path. +/// +/// Falls back to [`DaemonEndpoint::default`] on any failure — missing file, +/// unreadable file, malformed YAML, missing fields, or no home directory. +pub fn read_daemon_endpoint() -> DaemonEndpoint { + match bootstrap_path() { + Some(path) => read_daemon_endpoint_at(&path), + None => DaemonEndpoint::default(), + } +} + +/// Read the daemon endpoint from a specific bootstrap file path. +/// +/// Exposed for tests and for callers that know the path explicitly. +/// Same fallback semantics as [`read_daemon_endpoint`]. +pub fn read_daemon_endpoint_at(path: &Path) -> DaemonEndpoint { + let Ok(contents) = std::fs::read_to_string(path) else { + return DaemonEndpoint::default(); + }; + let Ok(yaml) = serde_yaml::from_str::(&contents) else { + return DaemonEndpoint::default(); + }; + + let port = yaml + .get("daemon_port") + .and_then(|v| v.as_u64()) + .and_then(|n| u16::try_from(n).ok()) + .unwrap_or(DEFAULT_DAEMON_PORT); + + let host = yaml + .get("bind_host") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .unwrap_or_else(|| DEFAULT_BIND_HOST.to_string()); + + DaemonEndpoint { host, port } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn missing_file_returns_defaults() { + let dir = tempdir().unwrap(); + let path = dir.path().join("does-not-exist.yaml"); + assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default()); + } + + #[test] + fn malformed_yaml_returns_defaults() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.yaml"); + fs::write(&path, ": : not valid yaml ::\n\t").unwrap(); + assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default()); + } + + #[test] + fn empty_file_returns_defaults() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.yaml"); + fs::write(&path, "").unwrap(); + assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default()); + } + + #[test] + fn missing_fields_return_defaults() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.yaml"); + fs::write(&path, "other_field: value\n").unwrap(); + assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default()); + } + + #[test] + fn reads_custom_port() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.yaml"); + fs::write(&path, "daemon_port: 61234\n").unwrap(); + let ep = read_daemon_endpoint_at(&path); + assert_eq!(ep.port, 61234); + assert_eq!(ep.host, DEFAULT_BIND_HOST); + } + + #[test] + fn reads_custom_host_and_port() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.yaml"); + fs::write(&path, "daemon_port: 60887\nbind_host: 0.0.0.0\n").unwrap(); + let ep = read_daemon_endpoint_at(&path); + assert_eq!(ep.port, 60887); + assert_eq!(ep.host, "0.0.0.0"); + } + + #[test] + fn out_of_range_port_falls_back() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.yaml"); + fs::write(&path, "daemon_port: 70000\n").unwrap(); + assert_eq!(read_daemon_endpoint_at(&path).port, DEFAULT_DAEMON_PORT); + } +} diff --git a/crates/gcore/src/daemon_url.rs b/crates/gcore/src/daemon_url.rs new file mode 100644 index 0000000..086db2c --- /dev/null +++ b/crates/gcore/src/daemon_url.rs @@ -0,0 +1,102 @@ +//! Daemon URL composition with unroutable-host normalization. +//! +//! The Gobby daemon advertises a listen address in `bootstrap.yaml`, but +//! clients need a *dial* address. `0.0.0.0` and `::` are wildcard listen +//! addresses — you cannot `connect(2)` to them — so a user who sets +//! `bind_host: 0.0.0.0` (to expose the daemon on their LAN) must still +//! dial `127.0.0.1` from a local client. This module applies that +//! normalization uniformly; [`crate::bootstrap`] returns the raw endpoint. + +use std::path::Path; + +use crate::bootstrap::{DaemonEndpoint, read_daemon_endpoint, read_daemon_endpoint_at}; + +/// Compose the daemon dial URL from the default bootstrap path. +pub fn daemon_url() -> String { + endpoint_to_url(&read_daemon_endpoint()) +} + +/// Compose the daemon dial URL from a specific bootstrap file path. +/// +/// Exposed for tests and for callers that know the path explicitly. +pub fn daemon_url_at(path: &Path) -> String { + endpoint_to_url(&read_daemon_endpoint_at(path)) +} + +fn endpoint_to_url(endpoint: &DaemonEndpoint) -> String { + let host = dial_host(&endpoint.host); + format!("http://{host}:{}", endpoint.port) +} + +/// Map a listen host to a dial host. +/// +/// Wildcard listen addresses (`0.0.0.0`, `::`, `::0`) are rewritten to +/// `127.0.0.1`. Everything else passes through unchanged — hostnames, +/// named interfaces, explicit IPv4/IPv6 literals. In practice +/// bootstrap.yaml is always `localhost`, an IPv4 literal, or a wildcard, +/// so bracketing IPv6 for URL embedding is not handled here. +fn dial_host(host: &str) -> &str { + match host { + "0.0.0.0" | "::" | "::0" => "127.0.0.1", + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn write_bootstrap(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.yaml"); + fs::write(&path, contents).unwrap(); + (dir, path) + } + + #[test] + fn default_url_when_file_missing() { + let dir = tempdir().unwrap(); + let path = dir.path().join("nope.yaml"); + assert_eq!(daemon_url_at(&path), "http://127.0.0.1:60887"); + } + + #[test] + fn wildcard_ipv4_normalizes_to_loopback() { + let (_dir, path) = write_bootstrap("daemon_port: 60887\nbind_host: 0.0.0.0\n"); + assert_eq!(daemon_url_at(&path), "http://127.0.0.1:60887"); + } + + #[test] + fn wildcard_ipv6_normalizes_to_loopback() { + let (_dir, path) = write_bootstrap( + r#"daemon_port: 60887 +bind_host: "::" +"#, + ); + assert_eq!(daemon_url_at(&path), "http://127.0.0.1:60887"); + } + + #[test] + fn wildcard_ipv6_zero_normalizes_to_loopback() { + let (_dir, path) = write_bootstrap( + r#"daemon_port: 60887 +bind_host: "::0" +"#, + ); + assert_eq!(daemon_url_at(&path), "http://127.0.0.1:60887"); + } + + #[test] + fn localhost_passes_through() { + let (_dir, path) = write_bootstrap("daemon_port: 60887\nbind_host: localhost\n"); + assert_eq!(daemon_url_at(&path), "http://localhost:60887"); + } + + #[test] + fn custom_port_and_host_compose() { + let (_dir, path) = write_bootstrap("daemon_port: 61234\nbind_host: 10.0.0.5\n"); + assert_eq!(daemon_url_at(&path), "http://10.0.0.5:61234"); + } +} diff --git a/crates/gcore/src/lib.rs b/crates/gcore/src/lib.rs new file mode 100644 index 0000000..167a0d0 --- /dev/null +++ b/crates/gcore/src/lib.rs @@ -0,0 +1,9 @@ +//! Shared primitives for Gobby CLI tools. +//! +//! Small, dependency-light helpers that multiple Gobby binaries (`gcode`, +//! `gsqz`, `gloc`, `ghook`) share: project-root walk-up, project-id reading, +//! bootstrap config resolution, daemon URL construction. + +pub mod bootstrap; +pub mod daemon_url; +pub mod project; diff --git a/crates/gcore/src/project.rs b/crates/gcore/src/project.rs new file mode 100644 index 0000000..f3ac65f --- /dev/null +++ b/crates/gcore/src/project.rs @@ -0,0 +1,39 @@ +//! Project-root discovery and project-id reading. +//! +//! Kept in lockstep with `gcode/src/project.rs` until PR 4 (R2-08) migrates +//! gcode to import from here. + +use anyhow::Context; +use std::path::{Path, PathBuf}; + +/// Walk up from `start` looking for a `.gobby` directory containing either +/// `project.json` or `gcode.json`. Returns the project root (the directory +/// containing `.gobby`) or `None` if no project is found before hitting the +/// filesystem root. +pub fn find_project_root(start: &Path) -> Option { + let mut dir = start; + loop { + let gobby_dir = dir.join(".gobby"); + if gobby_dir.join("project.json").exists() || gobby_dir.join("gcode.json").exists() { + return Some(dir.to_path_buf()); + } + match dir.parent() { + Some(parent) => dir = parent, + None => return None, + } + } +} + +/// Read the project id from `/.gobby/project.json`. Accepts +/// either `id` or `project_id` as the key (legacy fallback). +pub fn read_project_id(project_root: &Path) -> anyhow::Result { + let path = project_root.join(".gobby").join("project.json"); + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let json: serde_json::Value = serde_json::from_str(&contents)?; + json.get("id") + .or_else(|| json.get("project_id")) + .and_then(|v| v.as_str()) + .map(String::from) + .context("'id' field not found in .gobby/project.json") +} diff --git a/crates/ghook/Cargo.toml b/crates/ghook/Cargo.toml new file mode 100644 index 0000000..69ed8f2 --- /dev/null +++ b/crates/ghook/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "gobby-hooks" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +authors = ["Josh Wilhelmi "] +description = "Sandbox-tolerant hook dispatcher for Gobby — enqueue-first, Rust port of the Python hook_dispatcher. Ships the `ghook` binary." +license = "Apache-2.0" +repository = "https://github.com/GobbyAI/gobby-cli" +homepage = "https://gobby.ai" +readme = "README.md" +keywords = ["gobby", "cli", "hooks"] +categories = ["command-line-utilities", "development-tools"] + +[[bin]] +name = "ghook" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +base64 = "0.22" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +clap = { version = "4", features = ["derive"] } +dirs = "6" +gobby-core = { path = "../gcore", version = "0.1.0" } +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +ureq = { version = "2", features = ["json"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +jsonschema = { version = "0.17", default-features = false } +tempfile = "3" diff --git a/crates/ghook/README.md b/crates/ghook/README.md new file mode 100644 index 0000000..0df8b59 --- /dev/null +++ b/crates/ghook/README.md @@ -0,0 +1,29 @@ +# ghook + +Sandbox-tolerant hook dispatcher for Gobby. + +`ghook` is invoked by host AI CLIs (Claude Code, Codex, Gemini CLI, Qwen +CLI) on lifecycle and tool-use events. It enqueues an envelope to +`~/.gobby/hooks/inbox/` *before* attempting to POST to the local Gobby +daemon — so the daemon's drain worker replays any envelope whose POST +was lost to a sandbox FS-read denial, a network blip, or daemon restart. + +## CLI surface + +```text +ghook --gobby-owned --cli= --type= [--critical] [--detach] +ghook --diagnose --cli= --type= +ghook --version +``` + +Exit codes: + +- `0` — delivered OR non-critical failure (envelope enqueued for replay) +- `2` — critical failure (envelope enqueued; signals the host CLI) + +## Schemas + +- `schemas/inbox-envelope.v1.schema.json` — what lands in the inbox. +- `schemas/diagnose-output.v1.schema.json` — what `--diagnose` prints. + +Both are validated in unit tests. diff --git a/crates/ghook/schemas/diagnose-output.v1.schema.json b/crates/ghook/schemas/diagnose-output.v1.schema.json new file mode 100644 index 0000000..86ad0f3 --- /dev/null +++ b/crates/ghook/schemas/diagnose-output.v1.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://gobby.ai/schemas/ghook/diagnose-output.v1.schema.json", + "title": "Gobby ghook --diagnose output", + "description": "Output of `ghook --diagnose --cli= --type=`. Schema version 1.", + "type": "object", + "required": [ + "schema_version", + "ghook_version", + "cli", + "hook_type", + "critical", + "terminal_context_enabled", + "daemon_url", + "daemon_host", + "daemon_port", + "cli_recognized" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "integer", + "const": 1 + }, + "ghook_version": { + "type": "string", + "minLength": 1 + }, + "cli": { + "type": "string", + "minLength": 1 + }, + "hook_type": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": ["string", "null"] + }, + "critical": { + "type": "boolean" + }, + "terminal_context_enabled": { + "type": "boolean" + }, + "daemon_url": { + "type": "string", + "minLength": 1 + }, + "daemon_host": { + "type": "string", + "minLength": 1 + }, + "daemon_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "project_root": { + "type": ["string", "null"] + }, + "project_id": { + "type": ["string", "null"] + }, + "terminal_context_preview": { + "type": ["object", "null"] + }, + "cli_recognized": { + "type": "boolean" + } + } +} diff --git a/crates/ghook/schemas/inbox-envelope.v1.schema.json b/crates/ghook/schemas/inbox-envelope.v1.schema.json new file mode 100644 index 0000000..11f55b5 --- /dev/null +++ b/crates/ghook/schemas/inbox-envelope.v1.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://gobby.ai/schemas/ghook/inbox-envelope.v1.schema.json", + "title": "Gobby ghook inbox envelope", + "description": "Envelope written by ghook to ~/.gobby/hooks/inbox/ and replayed by the daemon drain worker. Schema version 1.", + "type": "object", + "required": [ + "schema_version", + "enqueued_at", + "critical", + "hook_type", + "input_data", + "source", + "headers" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "Envelope schema version; consumers must reject unknown versions." + }, + "enqueued_at": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 UTC timestamp when ghook enqueued this envelope." + }, + "critical": { + "type": "boolean", + "description": "When true, ghook exited 2 on failure to signal the host CLI." + }, + "hook_type": { + "type": "string", + "minLength": 1, + "description": "Host-CLI-specific hook name (e.g. session-start, SessionStart, PreToolUse)." + }, + "input_data": { + "description": "Original stdin payload from the host CLI, optionally enriched with a terminal_context object for hooks that request it." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source CLI identifier passed to the daemon (claude, codex, gemini, qwen)." + }, + "headers": { + "type": "object", + "description": "HTTP headers mirroring what ghook sent (or would have sent) to the daemon. Omitted headers are absent keys; empty-string values are never emitted.", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "properties": { + "X-Gobby-Project-Id": { + "type": "string", + "minLength": 1 + }, + "X-Gobby-Session-Id": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/crates/ghook/src/cli_config.rs b/crates/ghook/src/cli_config.rs new file mode 100644 index 0000000..6169b52 --- /dev/null +++ b/crates/ghook/src/cli_config.rs @@ -0,0 +1,101 @@ +//! Per-CLI hook-dispatcher configuration. +//! +//! Mirrors the `CLIConfig` registry in `hook_dispatcher.py` — the set of +//! host CLIs Gobby dispatches for and, per CLI, which hooks are "critical" +//! (block on failure, exit 2) and which should carry enriched terminal +//! context. + +use std::collections::HashSet; + +/// Per-CLI dispatcher knobs. Frozen at compile time — CLIs are a closed set. +#[derive(Debug, Clone)] +pub struct CliConfig { + /// Source identifier sent to the daemon. + pub source: &'static str, + /// Hooks where failure should fail-closed (exit 2). + pub critical_hooks: HashSet<&'static str>, + /// Hooks that should carry enriched terminal context in `input_data`. + pub terminal_context_hooks: HashSet<&'static str>, +} + +impl CliConfig { + pub fn for_cli(cli: &str) -> Option { + match cli.to_ascii_lowercase().as_str() { + "claude" => Some(Self { + source: "claude", + critical_hooks: ["session-start", "session-end", "pre-compact"] + .into_iter() + .collect(), + terminal_context_hooks: ["session-start"].into_iter().collect(), + }), + "gemini" => Some(Self { + source: "gemini", + critical_hooks: ["SessionStart"].into_iter().collect(), + terminal_context_hooks: ["SessionStart"].into_iter().collect(), + }), + "qwen" => Some(Self { + source: "qwen", + critical_hooks: ["SessionStart"].into_iter().collect(), + terminal_context_hooks: ["SessionStart"].into_iter().collect(), + }), + "codex" => Some(Self { + source: "codex", + critical_hooks: ["SessionStart", "Stop"].into_iter().collect(), + terminal_context_hooks: [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Stop", + ] + .into_iter() + .collect(), + }), + _ => None, + } + } + + pub fn wants_terminal_context(&self, hook_type: &str) -> bool { + self.terminal_context_hooks.contains(hook_type) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_critical_hooks() { + let c = CliConfig::for_cli("claude").unwrap(); + assert_eq!(c.source, "claude"); + assert!(c.critical_hooks.contains("session-start")); + assert!(c.critical_hooks.contains("pre-compact")); + assert!(!c.critical_hooks.contains("SessionStart")); + } + + #[test] + fn codex_terminal_context_broad() { + let c = CliConfig::for_cli("codex").unwrap(); + assert!(c.wants_terminal_context("PreToolUse")); + assert!(c.wants_terminal_context("Stop")); + assert!(!c.wants_terminal_context("session-start")); + } + + #[test] + fn gemini_session_only_terminal_context() { + let c = CliConfig::for_cli("gemini").unwrap(); + assert!(c.wants_terminal_context("SessionStart")); + assert!(!c.wants_terminal_context("PreToolUse")); + } + + #[test] + fn unknown_cli_returns_none() { + assert!(CliConfig::for_cli("cursor").is_none()); + } + + #[test] + fn cli_name_is_case_insensitive() { + assert!(CliConfig::for_cli("CLAUDE").is_some()); + assert!(CliConfig::for_cli("Codex").is_some()); + } +} diff --git a/crates/ghook/src/detach.rs b/crates/ghook/src/detach.rs new file mode 100644 index 0000000..5ed2359 --- /dev/null +++ b/crates/ghook/src/detach.rs @@ -0,0 +1,43 @@ +//! Process detachment, cross-platform. +//! +//! Unix: single `setsid(2)` to escape the controlling terminal and the +//! parent's process group. Mirrors `start_new_session=True` in the +//! dispatcher (`hook_dispatcher.py:697`) — no double-fork, no daemonize +//! tricks. +//! +//! Windows: `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` would be the +//! parallel, but those flags apply at `CreateProcess` time, not to the +//! already-running current process. For a self-detaching binary, the +//! closest honest equivalent is `FreeConsole`. Keep the surface consistent +//! (calling `detach()` on Windows disables console I/O) but don't pretend +//! we're doing more than we are. + +/// Detach the current process from its controlling TTY and process group. +/// +/// Unix: `setsid()`. On failure (already a session leader, etc.) we log +/// nothing and continue — the dispatcher's Python parallel is also +/// best-effort (subprocess `start_new_session=True` just requests the +/// child leads a session). +/// +/// Windows: `FreeConsole()` — best effort, no-op if not attached. +pub fn detach() { + #[cfg(unix)] + { + // SAFETY: setsid is always safe to call. It fails only when the + // caller is already a process-group leader, in which case we just + // carry on. + unsafe { + libc::setsid(); + } + } + #[cfg(windows)] + { + // SAFETY: FreeConsole is safe to call even with no attached console. + extern "system" { + fn FreeConsole() -> i32; + } + unsafe { + FreeConsole(); + } + } +} diff --git a/crates/ghook/src/diagnose.rs b/crates/ghook/src/diagnose.rs new file mode 100644 index 0000000..a2b9a3d --- /dev/null +++ b/crates/ghook/src/diagnose.rs @@ -0,0 +1,142 @@ +//! `ghook --diagnose` — print what *would* happen for a given CLI/hook combo. +//! +//! Emits a JSON object validated against +//! `schemas/diagnose-output.v1.schema.json`. No network I/O, no envelope +//! write — this is a pure introspection surface so operators can confirm +//! configuration without spamming the inbox. + +use crate::cli_config::CliConfig; +use gobby_core::{bootstrap, daemon_url, project}; +use serde::Serialize; +use serde_json::Value; +use std::path::PathBuf; + +#[derive(Debug, Serialize)] +pub struct DiagnoseOutput { + pub schema_version: u32, + pub ghook_version: &'static str, + pub cli: String, + pub hook_type: String, + pub source: Option, + pub critical: bool, + pub terminal_context_enabled: bool, + pub daemon_url: String, + pub daemon_host: String, + pub daemon_port: u16, + pub project_root: Option, + pub project_id: Option, + pub terminal_context_preview: Option, + pub cli_recognized: bool, +} + +pub const DIAGNOSE_SCHEMA_VERSION: u32 = 1; +pub const GHOOK_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub fn diagnose(cli: &str, hook_type: &str) -> DiagnoseOutput { + let cfg = CliConfig::for_cli(cli); + let cli_recognized = cfg.is_some(); + + let endpoint = bootstrap::read_daemon_endpoint(); + let url = daemon_url::daemon_url(); + + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let project_root = project::find_project_root(&cwd); + let project_id = project_root + .as_ref() + .and_then(|r| project::read_project_id(r).ok()); + + let (source, critical, terminal_context_enabled, terminal_context_preview) = match cfg { + Some(c) => { + let critical = c.critical_hooks.contains(hook_type); + let wants_ctx = c.wants_terminal_context(hook_type); + let preview = if wants_ctx { + Some(crate::terminal_context::capture()) + } else { + None + }; + (Some(c.source.to_string()), critical, wants_ctx, preview) + } + None => (None, false, false, None), + }; + + DiagnoseOutput { + schema_version: DIAGNOSE_SCHEMA_VERSION, + ghook_version: GHOOK_VERSION, + cli: cli.to_string(), + hook_type: hook_type.to_string(), + source, + critical, + terminal_context_enabled, + daemon_url: url, + daemon_host: endpoint.host, + daemon_port: endpoint.port, + project_root, + project_id, + terminal_context_preview, + cli_recognized, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_cli_marked_not_recognized() { + let d = diagnose("cursor", "session-start"); + assert!(!d.cli_recognized); + assert!(d.source.is_none()); + assert!(!d.critical); + assert!(!d.terminal_context_enabled); + } + + #[test] + fn claude_session_start_is_critical_with_terminal_context() { + let d = diagnose("claude", "session-start"); + assert!(d.cli_recognized); + assert_eq!(d.source.as_deref(), Some("claude")); + assert!(d.critical); + assert!(d.terminal_context_enabled); + assert!(d.terminal_context_preview.is_some()); + } + + #[test] + fn codex_pre_tool_use_noncritical_with_terminal_context() { + let d = diagnose("codex", "PreToolUse"); + assert!(d.cli_recognized); + assert!(!d.critical); + assert!(d.terminal_context_enabled); + } + + #[test] + fn diagnose_output_validates_against_v1_schema() { + let schema_bytes = include_bytes!("../schemas/diagnose-output.v1.schema.json"); + let schema: serde_json::Value = serde_json::from_slice(schema_bytes).unwrap(); + let compiled = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema) + .expect("schema compiles"); + let out = diagnose("claude", "session-start"); + let v = serde_json::to_value(&out).unwrap(); + if let Err(errs) = compiled.validate(&v) { + let msgs: Vec<_> = errs.map(|e| format!("{e}")).collect(); + panic!("diagnose output failed schema validation: {msgs:?}"); + } + } + + #[test] + fn diagnose_output_for_unknown_cli_validates() { + let schema_bytes = include_bytes!("../schemas/diagnose-output.v1.schema.json"); + let schema: serde_json::Value = serde_json::from_slice(schema_bytes).unwrap(); + let compiled = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema) + .expect("schema compiles"); + let out = diagnose("cursor", "session-start"); + let v = serde_json::to_value(&out).unwrap(); + if let Err(errs) = compiled.validate(&v) { + let msgs: Vec<_> = errs.map(|e| format!("{e}")).collect(); + panic!("diagnose output failed schema validation: {msgs:?}"); + } + } +} diff --git a/crates/ghook/src/envelope.rs b/crates/ghook/src/envelope.rs new file mode 100644 index 0000000..74ba55f --- /dev/null +++ b/crates/ghook/src/envelope.rs @@ -0,0 +1,138 @@ +//! Inbox envelope schema v1. +//! +//! The envelope is what ghook enqueues to `~/.gobby/hooks/inbox/` and what +//! the daemon drain replays. Schema is frozen at v1 and validated in tests +//! against `schemas/inbox-envelope.v1.schema.json`. +//! +//! Omitted headers (no project id, no session id) are absent from the +//! `headers` object — never emitted as empty strings. This matches the +//! dispatcher's `_context_headers.setdefault` behavior where the key simply +//! isn't inserted (`hook_dispatcher.py:695-700`). + +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; + +pub const SCHEMA_VERSION: u32 = 1; + +/// Inbox-envelope schema v1. +/// +/// Field order follows the schema. `headers` is serialized as a plain +/// object; absent headers are not keys. `input_data` is the original stdin +/// payload verbatim (with `terminal_context` injected when applicable). +#[derive(Debug, Serialize)] +pub struct Envelope { + pub schema_version: u32, + pub enqueued_at: String, + pub critical: bool, + pub hook_type: String, + pub input_data: Value, + pub source: String, + pub headers: BTreeMap, +} + +impl Envelope { + pub fn new( + critical: bool, + hook_type: String, + input_data: Value, + source: String, + headers: BTreeMap, + ) -> Self { + Self { + schema_version: SCHEMA_VERSION, + enqueued_at: chrono::Utc::now().to_rfc3339(), + critical, + hook_type, + input_data, + source, + headers, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn example_envelope() -> Envelope { + let mut headers = BTreeMap::new(); + headers.insert("X-Gobby-Project-Id".into(), "proj-123".into()); + headers.insert("X-Gobby-Session-Id".into(), "sess-abc".into()); + Envelope::new( + true, + "session-start".into(), + json!({"session_id": "sess-abc"}), + "claude".into(), + headers, + ) + } + + #[test] + fn envelope_serializes_with_expected_fields() { + let env = example_envelope(); + let v: Value = serde_json::to_value(&env).unwrap(); + assert_eq!(v["schema_version"], 1); + assert_eq!(v["critical"], true); + assert_eq!(v["hook_type"], "session-start"); + assert_eq!(v["source"], "claude"); + assert_eq!(v["headers"]["X-Gobby-Project-Id"], "proj-123"); + assert_eq!(v["headers"]["X-Gobby-Session-Id"], "sess-abc"); + assert_eq!(v["input_data"]["session_id"], "sess-abc"); + assert!(v["enqueued_at"].as_str().unwrap().contains('T')); + } + + #[test] + fn empty_headers_serialize_as_empty_object() { + let env = Envelope::new( + false, + "session-end".into(), + json!({}), + "claude".into(), + BTreeMap::new(), + ); + let v: Value = serde_json::to_value(&env).unwrap(); + assert!(v["headers"].is_object()); + assert_eq!(v["headers"].as_object().unwrap().len(), 0); + } + + #[test] + fn envelope_validates_against_v1_schema() { + let schema_bytes = include_bytes!("../schemas/inbox-envelope.v1.schema.json"); + let schema: Value = serde_json::from_slice(schema_bytes).unwrap(); + let compiled = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema) + .expect("schema compiles"); + let env = example_envelope(); + let instance = serde_json::to_value(&env).unwrap(); + let result = compiled.validate(&instance); + if let Err(errors) = result { + let errs: Vec<_> = errors.map(|e| format!("{e}")).collect(); + panic!("envelope failed schema validation: {errs:?}"); + } + } + + #[test] + fn envelope_without_headers_validates_against_v1_schema() { + let schema_bytes = include_bytes!("../schemas/inbox-envelope.v1.schema.json"); + let schema: Value = serde_json::from_slice(schema_bytes).unwrap(); + let compiled = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema) + .expect("schema compiles"); + let env = Envelope::new( + false, + "pre-tool-use".into(), + json!({"tool_name": "Read"}), + "claude".into(), + BTreeMap::new(), + ); + let instance = serde_json::to_value(&env).unwrap(); + if let Err(errors) = compiled.validate(&instance) { + let errs: Vec<_> = errors.map(|e| format!("{e}")).collect(); + panic!("envelope failed schema validation: {errs:?}"); + } + } +} diff --git a/crates/ghook/src/main.rs b/crates/ghook/src/main.rs new file mode 100644 index 0000000..a298b5e --- /dev/null +++ b/crates/ghook/src/main.rs @@ -0,0 +1,262 @@ +//! ghook — sandbox-tolerant hook dispatcher. +//! +//! Three modes: +//! `ghook --gobby-owned --cli= --type= [--critical] [--detach]` +//! `ghook --diagnose --cli= --type=` +//! `ghook --version` +//! +//! Mode 1 enqueues an envelope to `~/.gobby/hooks/inbox/` and attempts a +//! POST to the daemon. On 2xx, the envelope is deleted; otherwise it +//! persists and the drain replays it. Exit codes: +//! 0 — success or non-critical failure (enqueued) +//! 2 — critical failure (enqueued) +//! +//! Mode 2 prints a JSON diagnostic, no network, no envelope write. +//! +//! Mode 3 prints the ghook version and writes +//! `~/.gobby/bin/.ghook-compatibility` with `{schema_version, ghook_version}`. + +use anyhow::Result; +use clap::Parser; +use serde_json::Value; +use std::collections::BTreeMap; +use std::io::Read; +use std::path::PathBuf; +use std::process::ExitCode; + +mod cli_config; +mod detach; +mod diagnose; +mod envelope; +mod terminal_context; +mod transport; + +use cli_config::CliConfig; +use envelope::Envelope; + +#[derive(Parser, Debug)] +#[command( + name = "ghook", + about = "Gobby sandbox-tolerant hook dispatcher", + disable_version_flag = true +)] +struct Args { + /// Normal hook-invocation mode. Required for enqueue/POST. + #[arg(long)] + gobby_owned: bool, + + /// Print diagnostic JSON for the given cli/type, then exit. + #[arg(long)] + diagnose: bool, + + /// Print version and write ~/.gobby/bin/.ghook-compatibility stamp. + #[arg(long)] + version: bool, + + /// Host CLI name (claude, codex, gemini, qwen). + #[arg(long)] + cli: Option, + + /// Hook type (e.g. session-start, SessionStart, PreToolUse). + #[arg(long = "type")] + hook_type: Option, + + /// Mark this hook as critical — failure exits 2 (signals host CLI). + #[arg(long)] + critical: bool, + + /// Detach from the parent's session/process group before the POST. + #[arg(long)] + detach: bool, +} + +fn main() -> ExitCode { + let args = Args::parse(); + + if args.version { + return match write_compatibility_stamp() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + // Still print the version; stamp-write failure is non-fatal. + println!("ghook {}", diagnose::GHOOK_VERSION); + eprintln!("note: could not write compatibility stamp: {e}"); + ExitCode::SUCCESS + } + }; + } + if args.diagnose { + return run_diagnose(&args); + } + if args.gobby_owned { + return run_gobby_owned(&args); + } + + eprintln!("ghook: no mode specified; use one of --gobby-owned, --diagnose, or --version"); + ExitCode::from(2) +} + +fn run_diagnose(args: &Args) -> ExitCode { + let (Some(cli), Some(hook_type)) = (args.cli.as_deref(), args.hook_type.as_deref()) else { + eprintln!("--diagnose requires --cli and --type"); + return ExitCode::from(2); + }; + let out = diagnose::diagnose(cli, hook_type); + match serde_json::to_string_pretty(&out) { + Ok(s) => { + println!("{s}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("diagnose serialization failed: {e}"); + ExitCode::from(2) + } + } +} + +fn run_gobby_owned(args: &Args) -> ExitCode { + let (Some(cli), Some(hook_type)) = (args.cli.as_deref(), args.hook_type.as_deref()) else { + eprintln!("--gobby-owned requires --cli and --type"); + return if args.critical { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + }; + }; + + // IMPORTANT: walk up for project context BEFORE any detach. + // Sandbox FS-read denials or a detached process's cwd semantics on + // macOS would otherwise surprise us (plan :76). + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let project_root = gobby_core::project::find_project_root(&cwd); + let project_id = project_root + .as_ref() + .and_then(|r| gobby_core::project::read_project_id(r).ok()); + + // Read stdin before detach too — detach closes the controlling TTY but + // stdin pipes from the host CLI should still be intact; read now to + // avoid late-read surprises if the host closes the pipe on exit. + let mut stdin_raw = Vec::with_capacity(4096); + let read_ok = std::io::stdin().read_to_end(&mut stdin_raw).is_ok(); + + // Parse. On malformed stdin: quarantine + exit (0 or 2). + let parsed: Result = if read_ok && !stdin_raw.is_empty() { + serde_json::from_slice(&stdin_raw) + } else { + // Empty stdin is treated as an empty object — dispatcher accepts + // missing input_data and continues (`:677` with empty raw parses + // as null, which fails there; here we normalize to {} for the + // non-critical case). + Ok(Value::Object(Default::default())) + }; + + let mut input_data = match parsed { + Ok(v) => v, + Err(e) => { + if let Err(werr) = + transport::quarantine_malformed(&stdin_raw, &e.to_string(), args.critical) + { + eprintln!("quarantine write failed: {werr}"); + } + return if args.critical { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + }; + } + }; + + // Recognize the CLI; unknown CLI is tolerated (use a conservative + // fallback) so hook scripts written for future CLIs don't break. + let cfg = CliConfig::for_cli(cli); + + // Terminal-context enrichment, gated by per-CLI set. + if let Some(c) = &cfg + && c.wants_terminal_context(hook_type) + { + terminal_context::inject(&mut input_data); + } + + // Headers: omit on missing (never empty string). + let mut headers: BTreeMap = BTreeMap::new(); + if let Some(pid) = project_id { + headers.insert("X-Gobby-Project-Id".into(), pid); + } + if let Some(sid) = input_data.get("session_id").and_then(|v| v.as_str()) + && !sid.is_empty() + { + headers.insert("X-Gobby-Session-Id".into(), sid.to_string()); + } + + let source = cfg + .as_ref() + .map(|c| c.source.to_string()) + .unwrap_or_else(|| cli.to_string()); + + let env = Envelope::new( + args.critical, + hook_type.to_string(), + input_data, + source, + headers, + ); + + // Enqueue first (atomic write to ~/.gobby/hooks/inbox/). + let inbox = match transport::inbox_dir() { + Ok(d) => d, + Err(e) => { + eprintln!("resolve inbox dir: {e}"); + return if args.critical { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + }; + } + }; + let enqueued_path = match transport::enqueue_to(&env, &inbox) { + Ok(p) => p, + Err(e) => { + eprintln!("enqueue failed: {e}"); + return if args.critical { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + }; + } + }; + + // Detach *after* project walk-up and enqueue — the file on disk is + // now the source of truth even if we die mid-POST. + if args.detach { + detach::detach(); + } + + // Best-effort POST. Enqueue file is deleted on 2xx; otherwise kept. + let daemon_url = gobby_core::daemon_url::daemon_url(); + let outcome = transport::post_and_cleanup(&env, &enqueued_path, &daemon_url); + + match outcome { + transport::DeliveryOutcome::Delivered => ExitCode::SUCCESS, + transport::DeliveryOutcome::Enqueued => { + if args.critical { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + } + } + } +} + +fn write_compatibility_stamp() -> Result<()> { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory"))?; + let bin_dir = home.join(".gobby").join("bin"); + std::fs::create_dir_all(&bin_dir)?; + let stamp_path = bin_dir.join(".ghook-compatibility"); + let stamp = serde_json::json!({ + "schema_version": envelope::SCHEMA_VERSION, + "ghook_version": diagnose::GHOOK_VERSION, + }); + let bytes = serde_json::to_vec_pretty(&stamp)?; + transport::atomic_write(&stamp_path, &bytes)?; + println!("ghook {}", diagnose::GHOOK_VERSION); + Ok(()) +} diff --git a/crates/ghook/src/terminal_context.rs b/crates/ghook/src/terminal_context.rs new file mode 100644 index 0000000..be4d36b --- /dev/null +++ b/crates/ghook/src/terminal_context.rs @@ -0,0 +1,201 @@ +//! Terminal/process context enrichment. +//! +//! Port of `hook_dispatcher.py:181-223` — captures the caller's PID, TTY, +//! tmux pane, `TERM_PROGRAM`, and `GOBBY_*` env vars so the daemon can +//! reconcile spawned-terminal agents on session-start. +//! +//! Sharp edge (dispatcher `:205`): `TMUX_PANE` is inherited by children +//! spawned into *other* terminals (e.g. Ghostty), so emitting it when +//! `TMUX` is not set would point `kill_agent` at the *parent's* pane. We +//! emit `tmux_pane`/`tmux_socket_path` only when `TMUX` is present in the +//! environment. + +use serde_json::{Value, json}; +use std::env; + +/// Build a terminal-context object for injection under +/// `input_data.terminal_context`. +pub fn capture() -> Value { + let parent_pid = parent_pid_or_null(); + let tty = tty_name_or_null(); + let (tmux_pane, tmux_socket_path) = tmux_fields(); + let term_program = env_or_null("TERM_PROGRAM"); + + json!({ + "parent_pid": parent_pid, + "tty": tty, + "tmux_pane": tmux_pane, + "tmux_socket_path": tmux_socket_path, + "term_program": term_program, + "gobby_session_id": env_or_null("GOBBY_SESSION_ID"), + "gobby_parent_session_id": env_or_null("GOBBY_PARENT_SESSION_ID"), + "gobby_agent_run_id": env_or_null("GOBBY_AGENT_RUN_ID"), + "gobby_project_id": env_or_null("GOBBY_PROJECT_ID"), + "gobby_workflow_name": env_or_null("GOBBY_WORKFLOW_NAME"), + }) +} + +/// Inject terminal context into `input_data` when: +/// (a) `input_data` is a JSON object, AND +/// (b) no `terminal_context` key is already present (mirror Python's +/// `setdefault` — dispatcher `:682`). +pub fn inject(input_data: &mut Value) { + if let Some(obj) = input_data.as_object_mut() + && !obj.contains_key("terminal_context") + { + obj.insert("terminal_context".into(), capture()); + } +} + +fn env_or_null(key: &str) -> Value { + match env::var(key) { + Ok(v) => Value::String(v), + Err(_) => Value::Null, + } +} + +fn parent_pid_or_null() -> Value { + // getppid is infallible on all supported targets; no Windows port here, + // but std::process::id lacks a parent-pid equivalent so we call libc. + #[cfg(unix)] + { + // SAFETY: libc::getppid has no preconditions and cannot fail. + let pid = unsafe { libc::getppid() }; + Value::from(pid as i64) + } + #[cfg(windows)] + { + // Windows lacks a direct parent-PID syscall without snapshotting — + // dispatcher's `os.getppid()` is a Unix concept. Emit null rather + // than fabricate a value; the daemon treats null as "unknown". + Value::Null + } +} + +fn tty_name_or_null() -> Value { + #[cfg(unix)] + { + // SAFETY: libc::ttyname is thread-hostile (returns a pointer into + // a static buffer), but we're single-threaded here and copy the + // bytes out before any other call could mutate the buffer. + unsafe { + let ptr = libc::ttyname(0); + if ptr.is_null() { + return Value::Null; + } + let cstr = std::ffi::CStr::from_ptr(ptr); + match cstr.to_str() { + Ok(s) => Value::String(s.to_owned()), + Err(_) => Value::Null, + } + } + } + #[cfg(windows)] + { + Value::Null + } +} + +fn tmux_fields() -> (Value, Value) { + // Per dispatcher :244 — only report tmux context when TMUX is set. + let Ok(tmux) = env::var("TMUX") else { + return (Value::Null, Value::Null); + }; + let pane = env::var("TMUX_PANE") + .map(Value::String) + .unwrap_or(Value::Null); + let socket = parse_tmux_socket_path(&tmux) + .map(Value::String) + .unwrap_or(Value::Null); + (pane, socket) +} + +/// Extract the socket path from the `TMUX` env var. Mirror of +/// `gobby.sessions.tmux_context.parse_tmux_socket_path` and the inline +/// copy at `hook_dispatcher.py:43-53`. +fn parse_tmux_socket_path(tmux_env: &str) -> Option { + let head = tmux_env.split(',').next()?.trim(); + if head.is_empty() { + None + } else { + Some(head.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_socket_path_extracts_leading_segment() { + assert_eq!( + parse_tmux_socket_path("/private/tmp/tmux-501/default,12345,0"), + Some("/private/tmp/tmux-501/default".into()) + ); + } + + #[test] + fn parse_socket_path_handles_empty() { + assert_eq!(parse_tmux_socket_path(""), None); + assert_eq!(parse_tmux_socket_path(",12,0"), None); + } + + #[test] + fn inject_sets_terminal_context_when_absent() { + let mut data = json!({"session_id": "s1"}); + inject(&mut data); + assert!(data.get("terminal_context").is_some()); + } + + #[test] + fn inject_respects_existing_terminal_context() { + let mut data = json!({ + "session_id": "s1", + "terminal_context": {"custom": "preserved"}, + }); + inject(&mut data); + assert_eq!(data["terminal_context"]["custom"], "preserved"); + assert!(data["terminal_context"].get("parent_pid").is_none()); + } + + #[test] + fn inject_no_op_on_non_object() { + let mut data = json!("not an object"); + inject(&mut data); + assert_eq!(data, json!("not an object")); + } + + #[test] + fn capture_emits_expected_keys() { + let ctx = capture(); + let obj = ctx.as_object().expect("object"); + for key in [ + "parent_pid", + "tty", + "tmux_pane", + "tmux_socket_path", + "term_program", + "gobby_session_id", + "gobby_parent_session_id", + "gobby_agent_run_id", + "gobby_project_id", + "gobby_workflow_name", + ] { + assert!(obj.contains_key(key), "missing key: {key}"); + } + } + + #[test] + fn tmux_fields_null_without_tmux_env() { + // Dispatcher :244 — no TMUX means no tmux_pane / tmux_socket_path. + // We probe the pure function via capture() and assert that when + // TMUX is unset in this test process, both fields are null. + // (CI runs without TMUX; local devs might differ, so we guard.) + if std::env::var_os("TMUX").is_none() { + let ctx = capture(); + assert_eq!(ctx["tmux_pane"], Value::Null); + assert_eq!(ctx["tmux_socket_path"], Value::Null); + } + } +} diff --git a/crates/ghook/src/transport.rs b/crates/ghook/src/transport.rs new file mode 100644 index 0000000..a53161b --- /dev/null +++ b/crates/ghook/src/transport.rs @@ -0,0 +1,259 @@ +//! Enqueue-first transport. +//! +//! Every invocation of `ghook --gobby-owned` writes an envelope to +//! `~/.gobby/hooks/inbox/

--.json` (atomic `tmp` → `fsync` → +//! rename) *before* attempting the daemon POST. On 2xx we delete the +//! inbox file; on any other outcome (timeout, connection refused, 5xx) we +//! leave it for the daemon's drain worker to replay. +//! +//! Filename shape (see plan Q4): +//! `--.json` +//! prefix = 'c' (critical) | 'n' (non-critical) +//! ts13 = 13-digit zero-padded milliseconds since epoch (lex-sort) +//! uuid = random v4 +//! `.tmp` suffix on the intermediate write — never a valid replay target. + +use crate::envelope::Envelope; +use anyhow::{Context, Result}; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const POST_TIMEOUT: Duration = Duration::from_secs(30); +const HOOKS_ENDPOINT: &str = "/api/hooks/execute"; + +/// Result of `enqueue_and_post` — the main CLI needs to know whether the +/// daemon ACKed (delete the inbox file and return early) or not (keep the +/// file; the drain will handle it). +#[derive(Debug)] +pub enum DeliveryOutcome { + /// Daemon returned 2xx — inbox file already deleted. + Delivered, + /// Daemon did not 2xx — inbox file persists for drain replay. + Enqueued, +} + +/// Compute the inbox directory (`~/.gobby/hooks/inbox/`). +pub fn inbox_dir() -> Result { + let home = dirs::home_dir().context("no home directory")?; + Ok(home.join(".gobby").join("hooks").join("inbox")) +} + +/// Compute the quarantine directory (`~/.gobby/hooks/inbox/quarantine/`). +pub fn quarantine_dir() -> Result { + Ok(inbox_dir()?.join("quarantine")) +} + +/// Zero-padded 13-digit ms-since-epoch timestamp for lex-sortable filenames. +pub fn ts13() -> String { + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("{ms:013}") +} + +/// Build the envelope filename for the given critical flag. +pub fn envelope_filename(critical: bool) -> String { + let prefix = if critical { 'c' } else { 'n' }; + let uuid = uuid::Uuid::new_v4(); + format!("{prefix}-{}-{uuid}.json", ts13()) +} + +/// Atomically write `bytes` to `final_path` via tmp + fsync + rename. +/// +/// Creates the parent directory if missing. The tmp file lives next to +/// the final path with a `.tmp` suffix — the drain ignores `*.tmp`. +pub fn atomic_write(final_path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = final_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create_dir_all {}", parent.display()))?; + } + let mut tmp = final_path.to_path_buf(); + let mut name = tmp + .file_name() + .context("final path has no file name")? + .to_owned(); + name.push(".tmp"); + tmp.set_file_name(name); + + { + let mut f = File::create(&tmp).with_context(|| format!("create tmp {}", tmp.display()))?; + f.write_all(bytes) + .with_context(|| format!("write tmp {}", tmp.display()))?; + f.sync_all() + .with_context(|| format!("fsync tmp {}", tmp.display()))?; + } + fs::rename(&tmp, final_path) + .with_context(|| format!("rename {} -> {}", tmp.display(), final_path.display()))?; + Ok(()) +} + +/// Serialize `envelope` to the given inbox directory and return the path. +/// +/// Caller can then call [`post_and_cleanup`] to attempt delivery. +pub fn enqueue_to(envelope: &Envelope, inbox: &Path) -> Result { + let name = envelope_filename(envelope.critical); + let path = inbox.join(&name); + let bytes = serde_json::to_vec_pretty(envelope)?; + atomic_write(&path, &bytes)?; + Ok(path) +} + +/// POST the envelope to the daemon. On 2xx, delete the inbox file and +/// return `Delivered`. On any other outcome, leave the file and return +/// `Enqueued`. +/// +/// `daemon_url` is the base URL (e.g. `http://127.0.0.1:60887`). The +/// endpoint path is appended here. +pub fn post_and_cleanup( + envelope: &Envelope, + enqueued_path: &Path, + daemon_url: &str, +) -> DeliveryOutcome { + let endpoint = format!("{daemon_url}{HOOKS_ENDPOINT}"); + let mut req = ureq::post(&endpoint) + .timeout(POST_TIMEOUT) + .set("Content-Type", "application/json"); + for (k, v) in &envelope.headers { + req = req.set(k, v); + } + + let body = match serde_json::to_string(envelope) { + Ok(s) => s, + Err(_) => return DeliveryOutcome::Enqueued, + }; + + match req.send_string(&body) { + Ok(resp) if (200..300).contains(&resp.status()) => { + let _ = fs::remove_file(enqueued_path); + DeliveryOutcome::Delivered + } + _ => DeliveryOutcome::Enqueued, + } +} + +/// Quarantine malformed stdin under the default `~/.gobby/hooks/inbox/quarantine/`. +/// Errors if the home directory cannot be resolved. +pub fn quarantine_malformed( + stdin_bytes: &[u8], + json_error: &str, + critical: bool, +) -> Result { + let dir = quarantine_dir()?; + quarantine_malformed_at(&dir, stdin_bytes, json_error, critical) +} + +/// Write a malformed-stdin quarantine envelope into `dir`. +/// +/// Writes two files atomically: +/// - `.json` — body containing base64 of the raw stdin bytes. +/// - `.meta.json` — sidecar with `reason`, `json_error`, `stdin_bytes_b64`. +/// +/// The drain never replays quarantined envelopes — they surface via +/// `gobby status` / logs. +pub fn quarantine_malformed_at( + dir: &Path, + stdin_bytes: &[u8], + json_error: &str, + critical: bool, +) -> Result { + use base64::Engine; + + let prefix = if critical { 'c' } else { 'n' }; + let ts = ts13(); + let uuid = uuid::Uuid::new_v4(); + let stem = format!("{prefix}-{ts}-{uuid}"); + let body_path = dir.join(format!("{stem}.json")); + let meta_path = dir.join(format!("{stem}.meta.json")); + + fs::create_dir_all(dir).with_context(|| format!("create_dir_all {}", dir.display()))?; + + let b64 = base64::engine::general_purpose::STANDARD.encode(stdin_bytes); + let body = serde_json::json!({ + "quarantined": true, + "stdin_bytes_b64": b64, + }); + atomic_write(&body_path, &serde_json::to_vec_pretty(&body)?)?; + + let meta = serde_json::json!({ + "reason": "malformed_stdin", + "json_error": json_error, + "stdin_bytes_b64": b64, + }); + atomic_write(&meta_path, &serde_json::to_vec_pretty(&meta)?)?; + Ok(body_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use tempfile::tempdir; + + #[test] + fn ts13_is_13_digits() { + let s = ts13(); + assert_eq!(s.len(), 13); + assert!(s.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn filename_prefix_reflects_critical() { + assert!(envelope_filename(true).starts_with('c')); + assert!(envelope_filename(false).starts_with('n')); + } + + #[test] + fn atomic_write_creates_parent_dirs() { + let dir = tempdir().unwrap(); + let nested = dir.path().join("a/b/c/out.json"); + atomic_write(&nested, b"{}").unwrap(); + assert!(nested.exists()); + assert_eq!(fs::read(&nested).unwrap(), b"{}"); + } + + #[test] + fn atomic_write_leaves_no_tmp_on_success() { + let dir = tempdir().unwrap(); + let path = dir.path().join("ok.json"); + atomic_write(&path, b"{}").unwrap(); + let tmp = dir.path().join("ok.json.tmp"); + assert!(!tmp.exists()); + } + + #[test] + fn enqueue_writes_envelope_to_inbox() { + let dir = tempdir().unwrap(); + let env = Envelope::new( + true, + "session-start".into(), + serde_json::json!({"session_id":"s"}), + "claude".into(), + BTreeMap::new(), + ); + let path = enqueue_to(&env, dir.path()).unwrap(); + assert!(path.exists()); + let name = path.file_name().unwrap().to_str().unwrap(); + assert!(name.starts_with('c')); + assert!(name.ends_with(".json")); + assert!(!name.ends_with(".tmp.json")); + } + + #[test] + fn quarantine_writes_pair() { + let dir = tempdir().unwrap(); + let body = + quarantine_malformed_at(dir.path(), b"not json", "expected value", false).unwrap(); + let stem = body.file_stem().unwrap().to_str().unwrap().to_owned(); + let meta = body.with_file_name(format!("{stem}.meta.json")); + assert!(body.exists()); + assert!(meta.exists()); + let meta_val: serde_json::Value = + serde_json::from_slice(&fs::read(&meta).unwrap()).unwrap(); + assert_eq!(meta_val["reason"], "malformed_stdin"); + assert_eq!(meta_val["json_error"], "expected value"); + assert!(meta_val["stdin_bytes_b64"].is_string()); + } +} diff --git a/crates/gsqz/Cargo.toml b/crates/gsqz/Cargo.toml index af7a7e4..9a4adb9 100644 --- a/crates/gsqz/Cargo.toml +++ b/crates/gsqz/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gobby-squeeze" -version = "0.4.0" +version = "0.4.1" edition = "2024" rust-version = "1.85" authors = ["Josh Wilhelmi "] diff --git a/crates/gsqz/src/compressor.rs b/crates/gsqz/src/compressor.rs index 344c381..c453eb3 100644 --- a/crates/gsqz/src/compressor.rs +++ b/crates/gsqz/src/compressor.rs @@ -18,6 +18,19 @@ impl CompressionResult { } (1.0 - self.compressed_chars as f64 / self.original_chars as f64) * 100.0 } + + /// True when no useful compression occurred — original output should be + /// surfaced verbatim, with no `[Output compressed by gsqz — …]` header + /// and no daemon savings report. Covers: + /// - `passthrough` — output too short or fallback couldn't help. + /// - `excluded` — command matched an exclusion regex. + /// - `*/no-op` — a pipeline matched but adding the low-savings marker + /// would itself have grown the output, so we kept the original. + pub fn is_passthrough(&self) -> bool { + self.strategy_name == "passthrough" + || self.strategy_name == "excluded" + || self.strategy_name.ends_with("/no-op") + } } struct CompiledPipeline { @@ -148,13 +161,22 @@ impl Compressor { // Named pipeline with low savings gets [gsqz:low-savings] marker. // Fallback path already has [gsqz:passthrough] marker, so just falls through. + // Skip the marker if adding it would make the output larger than the original. if compressed_chars >= (original_chars * 95) / 100 && matched { let marked = format!("[gsqz:low-savings]\n{}", compressed); + if marked.len() < original_chars { + return CompressionResult { + compressed: marked.clone(), + original_chars, + compressed_chars: marked.len(), + strategy_name: format!("{}/low-savings", strategy_name), + }; + } return CompressionResult { - compressed: marked.clone(), + compressed: output.to_string(), original_chars, - compressed_chars: marked.len(), - strategy_name: format!("{}/low-savings", strategy_name), + compressed_chars: original_chars, + strategy_name: format!("{}/no-op", strategy_name), }; } @@ -413,7 +435,44 @@ mod tests { #[test] fn test_low_savings_pipeline_gets_marker() { - // A named pipeline that barely compresses should get [gsqz:low-savings] + // A named pipeline that compresses just a little (still above the 95% threshold) + // gets the [gsqz:low-savings] marker as long as the marker fits under the original. + let mut config = test_config(); + config.settings.min_output_length = 0; + config.pipelines.insert( + "minimal-pipeline".into(), + crate::config::Pipeline { + match_pattern: r"\bminimal\b".into(), + steps: vec![crate::config::Step::FilterLines( + crate::config::FilterLinesArgs { + patterns: vec!["^drop ".into()], + }, + )], + on_empty: None, + }, + ); + let compressor = Compressor::new(&config); + // ~100 keep lines + 3 drop lines — filter removes ~3% of bytes, under the + // 5% savings threshold, but the savings are enough that the ~20-byte marker + // still fits under the original size. + let mut lines: Vec = (0..100) + .map(|i| format!("keep line number {}\n", i)) + .collect(); + lines.push("drop this entire line\n".into()); + lines.push("drop this entire line\n".into()); + lines.push("drop this entire line\n".into()); + let output = lines.join(""); + let result = compressor.compress("minimal", &output); + assert!(result.strategy_name.contains("low-savings")); + assert!(result.compressed.starts_with("[gsqz:low-savings]\n")); + assert!(result.compressed_chars < result.original_chars); + } + + #[test] + fn test_low_savings_suppressed_when_marker_would_grow_output() { + // A named pipeline that produces zero savings should NOT get the marker — + // adding it would make the output larger than the original. Return the + // original unchanged with a {pipeline}/no-op strategy name instead. let mut config = test_config(); config.settings.min_output_length = 0; config.pipelines.insert( @@ -429,8 +488,32 @@ mod tests { .map(|i| format!("unique line {}\n", i)) .collect::(); let result = compressor.compress("noop", &output); - assert!(result.strategy_name.contains("low-savings")); - assert!(result.compressed.starts_with("[gsqz:low-savings]\n")); + assert_eq!(result.strategy_name, "noop-pipeline/no-op"); + assert!(!result.compressed.contains("[gsqz:low-savings]")); + assert_eq!(result.compressed, output); + assert_eq!(result.compressed_chars, result.original_chars); + // /no-op is a passthrough — main.rs uses this to skip the outer header. + assert!(result.is_passthrough()); + } + + #[test] + fn test_is_passthrough_classification() { + let mk = |name: &str| CompressionResult { + compressed: String::new(), + original_chars: 0, + compressed_chars: 0, + strategy_name: name.into(), + }; + // Pure passthrough cases — main.rs surfaces output verbatim. + assert!(mk("passthrough").is_passthrough()); + assert!(mk("excluded").is_passthrough()); + assert!(mk("git-mutation/no-op").is_passthrough()); + assert!(mk("cargo-test/no-op").is_passthrough()); + // Real compression — main.rs prepends the header and reports to daemon. + assert!(!mk("git-status").is_passthrough()); + assert!(!mk("cargo-test/low-savings").is_passthrough()); + assert!(!mk("pytest/on_empty").is_passthrough()); + assert!(!mk("fallback").is_passthrough()); } #[test] diff --git a/crates/gsqz/src/main.rs b/crates/gsqz/src/main.rs index 5bfc77e..f9bfeda 100644 --- a/crates/gsqz/src/main.rs +++ b/crates/gsqz/src/main.rs @@ -238,8 +238,7 @@ fn run_output_mode(cmd: &str, config: &Config, stats: bool) { } // Report savings to daemon (best-effort) - if result.strategy_name != "passthrough" - && result.strategy_name != "excluded" + if !result.is_passthrough() && let Some(ref url) = daemon_url { daemon::report_savings( @@ -250,16 +249,15 @@ fn run_output_mode(cmd: &str, config: &Config, stats: bool) { ); } - let output_str = if result.strategy_name != "passthrough" && result.strategy_name != "excluded" - { + let output_str = if result.is_passthrough() { + result.compressed + } else { format!( "[Output compressed by gsqz — {}, {:.0}% reduction]\n{}", result.strategy_name, result.savings_pct(), result.compressed ) - } else { - result.compressed }; print!("{}", output_str); diff --git a/crates/gsqz/src/primitives/group.rs b/crates/gsqz/src/primitives/group.rs index 24316d3..eb416cd 100644 --- a/crates/gsqz/src/primitives/group.rs +++ b/crates/gsqz/src/primitives/group.rs @@ -370,7 +370,7 @@ fn group_by_extension(lines: Vec) -> Vec { // Sort by count descending let mut sorted: Vec<_> = groups.into_iter().collect(); - sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + sorted.sort_by_key(|b| std::cmp::Reverse(b.1.len())); let mut result = Vec::new(); for (ext, files) in &sorted { @@ -411,7 +411,7 @@ fn group_by_directory(lines: Vec) -> Vec { } let mut sorted: Vec<_> = groups.into_iter().collect(); - sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + sorted.sort_by_key(|b| std::cmp::Reverse(b.1.len())); let mut result = Vec::new(); for (dirname, files) in &sorted { diff --git a/docs/guides/gcode-development-guide.md b/docs/guides/gcode-development-guide.md index a6c2b89..2e18a33 100644 --- a/docs/guides/gcode-development-guide.md +++ b/docs/guides/gcode-development-guide.md @@ -38,6 +38,8 @@ On exit, `semantic::shutdown()` explicitly drops the embedding model to prevent 3. Otherwise, walk up from cwd looking for `.gobby/project.json` (Gobby-managed) or `.gobby/gcode.json` (standalone) 4. Fall back to VCS root markers (`.git`, `.hg`, `.svn`) or cwd +The walk-up and `project.json` reading steps use `gobby_core::project::find_project_root` and `gobby_core::project::read_project_id` (extracted from `gcode/src/project.rs` so `ghook` and other binaries can share the same logic). Bootstrap-config and daemon-URL helpers also come from `gobby-core`. See [gobby-core Development Guide](gcore-development-guide.md) for the shared API. + ### Database Path Selection 1. Check `~/.gobby/bootstrap.yaml` for `database_path` key diff --git a/docs/guides/gcore-development-guide.md b/docs/guides/gcore-development-guide.md new file mode 100644 index 0000000..00f9de4 --- /dev/null +++ b/docs/guides/gcore-development-guide.md @@ -0,0 +1,170 @@ +# gobby-core Development Guide + +Technical internals for developers and agents working in the `gobby-core` crate (`crates/gcore/`). + +## What gobby-core Is + +`gobby-core` is a small, dependency-light shared-primitives crate consumed by every Gobby CLI binary (`gcode`, `gsqz`, `gloc`, `ghook`). It exists so the binaries don't reimplement the same project-discovery and daemon-addressing logic four times — and so a behavior change (e.g. how the daemon URL is normalized) propagates with one PR instead of four. + +It has no CLI. It has no public state. It's a library — that's the whole shape. + +## Module Map + +`crates/gcore/src/`: + +| Module | Responsibility | +|--------|----------------| +| `project` | Walk up from a starting directory to find a `.gobby/` directory containing `project.json` or `gcode.json`. Read the `id` (or legacy `project_id`) field from `project.json`. | +| `bootstrap` | Read `~/.gobby/bootstrap.yaml` to get the daemon's listen endpoint (`bind_host`, `daemon_port`). Falls back to `127.0.0.1:60887` when the file is missing or malformed. | +| `daemon_url` | Compose a dial URL from a `DaemonEndpoint`, normalizing wildcard listen addresses (`0.0.0.0`, `::`, `::0`) to `127.0.0.1`. | + +Roughly 250 lines of source total. Adding a fourth module should require justification. + +## Public API + +### `project` + +```rust +pub fn find_project_root(start: &Path) -> Option; +pub fn read_project_id(project_root: &Path) -> anyhow::Result; +``` + +`find_project_root` walks up from `start` looking for a `.gobby/project.json` (Gobby-managed) or `.gobby/gcode.json` (gcode-standalone). Returns the directory *containing* `.gobby/`, not `.gobby/` itself. Returns `None` when neither marker is found before hitting the filesystem root. + +`read_project_id` reads `/.gobby/project.json` and extracts the `id` field, falling back to the legacy `project_id` key. Errors if the file is missing, malformed, or the field isn't present. + +```rust +let cwd = std::env::current_dir()?; +if let Some(root) = gobby_core::project::find_project_root(&cwd) { + let id = gobby_core::project::read_project_id(&root)?; + println!("project {id} at {}", root.display()); +} +``` + +### `bootstrap` + +```rust +pub const DEFAULT_DAEMON_PORT: u16 = 60887; +pub const DEFAULT_BIND_HOST: &str = "127.0.0.1"; + +pub struct DaemonEndpoint { pub host: String, pub port: u16 } + +pub fn bootstrap_path() -> Option; +pub fn read_daemon_endpoint() -> DaemonEndpoint; +pub fn read_daemon_endpoint_at(path: &Path) -> DaemonEndpoint; +``` + +`read_daemon_endpoint` is the lookup callers want. `read_daemon_endpoint_at` exists for tests and for callers who already know the path. Both return `DaemonEndpoint::default()` (loopback + 60887) on any failure — missing file, unreadable, malformed YAML, missing fields, no home directory. **No errors are surfaced**; clients should always get *something* usable. + +`DaemonEndpoint` returns the raw endpoint as written. `0.0.0.0` and `::` are valid listen addresses but invalid dial addresses — normalization is the caller's job, or the `daemon_url` module's, not this one's. + +### `daemon_url` + +```rust +pub fn daemon_url() -> String; +pub fn daemon_url_at(path: &Path) -> String; +``` + +Composes `http://{host}:{port}` from a bootstrap-derived endpoint, with one rewrite: wildcard listen hosts (`0.0.0.0`, `::`, `::0`) become `127.0.0.1`. Hostnames, named interfaces, and explicit IPv4/IPv6 literals pass through unchanged. + +```rust +let url = gobby_core::daemon_url::daemon_url(); +// "http://127.0.0.1:60887" for default bootstrap +// "http://10.0.0.5:61234" if bootstrap has bind_host: 10.0.0.5 +// "http://127.0.0.1:60887" if bootstrap has bind_host: 0.0.0.0 +ureq::post(&format!("{url}/api/hooks/execute")).send_string(body)?; +``` + +Bracketing IPv6 literals for URL embedding is **not** handled here — in practice `bootstrap.yaml` is always `localhost`, an IPv4 literal, or a wildcard. If that ever stops being true, this is the place to add it. + +## Why These Three Modules Specifically + +Each module exists because at least two binaries need exactly this logic, and getting it slightly wrong in one of them would silently misbehave: + +| Module | Consumers (today) | What goes wrong if duplicated | +|--------|-------------------|-------------------------------| +| `project` | `gcode`, `ghook` (and `gsqz`/`gloc` could use it) | Project discovery walks up across mounts, weird symlink loops, race conditions with `.gobby/` creation. One implementation = one set of edge cases. | +| `bootstrap` | `gcode`, `ghook` | YAML field naming, fallback semantics. Easy for two implementations to disagree on whether a missing field is fatal. | +| `daemon_url` | `ghook` (and `gcode` daemon RPC) | Wildcard-host normalization is non-obvious. A binary that POSTs to `0.0.0.0` will hang for the connect timeout instead of failing fast. | + +## Versioning Policy + +`gobby-core` is `0.x`. The contract: + +- **Patch bumps (0.1.x)** — bug fixes, doc changes, internal refactors with no public API change. +- **Minor bumps (0.x.0)** — additive public API (new functions, new fields). Existing consumers stay compatible. +- **Pre-1.0 breaking changes** — bump the minor and bump *every* consumer crate's gobby-core dep in the same release. Don't strand consumers on an old gobby-core. + +Consumers pin to a minor version (`gobby-core = "0.1"`) so patch updates are picked up automatically but additive changes require a coordinated bump. + +## How to Consume + +### In-tree (workspace crates) + +```toml +[dependencies] +gobby-core = { path = "../gcore", version = "0.1" } +``` + +The `path` is for local workspace builds; `version` is required by `cargo publish` and gets used when consumers install the crate from crates.io. Don't drop the `version` field — `cargo publish` will reject the consumer's manifest. + +### Out-of-tree + +```toml +[dependencies] +gobby-core = "0.1" +``` + +Resolves against crates.io. The crate has no opinionated dependencies — `anyhow`, `dirs`, `serde_json`, `serde_yaml`, and `tempfile` (dev-only). It will not pull in tokio, reqwest, tracing, or anything else heavy. + +## Adding a New Helper + +Before adding a module or function to `gobby-core`, check: + +1. **Do at least two binaries need it?** If only one does, keep it in that binary. +2. **Is it dependency-light?** New deps in `gobby-core` propagate to *every* binary. Adding `tokio` here would 5x the binary size of `ghook` for zero benefit. If the helper needs heavy deps, it probably belongs in a separate shared crate. +3. **Is it stateless or near-stateless?** `gobby-core` functions are pure or do narrow I/O (read one file, return result). A module that holds connection pools or background workers belongs elsewhere. +4. **Is the public surface small?** Three functions + a `DaemonEndpoint` struct is the right order of magnitude. If you find yourself adding a builder, a config object, and an `init()` function, reconsider. + +If yes to all four, add the module: + +1. Create `crates/gcore/src/.rs` with `//!` module docs. +2. Add `pub mod ;` to `crates/gcore/src/lib.rs`. +3. Write tests that pin behavior under the failure modes the consumer cares about (missing input, malformed input, edge-case values). +4. Update this guide's module map. +5. Bump `gobby-core` to the next minor version (`0.2.0`) since you're adding public API. +6. Update consumer crates to use the new helper, replacing any duplicated implementation. Bump their versions too. + +## Testing + +Each module has `#[cfg(test)] mod tests` with `tempfile::tempdir()` for filesystem isolation: + +- **project**: implicitly tested via consumer binaries (`gcode`, `ghook`); the module mirrors `gcode/src/project.rs` line-for-line. +- **bootstrap**: missing/malformed/empty files all return defaults; custom port/host parsing; out-of-range port falls back to default. +- **daemon_url**: wildcard IPv4/IPv6 normalize to loopback; localhost passes through; custom host+port composes correctly. + +```bash +cargo test -p gobby-core +``` + +Fast, no I/O outside `tempdir()`, no network. Should run in well under a second. + +## Design Decisions + +### Why Infallible Defaults Instead of `Result` + +`read_daemon_endpoint` and friends return `DaemonEndpoint` (not `Result`). The reasoning: + +- Every consumer wants *some* endpoint to dial. Erroring at startup because `~/.gobby/bootstrap.yaml` doesn't exist would force every binary to handle the error identically (fall back to loopback + 60887). Centralizing that fallback here is the right move. +- The daemon defaults are well-known and stable. There's no "right" error message to surface — "use loopback" is always the answer. +- If a binary genuinely needs to know whether the file existed (e.g. for a setup-wizard prompt), it can call `bootstrap_path()` and `Path::exists()` directly. + +`read_project_id` *does* return `Result` because there's no sane default for "I asked for a project ID and there isn't one" — the caller has to decide what that means. + +### Why Listen-Address Normalization Lives in `daemon_url`, Not `bootstrap` + +`bootstrap` returns the raw endpoint as written so callers can distinguish "user configured `0.0.0.0` for LAN exposure" from "user configured `127.0.0.1`." `daemon_url` is the layer concerned with *dialing*, so that's where the rewrite happens. Diagnostic tooling that wants to display the actual `bind_host` (e.g. `ghook --diagnose`) reads from `bootstrap` directly. + +### Why Not Re-Export from a Prelude + +There's no `gobby_core::prelude`. The crate is small enough that explicit imports (`use gobby_core::project::find_project_root`) are clearer than a glob. Keep it that way until the public surface grows past ~10 items. diff --git a/docs/guides/ghook-development-guide.md b/docs/guides/ghook-development-guide.md new file mode 100644 index 0000000..c2a7f84 --- /dev/null +++ b/docs/guides/ghook-development-guide.md @@ -0,0 +1,299 @@ +# ghook Development Guide + +Technical internals for developers and agents working in the ghook codebase. + +## Architecture Overview + +```text +host AI CLI (Claude Code / Codex / Gemini / Qwen) + │ spawns: ghook --gobby-owned --cli= --type= [--critical] [--detach] + │ pipes: stdin = hook payload (JSON object) + ▼ +main.rs::run_gobby_owned + │ + ├─ project::find_project_root + read_project_id (gobby-core) + │ ── walk-up BEFORE any detach; sandbox-safe. + │ + ├─ stdin → serde_json::from_slice + │ ── on malformed: transport::quarantine_malformed → exit + │ + ├─ cli_config::CliConfig::for_cli(cli) + │ ── per-CLI critical/terminal_context registry + │ + ├─ terminal_context::inject (if cfg.wants_terminal_context) + │ + ├─ envelope::Envelope::new + │ + ├─ transport::enqueue_to(envelope, ~/.gobby/hooks/inbox/) + │ ── atomic write: tmp → fsync → rename + │ + ├─ detach::detach() (if --detach: setsid on Unix) + │ + └─ transport::post_and_cleanup + │ + ├─ POST {daemon_url}/api/hooks/execute (30s timeout) + ├─ 2xx → fs::remove_file(envelope) → ExitCode::SUCCESS + └─ failure → leave envelope → ExitCode::SUCCESS or 2 + (drain worker replays) +``` + +Spool-first ordering is load-bearing. The envelope is on disk before anything risky (network I/O, detach) happens, so the daemon's drain worker is the source of truth even if ghook dies mid-POST. + +### Why a Separate Binary? + +The original Python `hook_dispatcher.py` ran inside the daemon process. That made it sensitive to daemon downtime: hook → daemon socket → daemon process. ghook is a small standalone binary so: + +1. It can run when the daemon is dead. Envelopes spool to disk. +2. It can survive sandbox FS-read denials that would crash an embedded interpreter. +3. Host CLIs can invoke it from any context (including detached sessions). +4. Failure modes are constrained: writing one file, sending one HTTP request. + +## Module Map + +`crates/ghook/src/`: + +| Module | Responsibility | +|--------|----------------| +| `main.rs` | Arg parsing (clap), mode dispatch (`--gobby-owned`/`--diagnose`/`--version`), orchestrates the dispatch flow. | +| `cli_config.rs` | Per-CLI registry (claude/codex/gemini/qwen) — which hooks are critical, which want terminal context. Compile-time frozen. | +| `envelope.rs` | `Envelope` struct + `SCHEMA_VERSION = 1`. Serializes to the inbox JSON shape. | +| `transport.rs` | Inbox path resolution, atomic write, enqueue, POST + cleanup, quarantine for malformed stdin. | +| `terminal_context.rs` | Captures parent PID, TTY, tmux pane/socket, `TERM_PROGRAM`, `GOBBY_*` env vars. Injects under `input_data.terminal_context`. | +| `diagnose.rs` | `--diagnose` mode — pure introspection, no I/O side effects. Returns `DiagnoseOutput` matching `schemas/diagnose-output.v1.schema.json`. | +| `detach.rs` | Unix `setsid(2)` / Windows `FreeConsole()` — best-effort detach from controlling TTY and process group. | + +`crates/ghook/schemas/`: + +| File | Validated against in | +|------|----------------------| +| `inbox-envelope.v1.schema.json` | `envelope::tests::envelope_validates_against_v1_schema` | +| `diagnose-output.v1.schema.json` | `diagnose::tests::diagnose_output_validates_against_v1_schema` | + +## Envelope Schema (v1) + +The envelope is what ghook writes to `~/.gobby/hooks/inbox/` and what the daemon's drain worker replays. Schema is frozen at v1 — consumers must reject unknown versions. + +```rust +pub struct Envelope { + pub schema_version: u32, // const 1 + pub enqueued_at: String, // RFC 3339 UTC + pub critical: bool, + pub hook_type: String, // host-CLI-specific + pub input_data: Value, // verbatim stdin + optional terminal_context + pub source: String, // "claude" / "codex" / "gemini" / "qwen" / passthrough + pub headers: BTreeMap, +} +``` + +### Field Semantics + +| Field | Why It Exists | +|-------|---------------| +| `schema_version` | Forward-compat. Daemon rejects unknown versions rather than parsing partial data. | +| `enqueued_at` | Lets the drain worker compute hook latency and detect very-stale envelopes. | +| `critical` | Recorded so the daemon knows whether the host CLI was told this hook fail-closed. Influences alerting. | +| `hook_type` | Opaque — exact identifier the host CLI's hook system uses (`session-start`, `PreToolUse`, etc.). | +| `input_data` | Original stdin verbatim. `terminal_context` is *injected* into the existing object (mirrors Python's `setdefault`) — never overwritten if already present. | +| `source` | Recognized CLI → canonical name from `CliConfig::source`. Unknown CLI → the `--cli` value verbatim, so future CLIs route correctly without code changes. | +| `headers` | Mirrors what ghook sent (or would have sent) on the POST. Omitted headers are absent keys; **empty-string values are never emitted** — this matches `hook_dispatcher.py:695-700` behavior and is enforced by the schema (`additionalProperties.minLength: 1`). | + +### Standard Headers + +| Header | When Present | Source | +|--------|--------------|--------| +| `X-Gobby-Project-Id` | Project root resolved AND `project.json` has an `id`/`project_id` field | `gobby_core::project::read_project_id` | +| `X-Gobby-Session-Id` | `input_data.session_id` is a non-empty string | `input_data["session_id"]` | + +Both are inserted only when non-empty. The schema enforces `minLength: 1` on header values to match. + +## Diagnose Output Schema (v1) + +`--diagnose` returns a JSON object validated against `schemas/diagnose-output.v1.schema.json`. It runs the same config-resolution code paths as `--gobby-owned` but stops short of any I/O side effect. + +```rust +pub struct DiagnoseOutput { + pub schema_version: u32, // const 1 + pub ghook_version: &'static str, // env!("CARGO_PKG_VERSION") + pub cli: String, + pub hook_type: String, + pub source: Option, // null if cli not recognized + pub critical: bool, // would this hook be critical for this CLI? + pub terminal_context_enabled: bool, + pub daemon_url: String, + pub daemon_host: String, + pub daemon_port: u16, + pub project_root: Option, + pub project_id: Option, + pub terminal_context_preview: Option, // populated when terminal_context_enabled + pub cli_recognized: bool, +} +``` + +The `terminal_context_preview` field is the actual context that *would* be injected — operators can inspect what the daemon will receive without sending a real hook. + +## Transport: Spool & POST + +**File:** `src/transport.rs` + +### Filename Shape + +```text +~/.gobby/hooks/inbox/--.json + └c│n┘ └13-digit ms┘ └v4┘ +``` + +| Field | Purpose | +|-------|---------| +| `prefix` | `c` for critical, `n` for non-critical. Lets the drain worker prioritize critical envelopes. | +| `ts13` | Zero-padded 13-digit ms-since-epoch. Lex-sortable, so drain order matches enqueue order even within the same second. | +| `uuid` | Random v4. Disambiguates simultaneous enqueues from concurrent hook fires. | + +`.tmp` suffix is reserved for the intermediate atomic-write stage. The drain ignores `*.tmp` — it's never a valid replay target. + +### Atomic Write + +```rust +atomic_write(final_path, bytes): + create_dir_all(parent) + tmp = final_path.with_suffix(".tmp") + File::create(tmp).write_all(bytes).sync_all() // fsync + fs::rename(tmp, final_path) // atomic on POSIX +``` + +`sync_all()` is critical — without it, `rename` makes the directory entry visible but the file's contents may not have hit disk. A crash between `rename` and the OS's deferred write would leave a zero-byte envelope that the drain would parse-fail on. + +### POST + Cleanup + +`post_and_cleanup` POSTs to `{daemon_url}/api/hooks/execute` with a 30-second timeout. The envelope's `headers` are mirrored as HTTP headers. On 2xx, the inbox file is deleted; otherwise it's left in place. + +The 30s timeout is deliberately generous — the daemon may be doing real work (DB writes, agent reconciliation). `--detach` is the escape hatch for hooks where the host CLI tears down its session before 30s. + +### Quarantine for Malformed Stdin + +When stdin can't be parsed as JSON, `quarantine_malformed_at` writes two files into `~/.gobby/hooks/inbox/quarantine/`: + +| File | Contents | +|------|----------| +| `.json` | `{"quarantined": true, "stdin_bytes_b64": "..."}` | +| `.meta.json` | `{"reason": "malformed_stdin", "json_error": "", "stdin_bytes_b64": "..."}` | + +The drain never replays quarantined envelopes — they surface via `gobby status` and daemon logs. Putting them under the inbox tree (rather than alongside) means they share the same disk-space-management story without polluting drain attempts. + +## Terminal Context + +**File:** `src/terminal_context.rs` + +Captures the caller's process context for hooks that need it (mainly `session-start`/`SessionStart`). Port of `hook_dispatcher.py:181-223`. + +| Field | Source | Notes | +|-------|--------|-------| +| `parent_pid` | `libc::getppid()` (Unix) / null (Windows) | The host CLI's PID — daemon uses this to reconcile spawned-terminal agents. | +| `tty` | `libc::ttyname(0)` | Controlling terminal device path. | +| `tmux_pane` | `TMUX_PANE` env var, **only if `TMUX` is set** | Sharp edge from dispatcher `:205` — `TMUX_PANE` is inherited by children spawned into *other* terminals (e.g. Ghostty), so emitting it without checking `TMUX` would point `kill_agent` at the parent's pane. | +| `tmux_socket_path` | First comma-separated segment of `TMUX` | Mirror of `gobby.sessions.tmux_context.parse_tmux_socket_path`. | +| `term_program` | `TERM_PROGRAM` env var | | +| `gobby_session_id`, `gobby_parent_session_id`, `gobby_agent_run_id`, `gobby_project_id`, `gobby_workflow_name` | Eponymous env vars | Set by the Gobby daemon when it spawns the host CLI; let us correlate hooks back to the spawning context. | + +`inject(input_data)` only adds `terminal_context` when: + +1. `input_data` is a JSON object (not an array, scalar, etc.). +2. The key isn't already present (`setdefault` semantics from dispatcher `:682`). + +This means a host CLI can pre-populate `terminal_context` and ghook will respect it. + +## Detach Semantics + +**File:** `src/detach.rs` + +`--detach` is requested for hooks where the host CLI exits very quickly after firing (e.g. `Stop`). On Unix it calls `setsid(2)` to escape the controlling terminal and the parent's process group — the host CLI can wait for ghook's exit code without ghook being killed when the host's session tears down. + +On Windows, `setsid` doesn't exist. `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` would be the parallel, but those flags apply at `CreateProcess` time, not to an already-running process. `FreeConsole()` is the closest honest equivalent — it disables console I/O for the current process. The function exists on Windows so callers don't need `cfg` checks, but it does less than its Unix counterpart. + +**Critical ordering** (`main.rs:127-128`): project-root walk-up happens *before* `detach::detach()`. macOS sandbox FS-read denials and the cwd semantics of detached processes would otherwise surprise us — the project root is captured while we still have the host CLI's full file-access context. + +## Critical vs Non-Critical Exit Semantics + +```text + | POST 2xx | POST failure | malformed stdin +------------------+-------------------+-------------------+------------------ +--critical | exit 0 (Delivered)| exit 2 (Enqueued) | exit 2 +no --critical | exit 0 (Delivered)| exit 0 (Enqueued) | exit 0 +``` + +`--critical` is set per-hook in the host CLI's `settings.json`. ghook treats it as opaque — the per-CLI registry in `cli_config.rs` describes what *should* be critical for diagnose purposes, but the actual exit-code decision is driven by the flag the host CLI passed. + +The envelope is enqueued in all cases (except malformed stdin, which goes to quarantine). `--critical` only changes whether ghook signals the host CLI to abort. + +## Testing + +### Unit Tests + +Each module has `#[cfg(test)] mod tests` with comprehensive coverage: + +- **envelope.rs**: serialization shape, schema validation against `inbox-envelope.v1.schema.json`, empty-headers serializing as empty object. +- **transport.rs**: 13-digit timestamp shape, filename prefix matches `critical`, atomic-write creates parents, no `.tmp` left on success, enqueue produces valid filename, quarantine pair structure. +- **diagnose.rs**: unknown CLI → not recognized + null source; known CLI/hook combos hit the right critical/terminal-context flags; schema validation for both recognized and unrecognized CLIs. +- **cli_config.rs**: per-CLI critical/terminal-context membership; case-insensitive CLI lookup; unknown CLIs return `None`. +- **terminal_context.rs**: tmux socket-path parsing edge cases, `inject` respects existing context, `inject` no-ops on non-objects, `capture` emits all expected keys. + +### Schema Validation in Tests + +Both schemas are validated end-to-end at test time using `jsonschema`: + +```rust +let schema_bytes = include_bytes!("../schemas/inbox-envelope.v1.schema.json"); +let schema: Value = serde_json::from_slice(schema_bytes).unwrap(); +let compiled = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema) + .expect("schema compiles"); +let instance = serde_json::to_value(&envelope).unwrap(); +compiled.validate(&instance)?; +``` + +This means changing the Rust struct without updating the schema (or vice versa) breaks the build — they're kept in lockstep by the test suite. + +### Running Tests + +```bash +cargo test -p gobby-hooks +``` + +No integration tests — ghook's I/O is contained (one file write, one HTTP POST), and both are covered by unit tests using `tempfile::tempdir()` and dummy daemon URLs. + +## Adding a New Host CLI + +The flow to support a new CLI (say, "cursor"): + +1. **Add a registry entry** in `cli_config.rs::CliConfig::for_cli`: + + ```rust + "cursor" => Some(Self { + source: "cursor", + critical_hooks: ["session-start"].into_iter().collect(), + terminal_context_hooks: ["session-start"].into_iter().collect(), + }), + ``` + +2. **Add a unit test** for the new entry in `cli_config.rs::tests`. + +3. **Add a diagnose test** in `diagnose.rs::tests` confirming the new CLI is recognized. + +4. **No envelope schema changes** — the schema is CLI-agnostic. `source` is just a string. + +5. **No transport changes** — same inbox, same daemon endpoint. + +Unknown CLIs are tolerated at runtime (`source` falls back to the literal `--cli` value), so a hook script written for a CLI ghook doesn't yet recognize will still spool envelopes — they just won't get terminal-context enrichment or per-CLI critical-hook handling. Adding a registry entry upgrades that path from "tolerated" to "first-class." + +## Adding a New Hook Type + +Almost always config-only. ghook treats `--type` as opaque. To make a hook critical or to enable terminal-context enrichment for it, add the hook type to the relevant set in the CLI's `CliConfig` entry. No envelope, transport, or schema changes required. + +## Versioning + +ghook is at `0.1.0`. `SCHEMA_VERSION` is also `1`. The two version numbers are independent: + +- **Crate version** bumps for any code change (binary behavior, dependencies, perf, etc.). +- **`SCHEMA_VERSION`** bumps only when the envelope shape changes in a way the daemon must explicitly handle. + +`--version` writes `~/.gobby/bin/.ghook-compatibility` with both numbers, so the daemon can detect mismatches at startup and refuse to drain envelopes from a future schema it doesn't understand. diff --git a/docs/guides/ghook-user-guide.md b/docs/guides/ghook-user-guide.md new file mode 100644 index 0000000..6e2ede1 --- /dev/null +++ b/docs/guides/ghook-user-guide.md @@ -0,0 +1,231 @@ +# ghook User Guide + +ghook is the sandbox-tolerant hook dispatcher Gobby uses to receive lifecycle and tool-use events from host AI CLIs (Claude Code, Codex, Gemini CLI, Qwen CLI). It enqueues an envelope to `~/.gobby/hooks/inbox/` *before* attempting to POST to the local Gobby daemon — so the daemon's drain worker can replay any envelope whose POST was lost to a sandbox FS-read denial, a network blip, or a daemon restart. + +You don't usually invoke ghook directly. The Gobby installer wires it into each host CLI's hook configuration. This guide explains what it does, how to verify it's working, and how to wire it manually if you need to. + +## Installation + +If you use [Gobby](https://github.com/GobbyAI/gobby), ghook is already installed and wired into your supported AI CLIs. + +Otherwise, install from a release binary or crates.io: + +```bash +cargo install gobby-hooks +``` + +The binary is named `ghook` (the package is `gobby-hooks` to disambiguate from singular use; the binary stays short). + +## How It Works + +```text +host AI CLI fires hook + └─ runs ghook --gobby-owned --cli= --type= + ├─ resolves project root (walk up from cwd to .gobby/project.json) + ├─ reads stdin (the host CLI's hook payload) + ├─ enriches input_data with terminal_context (when applicable) + ├─ writes envelope atomically to ~/.gobby/hooks/inbox/ + └─ POSTs envelope to the Gobby daemon + ├─ 2xx → delete inbox file, exit 0 + └─ failure → leave inbox file, exit 0 or 2 depending on --critical + └─ daemon's drain worker replays on next tick +``` + +Spool-first ordering is the whole point. If anything between ghook and the daemon goes wrong (sandbox FS denial, network blip, daemon restart), the envelope is already on disk and the daemon will pick it up on its next drain pass. From the host CLI's perspective the hook either succeeded or failed-loud (exit 2) — replay is invisible. + +## CLI Surface + +ghook has three modes. Exactly one must be selected. + +```text +ghook --gobby-owned --cli= --type= [--critical] [--detach] +ghook --diagnose --cli= --type= +ghook --version +``` + +| Flag | Mode | Purpose | +|------|------|---------| +| `--gobby-owned` | dispatch | Normal hook invocation. Reads stdin, enqueues, attempts POST. | +| `--diagnose` | introspection | Prints a JSON snapshot of what *would* happen. No network, no envelope write. | +| `--version` | metadata | Prints version and writes `~/.gobby/bin/.ghook-compatibility` for the daemon. | +| `--cli` | required for dispatch/diagnose | Host CLI name: `claude`, `codex`, `gemini`, `qwen`. Case-insensitive. | +| `--type` | required for dispatch/diagnose | Hook type. CLI-specific (e.g. `session-start` for Claude, `SessionStart` for Codex/Gemini/Qwen, `PreToolUse`, `PostToolUse`, `Stop`, `pre-compact`, `session-end`). | +| `--critical` | dispatch | Treat enqueue/POST failure as fatal — exit 2 to signal the host CLI. Default is exit 0 even on failure (envelope is still spooled). | +| `--detach` | dispatch | After enqueue and project-root walk-up, call `setsid(2)` to escape the host CLI's process group before the POST. Useful for hooks where the host CLI tears down its session immediately. | + +### Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Delivered (2xx) **OR** non-critical failure (envelope enqueued for replay). | +| `2` | Critical failure (envelope enqueued; signals the host CLI to abort). Used when `--critical` is set and POST fails. | + +The `2` exit signals the host CLI that the hook didn't deliver synchronously. The envelope is *still* enqueued — the daemon will replay it. `--critical` is only about whether ghook tells the host CLI "this hook didn't go through right now," not about whether the event is lost. + +## Wiring ghook into Claude Code + +Most users get this configured automatically by the Gobby installer. To wire it manually, add hook entries to your Claude Code `settings.json`: + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "ghook --gobby-owned --cli=claude --type=session-start --critical" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "ghook --gobby-owned --cli=claude --type=session-end --critical" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "ghook --gobby-owned --cli=claude --type=PreToolUse" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "ghook --gobby-owned --cli=claude --type=PostToolUse" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "ghook --gobby-owned --cli=claude --type=pre-compact --critical" + } + ] + } + ] + } +} +``` + +Claude Code uses lowercase-hyphenated names internally for some hooks (`session-start`, `pre-compact`, `session-end`) and PascalCase for others (`PreToolUse`, `PostToolUse`). ghook treats `--type` as an opaque string, so pass the exact identifier the daemon expects for that CLI. + +The `--critical` flag is on lifecycle hooks (`session-start`, `session-end`, `pre-compact`) because these set up state the daemon needs immediately. Tool-use hooks are non-critical — the envelope still spools, but a transient daemon outage won't block your tool call. + +### Codex, Gemini, Qwen + +Same pattern with different `--cli` and `--type` values. ghook's per-CLI registry (see `crates/ghook/src/cli_config.rs`) defines which hooks are critical and which receive enriched terminal context for each host CLI: + +| CLI | Critical hooks | Terminal-context hooks | +|-----|----------------|------------------------| +| `claude` | `session-start`, `session-end`, `pre-compact` | `session-start` | +| `codex` | `SessionStart`, `Stop` | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` | +| `gemini` | `SessionStart` | `SessionStart` | +| `qwen` | `SessionStart` | `SessionStart` | + +Unknown `--cli` values are tolerated — ghook uses the literal name as the source identifier and skips terminal-context enrichment. Hooks written for future CLIs won't break. + +## Diagnose Mode + +`ghook --diagnose` is the fastest way to confirm a hook is wired correctly. It runs the same configuration resolution as `--gobby-owned` but skips the network and the envelope write — pure introspection. + +```bash +$ ghook --diagnose --cli=claude --type=session-start +{ + "schema_version": 1, + "ghook_version": "0.1.0", + "cli": "claude", + "hook_type": "session-start", + "source": "claude", + "critical": true, + "terminal_context_enabled": true, + "daemon_url": "http://127.0.0.1:60887", + "daemon_host": "127.0.0.1", + "daemon_port": 60887, + "project_root": "/Users/josh/Projects/gobby-cli", + "project_id": "3bf57fe7-2a0c-4074-8912-a83d9cd4df01", + "terminal_context_preview": { + "parent_pid": 72441, + "tty": "/dev/ttys005", + "tmux_pane": "%179", + "term_program": "tmux", + "...": "..." + }, + "cli_recognized": true +} +``` + +Look for: + +- **`cli_recognized: true`** — confirms ghook knows about this CLI. `false` means it'll still spool, but without terminal-context enrichment and without honoring the per-CLI critical-hooks list. +- **`critical: true/false`** — does ghook *itself* consider this hook type critical for that CLI? Note this is independent of the `--critical` flag, which the host CLI sets based on its own settings. +- **`terminal_context_enabled: true`** — will ghook inject `terminal_context` into `input_data` for this hook? Required for hooks that the daemon uses to reconcile spawned-terminal agents. +- **`daemon_url`** — where will the POST go? If this is wrong, fix `~/.gobby/bootstrap.yaml`. +- **`project_root` / `project_id`** — did ghook correctly walk up from cwd to the project? `null` means no `.gobby/project.json` was found — daemon will receive the envelope without an `X-Gobby-Project-Id` header. + +The diagnose JSON is validated against `crates/ghook/schemas/diagnose-output.v1.schema.json` in tests, so the schema is stable. + +## Inbox & Replay + +Envelopes spool to `~/.gobby/hooks/inbox/--.json`: + +| Filename part | Meaning | +|---------------|---------| +| `prefix` | `c` (critical) or `n` (non-critical) — lets the drain worker prioritize critical hooks first | +| `ts13` | 13-digit zero-padded ms since epoch — gives lex-sortable filenames so drain order matches enqueue order | +| `uuid` | Random v4 — disambiguates within the same millisecond | +| `.tmp` suffix | Intermediate write; never a valid replay target. `atomic_write` does write→fsync→rename so the drain only ever sees fully-written envelopes. | + +**Don't touch this directory by hand.** The daemon's drain worker owns it. If you need to clear stuck envelopes, stop the daemon first, delete the files, then start it again. + +### Quarantine + +Malformed stdin (the host CLI sent something that isn't valid JSON) lands in `~/.gobby/hooks/inbox/quarantine/` as a pair of files: + +- `.json` — body containing the raw stdin bytes, base64-encoded. +- `.meta.json` — sidecar with `reason: "malformed_stdin"`, the JSON parse error, and the same base64 payload. + +The drain never replays quarantined envelopes — they surface via `gobby status` and daemon logs so you can investigate. + +## Troubleshooting + +### `ghook: no mode specified` + +You ran ghook without `--gobby-owned`, `--diagnose`, or `--version`. Pick one. The host CLI's hook command should always include `--gobby-owned`. + +### `--gobby-owned requires --cli and --type` + +Both flags are mandatory in dispatch mode. Check the hook entry in your host CLI's `settings.json`. + +### Hook fires but daemon never receives it + +1. `ghook --diagnose --cli= --type=` — confirm `daemon_url` is right and the CLI is recognized. +2. `ls ~/.gobby/hooks/inbox/` — if envelopes are piling up here, ghook is enqueuing fine but the daemon isn't draining. Check that the daemon is running. +3. If the inbox is empty too, the host CLI may not be invoking ghook at all. Check the host CLI's hook log/output. + +### Hook returns exit 2 unexpectedly + +The hook is marked `--critical` and the POST failed. The envelope is still spooled — check `~/.gobby/hooks/inbox/` for a `c-...json` file. The daemon will replay it. If you don't want exit 2 to signal the host CLI, drop `--critical` from the hook command (but only if you're OK with delayed delivery for that hook type). + +### Sandbox FS-read denials (macOS) + +The whole point of ghook's design is that this case is survivable. The envelope is written before the POST is attempted, and project-root walk-up happens before any potential `--detach`. If you see the daemon receive the envelope on the *next* hook fire instead of immediately, that's the drain worker doing its job — not a bug. + +### Schema version mismatch + +Envelopes carry `schema_version: 1`. If the daemon rejects envelopes for being a newer version than it understands, the daemon needs updating. ghook's `--version` command writes `~/.gobby/bin/.ghook-compatibility` so the daemon can detect this. diff --git a/docs/guides/gsqz-user-guide.md b/docs/guides/gsqz-user-guide.md index 78d9280..077cece 100644 --- a/docs/guides/gsqz-user-guide.md +++ b/docs/guides/gsqz-user-guide.md @@ -200,7 +200,7 @@ Pipe chains (`|`) are NOT split — the output comes from the last command in th When output quality is degraded, gsqz prepends markers so the LLM knows: - `[gsqz:passthrough]` — no pipeline matched, fallback truncation applied -- `[gsqz:low-savings]` — a pipeline matched but achieved less than 5% compression +- `[gsqz:low-savings]` — a pipeline matched but achieved less than 5% compression. The marker is suppressed when prepending it would actually grow the output beyond the original — gsqz will only annotate when the annotation isn't itself making things worse. ## On-Empty Fallback @@ -288,6 +288,7 @@ The `--stats` flag prints to stderr: Strategy names to look for: - A pipeline name (e.g. `git-status`, `pytest`, `cargo-test`) — matched and compressed - `{name}/low-savings` — pipeline matched but compression was marginal (<5%) +- `{name}/no-op` — pipeline matched but adding the low-savings marker would have grown the output, so the original is surfaced verbatim (no header, no daemon report) - `{name}/on_empty` — pipeline produced empty output, on_empty fallback used - `fallback` — no pipeline matched, generic truncation applied (with `[gsqz:passthrough]` marker) - `passthrough` — output was too short or compression didn't help diff --git a/docs/plans/sandbox-tolerant-hooks-rust.md b/docs/plans/sandbox-tolerant-hooks-rust.md new file mode 100644 index 0000000..4386431 --- /dev/null +++ b/docs/plans/sandbox-tolerant-hooks-rust.md @@ -0,0 +1,281 @@ +# Hand-off to the daemon-side agent — integrated with Rust migration epic + +## Context + +We're the Rust side in `gobby-cli`. A parallel agent is working the Python +daemon side in `gobby`. Shared brief: `docs/plans/sandbox-tolerant-hooks.md`. + +Two things reshape the original plan: + +1. **Hooks are registered globally** in `~/.claude/settings.json`, + `~/.codex/config.toml`, etc. — one command string shared across all + projects. Implication: the registered command MUST be an absolute path + (`~/.gobby/bin/ghook` expanded). Hook *invocation* cwd is set by the + host CLI at spawn to whatever dir the user launched from — walk-up + finds the current project if one exists, else project-id header is + omitted. Matches dispatcher behavior. + +2. **The `rust-migration-epic.md` already plans the shared crate.** It's + `gobby-core`, not `gobby-common`. R2-01 scaffolds it; R2-02/R2-04/R2-05 + extract exactly what ghook needs (bootstrap resolution, project root, + project metadata). The epic's Hook Integration section (`:151-157`) + anticipates a Rust `gobby-hooks` binary and permits it against the stable + public daemon contract. Daemon-side hook *execution* migration is + Phase 6 (R6-04 through R6-07) — out of scope here. ghook is client-side + only; daemon stays Python. + +`hook_dispatcher.py` is long-stable code, so parity is achievable against +a fixed reference. + +## Approach + +Ship four PRs in sequence (gobby-cli), satisfying the epic's "one primary +boundary per atomic item" standard. Each maps to an R-item from the epic. + +### PR 1 — `gobby-core` scaffold + project helpers (R2-01 + R2-04 + R2-05) + +- New crate `crates/gcore/` — workspace lib, no binaries. +- Move `find_project_root` and `read_project_id` from `gcode/src/project.rs` + into `gobby-core::project`. Expose as public API. +- `gcode` keeps its local copy temporarily (R2-08 is the later migration + item). No behavior change to gcode. No tests move. +- Cargo.toml: add `gobby-core` to workspace members; default profile. + +### PR 2 — bootstrap + daemon-URL helpers in `gobby-core` (R2-02 + R2-03) + +- Port `hook_dispatcher.py:145-175` port-resolution logic: read + `~/.gobby/bootstrap.yaml`, extract `daemon_port`, default `60887` on + missing/malformed. Use `serde_yaml = "0.9"` (matches gsqz/gcode workspace + consistency). +- `dirs::home_dir()` for `~` expansion — gsqz config resolver already uses + this pattern; copy. +- No consumers yet — ghook is the first in PR 3. + +### PR 3 — `ghook` crate (this is the sandbox-tolerant-hooks implementation) + +- New `crates/ghook/` binary. Depends on `gobby-core`. +- CLI surface per the plan: + `ghook --gobby-owned --cli=<...> --type=<...> [--critical] [--detach]`, + `ghook --diagnose --cli=<...> --type=<...>`, `ghook --version`. +- Behavior mirrors `hook_dispatcher.py` where possible: + - stdin-JSON payload (line `:637-638`). + - **Terminal-context enrichment** (line `:640-643`): if `hook_type` is in + the per-CLI `terminal_context_hooks` set (from `CLIConfig` `:61`), call + the ported equivalent of `get_terminal_context()` (`:181-223`) and + `input_data.setdefault("terminal_context", ...)` before envelope + build. This is load-bearing — daemon uses it to reconcile + spawned-terminal agents (`hooks/event_handlers/_session_start.py:191`). + Capture: `parent_pid`, `tty`, `tmux_pane`, `tmux_socket_path`, + `term_program`, and env vars `GOBBY_SESSION_ID`, `GOBBY_PARENT_SESSION_ID`, + `GOBBY_AGENT_RUN_ID`, `GOBBY_PROJECT_ID`, `GOBBY_WORKFLOW_NAME`. + **Sharp edge** (`:205`): only emit `TMUX_PANE` when `TMUX` is also + set — parent/child tmux-pane confusion breaks `kill_agent`. + - `X-Gobby-Project-Id` from `_find_project_config(cwd).id` (line `:657`). + Omit header when missing — never empty string. Walk-up **must happen + before detach** — chdir/fd semantics inside a detached Rust process + surprise on macOS. + - `X-Gobby-Session-Id` from `input_data["session_id"]` (line `:659`). + Same omit-on-missing semantics. + - Detach: single `setsid` (matches dispatcher's `start_new_session=True` + at `:697`) on Unix; `FreeConsole()` on Windows. `DETACHED_PROCESS` and + `CREATE_NEW_PROCESS_GROUP` are `CreateProcess` parent-side flags and + cannot be self-applied from the already-spawned child — `FreeConsole` + is the correct post-spawn analog (release the inherited console + handle). +- Upgrades beyond dispatcher: + - **Enqueue-first**: write envelope to + `~/.gobby/hooks/inbox/

--.json.tmp` → `fsync` → rename. + Then POST. On 2xx, delete. Prefix `c`/`n` for critical/non-critical; + `ts13` is zero-padded 13-digit millis for lex-sort stability. + - **Unparseable stdin**: dispatcher drops on floor (`:647-651`). Ghook + writes **directly** to + `~/.gobby/hooks/inbox/quarantine/

--.json` with a + `.meta.json` sidecar: `{reason: "malformed_stdin", json_error: "...", + stdin_bytes_b64: "..."}`. Drain never replays from quarantine — it + surfaces via `gobby status` / logs. Keeps the normal envelope schema + clean (no `malformed` branch in schema v1). Exit 0 (non-critical) or 2 + (critical). +- `serde_yaml = "0.9"`, `ureq` matching gsqz's feature set (no TLS; loopback + only), `clap` derive, `anyhow`. +- Schema files in `crates/ghook/schemas/`: + - `inbox-envelope.v1.schema.json` + - `diagnose-output.v1.schema.json` + - `cargo test` validates serialized output against both. +- `~/.gobby/bin/.ghook-compatibility` written by `ghook --version` on first + run: `{ "schema_version": 1, "ghook_version": "0.1.0" }`. +- CI: `.github/workflows/release-ghook.yml` mirrors `release-gcode.yml` + + binary-specific tag prefix (`gobby-hooks-v`, per commit `bf9eb40`). + Targets: `darwin-arm64`, `darwin-x86_64`, `linux-x86_64`, `linux-arm64`, + `windows-x86_64` (mirrors `install_setup.py:249-250` triples for + gsqz/gcode). Publish to crates.io as `gobby-hooks` so + `cargo-binstall` / `cargo install` fallbacks work in the daemon's + `_install_ghook()`. +- **Publish-order constraint.** `ghook` depends on `gobby-core`, so + `gobby-core` must publish to crates.io before any `ghook` release can + complete — crates.io rejects uploads with path-only dependencies. PR 1 + adds a sibling `.github/workflows/release-gcore.yml` that + publishes `gobby-core`, and `crates/ghook/Cargo.toml` declares its + `gobby-core` dep with both `version = "0.x.y"` and + `path = "../gcore"` — crates.io consumes the `version`, workspace + builds honor the `path`. The first `gobby-hooks-v` tag cannot + ship until the matching `gobby-core` version is live on the registry. + +### PR 4 — migrate `gcode` to `gobby-core` (R2-08) + +- Delete `gcode/src/project.rs`'s `find_project_root` + `read_project_id`. +- Update `gcode/src/config.rs` (line `:195`, `detect_project_root`) and + any other call sites to import from `gobby_core::project`. +- Clean `cargo test -p gobby-code` pass. +- `gsqz` migration (R2-09) is a separate follow-up — gsqz doesn't currently + use walk-up helpers, so it waits for R2-06 (HTTP client utilities) in a + later PR. + +## What we (Rust side) commit to — frozen contracts + +### Crate & distribution + +- Cargo package names: `gobby-core` (lib), `gobby-hooks` (bin `ghook`). +- Binary install: `~/.gobby/bin/ghook` + stamp `~/.gobby/bin/.ghook-version`. +- Crates.io: `gobby-core` and `gobby-hooks` both published. `gobby-core` + must publish first — crates.io rejects packages with path-only + dependencies, and `ghook` depends on `gobby-core`. `ghook`'s + `Cargo.toml` declares `gobby-core = { version = "0.x.y", path = "..." }` + so workspace builds stay path-backed while registry builds resolve the + version. +- GitHub release tag: `gobby-hooks-v`. Tarball: + `ghook-.tar.gz`. +- Windows: `ghook.exe`. + +### Envelope schema v1 (committed to `crates/ghook/schemas/`) + +```json +{ + "schema_version": 1, + "enqueued_at": "", + "critical": false, + "hook_type": "session-start", + "input_data": { "...": "original stdin payload" }, + "source": "claude", + "headers": { + "X-Gobby-Project-Id": "...", + "X-Gobby-Session-Id": "..." + } +} +``` + +Omitted headers are absent from the `headers` object entirely — no +empty-string values. + +### Quarantine sidecar + +When the drain gives up on an envelope, writes `.meta.json` next to +the quarantined envelope: `{ attempt_count, first_seen, last_error, +last_attempt }`. Agreed with daemon agent. + +### Exit codes + +- `0` — success OR non-critical failure (enqueued, will replay). +- `2` — critical failure (enqueued, will replay — signals the host CLI). + +## All contract questions resolved + +- **Q1.1** → stdin-only for headers. Env vars enter as terminal_context + *data*, not as headers. Parity with dispatcher `:659`. Empty stdin + normalizes to `input_data = {}` (ghook); dispatcher exits non-zero on + empty (`hook_dispatcher.py:677`). Documented transition-window + divergence — exit code is still governed by `--critical` alone, no + silent drop. +- **Q1.2** → omit header on missing, mirror `:657-661`. +- **Q1.3** → write directly to `inbox/quarantine/` with `.meta.json` + sidecar. No `malformed` flag in envelope schema. Drain never replays. +- **Q1.4** mechanism → port `_find_project_config` verbatim (`:527`). +- **Q1.4** assumption → cwd-inheritance confirmed across all four CLIs. + Sharp edge: strict sandbox FS-read denials on parent dirs → walk-up + returns None → header absent → daemon middleware treats as "no project + context" and executes hook un-scoped. Correct behavior, no header + fabrication. +- **Q2** (current.json) → moot, dropped. +- **Q3** (Windows) → in scope; mirror `install_setup.py:249-250` triples. +- **Q4** (inbox naming) → `

--.json`, lex sort, ignore `*.tmp`. +- **Q5a** → bare 40-byte hex SHA + newline in `gobby/schemas/SOURCE_COMMIT`. + Sync metadata lives in git log; file list implicit from `ls`. +- **Q5b** → `gobby-cli` is public (confirmed via `install_setup.py:234,237,538,541` + fetching release URLs with no auth). CI uses + `raw.githubusercontent.com/GobbyAI/gobby-cli//schemas/*`. +- **Q6** (serde_yaml) → `0.9`, workspace consistency. +- **Detach** → single `setsid` (Unix, matches `:697`) / `FreeConsole()` + (Windows — post-spawn release of the inherited console; `DETACHED_PROCESS` + and `CREATE_NEW_PROCESS_GROUP` are `CreateProcess` parent-side flags, not + self-applicable from the child). No double-fork. +- **Terminal context** → ported from `get_terminal_context()` `:181-223` + with the `TMUX`/`TMUX_PANE` inheritance rule at `:205`. Gated by + per-CLI `terminal_context_hooks` set from `CLIConfig` `:61`. +- **POST body shape** → daemon's `/api/hooks/execute` endpoint + (`servers/routes/mcp/hooks.py:258`) reads `hook_type`, `input_data`, + and `source` from the top level and hands the full payload through to + adapters, which only read the fields they need. Both the legacy + bare-body shape `{hook_type, input_data, source}` and the schema-v1 + envelope `{schema_version, enqueued_at, critical, hook_type, + input_data, source, headers}` are accepted — envelope extras fall + through as silent metadata. ghook sends envelope shape; the Python + plan's §2.8 adds a handler test that pins this tolerance so a future + refactor can't silently regress envelope-aware clients. + +Daemon agent has signed off. Green light to proceed with PRs 1–4. + +## Critical files (this repo) + +**New (PR 1):** +- `crates/gcore/Cargo.toml` +- `crates/gcore/src/lib.rs` +- `crates/gcore/src/project.rs` — moved from gcode +- Root `Cargo.toml` — add `gobby-core` to workspace members + +**New (PR 2):** +- `crates/gcore/src/bootstrap.rs` +- `crates/gcore/src/daemon_url.rs` + +**New (PR 3):** +- `crates/ghook/Cargo.toml` +- `crates/ghook/src/main.rs` — clap entry +- `crates/ghook/src/envelope.rs` +- `crates/ghook/src/transport.rs` +- `crates/ghook/src/diagnose.rs` +- `crates/ghook/src/detach.rs` — `#[cfg(unix)]` / `#[cfg(windows)]` split +- `crates/ghook/src/terminal_context.rs` — Rust port of + `hook_dispatcher.py:181-223` + per-CLI `terminal_context_hooks` gate +- `crates/ghook/schemas/inbox-envelope.v1.schema.json` +- `crates/ghook/schemas/diagnose-output.v1.schema.json` +- `.github/workflows/release-ghook.yml` +- Root `Cargo.toml` — add `ghook` to workspace + `opt-level="z"` override + +**Modified (PR 4):** +- `crates/gcode/src/project.rs` — remove migrated helpers +- `crates/gcode/src/config.rs:195` — update `detect_project_root` imports +- Any other gcode call sites + +## Verification + +- `cargo test --workspace` green at each PR boundary. +- `cargo clippy --workspace -- -D warnings` green. +- `cargo build --release -p gobby-hooks` produces binary in + `gsqz`/`gloc` size ballpark (`opt-level="z"` target). +- PR 3 manual: + - `echo '{"session_id":"test"}' | target/release/ghook --gobby-owned + --cli=claude --type=session-start` with daemon up — POST succeeds, + no inbox file lingers. + - Same with daemon down — inbox file persists, exit 0 (non-critical) or + 2 (critical). + - `ghook --diagnose --cli=codex --type=session-start` emits valid + diagnose JSON. + - `ghook --version` writes `.ghook-compatibility`. +- PR 4: `cargo test -p gobby-code` green; `detect_project_root` behavior + unchanged. + +## Not gated on 0.4.0 + +Per epic `:88-89`: Phase 2 low-risk extraction proceeds in parallel with +0.4.0 hardening. PR 1 and PR 2 are Phase 2 extractions. PR 3 (ghook) is +client-side against the stable `hook_dispatcher.py` contract, permitted by +the epic's Hook Integration section `:151-157`. None of this is Phase 3+ +implementation work against churning surfaces. diff --git a/docs/plans/sandbox-tolerant-hooks.md b/docs/plans/sandbox-tolerant-hooks.md new file mode 100644 index 0000000..46a6291 --- /dev/null +++ b/docs/plans/sandbox-tolerant-hooks.md @@ -0,0 +1,467 @@ +# Sandbox-compatible hooks across Claude / Codex / Gemini / QwenCode + +## Context + +Every Gobby adapter registers the same hook shape today: the CLI shells out to +`uv run $HOOKS_DIR/hook_dispatcher.py --cli= --type=`, which HTTP-POSTs +to `127.0.0.1:60887/api/hooks/execute`. That invocation has three hidden +dependencies the host CLI's sandbox can refuse: + +1. **Exec**: running `uv` and reading `~/.cache/uv/` + the dispatcher file. +2. **Filesystem**: reading `~/.gobby/bootstrap.yaml` and the hooks dir. +3. **Network**: loopback to `127.0.0.1:60887`. + +Codex CLI's default `sandbox_mode: workspace-write` has +`network_access: false`; Claude `sandbox: true`, Gemini strict profiles, and +QwenCode + OpenSandbox all hit one or more of the three. + +User priority: **Codex → Gemini → Claude Code → QwenCode.** Primary observed +symptom: "hook command won't start" — the `uv run` front end fails before +Python even loads. Loopback failure is secondary. + +**Implementation strategy: Rust.** Gobby is mid-migration from Python to +Rust; `~/Projects/gobby-cli` already ships `gcode`, `gsqz`, and `gloc` as +standalone binaries. The hook dispatcher is exactly the shape of code being +ported and is also the failure case we need to fix. Writing it in Python as a +`gobby-hook` console_script and then porting later would be a double- +implementation tax. Do it in Rust now, matching the existing crate pattern. + +Chosen posture on sandbox config: Gobby declares its requirements as data per +adapter and writes the corresponding entries into each CLI's settings at +`gobby install` time automatically, with `--dry-run` for visibility. + +Target outcome: a plain `gobby install` on a machine with `ghook` present in +`~/.gobby/bin/` and any of the four CLIs running in its strictest default +sandbox mode produces working hooks — no hand-edited CLI config. + +## Resolved questions (from prior review) + +- **Does plain `gobby install` always yield working hooks?** Yes — sandbox + mutations are default behavior, not opt-in. Visibility via install + transcript + new `--dry-run` flag. No `--apply-sandbox` gate. +- **Inbox record format** (minimum viable replay envelope): + ```json + { + "schema_version": 1, + "enqueued_at": "", + "critical": false, + "hook_type": "session-start", + "input_data": { ... original payload ... }, + "source": "claude", + "headers": { + "X-Gobby-Project-Id": "...", + "X-Gobby-Session-Id": "..." + } + } + ``` + Drain replays as an authenticated POST with these exact headers. Critical + hooks drain in enqueue order; non-critical hooks may be drained + concurrently within a given (session, hook_type) pair. +- **Python vs Rust:** Rust. `ghook` is the fourth crate in the gobby-cli + workspace. +- **Binary distribution model:** match `gcode` / `gsqz` exactly, including + **automatic install via `gobby install`**. `install_setup.py` already has + a three-tier fallback (`_install_gsqz_from_github` → `cargo-binstall` → + `cargo install`) with crates.io version resolution, stamp files + (`~/.gobby/bin/.gsqz-version`), and PATH setup across shells. A new + `_install_ghook()` mirrors that one-for-one. Python side resolves + `~/.gobby/bin/ghook` first, then `shutil.which()`. + +## Critical files + +**New in `~/Projects/gobby-cli`:** +- `crates/ghook/Cargo.toml` +- `crates/ghook/src/main.rs` — `clap` entry point +- `crates/ghook/src/envelope.rs` — replay envelope struct + serde +- `crates/ghook/src/transport.rs` — enqueue-first flow (file write + HTTP) +- `crates/ghook/src/diagnose.rs` — sandbox probe for `--diagnose` +- `crates/ghook/src/config.rs` — reads `~/.gobby/bootstrap.yaml` for port +- `.github/workflows/release-ghook.yml` — mirrors `release-gcode.yml` +- Root `Cargo.toml` — add `ghook` to workspace members, opt level "z" + +**Modified in `~/Projects/gobby` (the Python daemon side):** + +Binary installer — new `ghook` lane mirroring `gcode`/`gsqz`: +- `src/gobby/cli/install_setup.py` — add `_install_ghook()`, + `_get_latest_ghook_version()`, `_get_installed_ghook_version()`, + `_write_ghook_version_stamp()`, `_install_ghook_from_github()`, and the + cargo-binstall / cargo-install fallbacks, parallel to the existing + `_install_gsqz` / `_install_gcode` structure at `install_setup.py:232+`. + Hook the new lane into the main install flow around + `install_setup.py:183`. + +Adapters & templates — update the registered hook command string: +- `src/gobby/adapters/claude_code.py` +- `src/gobby/adapters/gemini.py` +- `src/gobby/adapters/codex_impl/adapter.py` +- `src/gobby/adapters/qwen.py` +- `src/gobby/install/{claude,gemini,codex,qwen}/hooks-template.json` + +Installers — write sandbox config, own the manifest: +- `src/gobby/cli/installers/claude.py:219` +- `src/gobby/cli/installers/gemini.py:102` +- `src/gobby/cli/installers/qwen.py:56,86` +- `src/gobby/cli/installers/codex.py:38,182` — swap regex TOML edits for + `tomli`/`tomli_w` +- `src/gobby/cli/installers/shared.py:86` — ownership probe migration +- `src/gobby/cli/install.py:153` — add `--dry-run` +- `src/gobby/utils/deps.py:111` — ownership probe migration + +Server / drain: +- `src/gobby/servers/routes/mcp/hooks.py:288` — drain replay must preserve + `X-Gobby-Project-Id` / `X-Gobby-Session-Id` +- New: `src/gobby/hooks/inbox.py` — daemon-side drain watcher +- `src/gobby/runner_maintenance.py` — wire drain into maintenance tick + +Cross-binary resolution utility: +- New: `src/gobby/utils/native_bin.py` — one resolver used by `ghook` + invocation, plus migrate `gcode` (in `src/gobby/code_index/maintenance.py`) + and `gsqz` (in `src/gobby/llm/sdk_utils.py`) to use it. Small, safe + refactor that pays down existing duplication. + +**To be retired (one release compatibility window):** +- `src/gobby/install/shared/hooks/hook_dispatcher.py` — remains installed + for pre-upgrade cleanup detection only; no longer invoked by new installs. + +**Reuse:** +- `src/gobby/agents/sandbox.py` — existing sandbox-profile vocabulary; model + adapter-side declarations on the same primitives where applicable. + +## Sequencing & independence + +The two repos work fully in parallel. There is **no hard synchronization +point** — the daemon does runtime detection of `ghook` and chooses between +the new and legacy code path on every `gobby install`. Whoever ships first +just works; the other side lights up when its piece arrives. + +**Detection rule (evaluated at `gobby install` time, per CLI):** + +``` +ghook_bin = native_bin.resolve("ghook") # ~/.gobby/bin/ghook, then PATH +if ghook_bin: + register_hook_command(f"{ghook_bin} --gobby-owned --cli=X --type=Y") + apply_sandbox_writes(SandboxRequirements(exec_paths=[ghook_bin], ...)) + record_manifest(hook_bin=ghook_bin, sandbox_writes=...) +else: + register_hook_command("uv run $HOOKS_DIR/hook_dispatcher.py ...") # legacy + skip_sandbox_writes() # don't loosen user's sandbox + # for a binary they don't have + print_warning("ghook not installed — sandboxed hooks may fail. " + "`gobby install` will retry installing it next run.") + record_manifest(hook_bin="legacy", sandbox_writes=None) +``` + +`_install_ghook()` is invoked before this check on every `gobby install` and +tolerates release-not-yet-available cleanly (returns `skipped`). So the +typical user path is: one `gobby install` run after `ghook` lands in +releases → binary downloaded → subsequent per-CLI registration picks the +`ghook` branch → sandbox writes applied → everything works. + +**What this buys us:** + +- No coordinated release day. Daemon PRs merge whenever; `ghook` PRs merge + whenever. +- Users who upgrade the Gobby daemon before `ghook` ships are unaffected — + the legacy `hook_dispatcher.py` path remains the default until `ghook` + can be installed. +- Users who upgrade and *do* have `ghook` get the new flow automatically + without re-running anything special. +- Downgrade and partial-install scenarios stay sane: if `ghook` disappears + from `~/.gobby/bin/` (manual delete, corrupt upgrade), next `gobby + install` detects its absence and flips back to legacy. The manifest + records which branch is active so uninstall knows what to remove. + +**Daemon-side invariants during the transition:** + +1. `hook_dispatcher.py` stays shipped in the Gobby wheel until `ghook` has + been released for one full version cycle. It is the fallback, not dead + code. +2. The drain (Phase 2.7) always runs regardless of which branch is active; + it watches the inbox dir and no-ops when empty. `hook_dispatcher.py` + will be retrofitted to write envelopes to the inbox on HTTP failure as + part of Phase 2.7 so the legacy path also benefits from loss-free + replay. (This is a small addition — the dispatcher already has all the + context it needs.) +3. Sandbox writes are strictly gated on `ghook` being present. We never + loosen a user's sandbox for a binary they don't have. +4. The install manifest carries a `branch: "ghook" | "legacy"` field so + re-runs can detect branch transitions and clean up the old entries + before writing the new ones. + +**Concretely for the `gobby-cli` agent:** you can ship `ghook` on whatever +timeline you want. Nothing on the daemon side changes the contract you're +writing against. The envelope and diagnose-output schemas live in +`gobby-cli` under your control; when `ghook` v0.1.0 lands, the daemon +picks it up on next `gobby install`. + +## Cross-repo coordination + +Work splits across two repos — no shared-dependency merge, but the coupling +seams are pinned as explicit schemas so the Rust-side agent (in +`gobby-cli`) and the Python-side agent (in `gobby`) can work in parallel +against a stable contract. + +**Canonical plan location:** `~/Projects/gobby-cli/docs/plans/sandbox-tolerant-hooks.md` +(copy of this plan file). The Rust-side agent owns Phases 0.6, 1.2, 2.1–2.6, +2.8, and the CI release. The Python-side agent owns everything else. + +**Pinned contracts (live in `gobby-cli`, consumed by both sides):** +- `gobby-cli/schemas/inbox-envelope.v1.schema.json` — the replay envelope + structure defined in "Resolved questions" above. Rust serializes, Python + deserializes; both validate on write/read at least in tests. +- `gobby-cli/schemas/diagnose-output.v1.schema.json` — the JSON shape that + `ghook --diagnose` emits. Python test harness parses against this. +- `~/.gobby/bin/.ghook-compatibility` — written by `ghook` itself (or by + `_install_ghook()` at install time). Records `{schema_version: 1}`. Gobby + daemon reads this on start; if incompatible, refuses to start and tells + the user to upgrade `ghook`. + +## Phase 0 — Prerequisites + +Pre-existing bugs / missing surfaces that must land first. + +1. **Fix Qwen package data.** Add `install/qwen/*` to `package-data` / + `include` in `pyproject.toml:95`. Built distributions are currently + missing `install/qwen/hooks-template.json`, which `installers/qwen.py` + expects. +2. **Structured install manifest.** New `~/.gobby/install-manifest.json` + records, per CLI, exactly which JSON keys / TOML paths Gobby owns: + ```json + { + "schema_version": 1, + "clis": { + "claude": { + "settings_path": "~/.claude/settings.json", + "owned_keys": ["hooks.SessionStart", "allowedHttpHookUrls"], + "hook_bin": "" + }, + "codex": { ... }, + "gemini": { ... }, + "qwen": { ... } + } + } + ``` + Replaces the current substring-based ownership probes. Install writes it; + uninstall consumes it. +3. **Swap Codex TOML edits to a library.** Replace the regex edits in + `installers/codex.py:38` with `tomli` + `tomli_w`. Prerequisite for + idempotent merges in Phase 3. +4. **`gobby install --dry-run`.** Extends `install.py:153` to compute all + writes (hook entries, sandbox mutations, manifest updates) and print + them without touching the filesystem. +5. **`native_bin.py` resolver + migrate existing callers.** Pays down + duplicated `~/.gobby/bin/` lookup logic now (currently two call + sites for `gcode` and `gsqz`) so Phase 2's `ghook` lookup is the third + user of one resolver, not new duplication. +6. **Pin the coupling schemas in `gobby-cli`.** Write + `gobby-cli/schemas/inbox-envelope.v1.schema.json` and + `gobby-cli/schemas/diagnose-output.v1.schema.json`. Add a `cargo test` + that validates `ghook`'s serialized output against them, and a pytest + equivalent in `gobby` that validates the Python-side drain's parsing. + These files ship in the `gobby-cli` repo and are the source of truth + for both sides. +7. **Copy this plan to `gobby-cli`.** `cp` the final version of this file + to `~/Projects/gobby-cli/docs/plans/sandbox-tolerant-hooks.md` so the + Rust-side agent has the same brief. Keep the file at + `~/.claude/plans/hi-gobby-i-see-nested-hellman.md` as Claude's working + copy for this session; the `gobby-cli` copy is the durable one. + +## Phase 1 — Sandbox test harness & compatibility matrix + +Goal: reproducible matrix that boots each CLI in its strictest default +sandbox mode and fires every registered hook event, capturing which +dependency (exec / FS read / FS write / loopback) is denied. + +1. `tests/integration/sandbox/` with one runner per CLI: + `run_codex_sandbox.py`, `run_claude_sandbox.py`, `run_gemini_sandbox.py`, + `run_qwen_sandbox.py`. +2. `ghook --diagnose` — probes exec (can I read my own binary?), FS read + (`~/.gobby/bootstrap.yaml`), FS write (`~/.gobby/hooks/inbox/test.tmp`), + loopback (TCP connect to `127.0.0.1:60887`). Emits JSON. Runners invoke + it through the registered hook command string so measurements reflect + the real in-sandbox context, not the host. +3. `docs/sandbox-compatibility.md` — matrix of `(cli, sandbox mode, hook + event) → diagnose output`. Internal reference. +4. Mark runners `@pytest.mark.integration` + `@pytest.mark.slow`; gate + behind `--run-sandbox`. Not run pre-push. + +## Phase 2 — Rust `ghook` binary + enqueue-first transport + +Goal: replace `uv run hook_dispatcher.py` with a single static binary +implementing a loss-free enqueue-first flow. + +1. **Scaffold the crate.** `crates/ghook/` in `gobby-cli`. Conventions match + existing crates: `anyhow::Result`, `clap` derive, `serde_json`, + `ureq` HTTP with 1s connect / 5s total for critical hooks and 500ms + total for non-critical, no tokio, fail-open pattern from `gsqz`. +2. **CLI surface:** + ``` + ghook --cli= --type= [--critical] + ghook --diagnose --cli=<...> --type=<...> + ghook --version + ``` + Reads JSON payload from stdin (matching current + `hook_dispatcher.py:653`). Reads context headers from environment + (`GOBBY_PROJECT_ID`, `GOBBY_SESSION_ID`) which the host CLI passes + through; falls back to reading them from + `~/.gobby/sessions/current.json` if not set. +3. **Port resolution.** Read `~/.gobby/bootstrap.yaml` with `serde_yaml`, + extract `daemon_port`, default 60887. Mirror + `hook_dispatcher.py:145-175` exactly. +4. **Enqueue-first flow.** Every invocation: + 1. Build the replay envelope. + 2. Atomically write to `~/.gobby/hooks/inbox/-.json` (write + `.tmp`, `fsync`, rename). + 3. POST payload + headers to the daemon with short timeout. + 4. On 2xx: delete inbox file, exit 0. + 5. On connect/timeout: keep file; exit 0 non-critical, 2 critical. + 6. On HTTP 4xx/5xx: keep file for diagnostics; exit per criticality. +5. **SessionEnd detach.** Replace the current detached-curl fork with + `ghook --detach` — on Unix, `setsid` + double-fork, then run the same + enqueue-first flow. Because the file write precedes the POST, the event + is durable even if the parent CLI kills the child mid-POST. +6. **Stable ownership marker.** Every registered hook command includes a + literal `--gobby-owned` flag (no-op at runtime, ownership signal for + probes). Same-PR migration of the three current substring probes: + - `installers/shared.py:86` + - `installers/codex.py:182` + - `utils/deps.py:111` + Keep an "old dispatcher detected" compatibility branch in each for one + release so upgrades clean up pre-existing installs. +7. **Inbox drain (Python side).** `src/gobby/hooks/inbox.py`: + - Scan `~/.gobby/hooks/inbox/` on daemon start and via + `runner_maintenance.py` tick. + - Replay in filename (timestamp) order; critical first. + - POST to the same internal hook-execute entry point the HTTP route + uses, with the envelope's headers. + - On success: delete. On failure: exponential backoff with cap; after + N failures quarantine to `inbox/quarantine/` with a log line. +8. **CI + release.** `.github/workflows/release-ghook.yml` mirrors + `release-gcode.yml`: multi-target (darwin-arm64, darwin-x86_64, + linux-x86_64, linux-arm64) tarballs to GitHub Releases. Publish `ghook` + to crates.io so the cargo-binstall / cargo-install fallback tiers work + out of the box — `install_setup.py`'s latest-version check hits + `crates.io/api/v1/crates/` today, and `ghook` needs to be there + for the same check to succeed. +9. **Update hook templates** in all four + `src/gobby/install//hooks-template.json` to emit the new command + string: ` --gobby-owned --cli= --type=` where + `` substitutes to the absolute resolved path from + `native_bin.py`. + +## Phase 3 — Declarative sandbox permissions per adapter + +Goal: adapters declare what they need; installers translate to idempotent +settings writes tracked by the install manifest. + +1. **`SandboxRequirements` dataclass** in + `src/gobby/adapters/sandbox_declaration.py`: + - `loopback_hosts: list[str]` + (default: `["127.0.0.1:60887", "127.0.0.1:60888"]`) + - `fs_read_paths: list[str]` + (default: `["~/.gobby/bootstrap.yaml", "~/.gobby/hooks/"]`) + - `fs_write_paths: list[str]` (default: `["~/.gobby/hooks/inbox/"]`) + - `exec_paths: list[str]` (default: `["~/.gobby/bin/ghook"]`) + Each adapter returns its requirements via `sandbox_requirements()`. +2. **Installer translation (all default-on during `gobby install`, + recorded in manifest, revertible via uninstall):** + - **Codex** (`installers/codex.py`): using `tomli_w`, set + `sandbox_workspace_write.network_access = true` in + `~/.codex/config.toml`. If Codex later exposes a loopback-only + allowlist, switch to that. + - **Claude Code** (`installers/claude.py`): JSON-merge + `allowedHttpHookUrls: ["http://127.0.0.1:60887/*"]` and — when the + user already has a `sandbox` block — append Gobby's `fs_read_paths` + / `fs_write_paths` / `exec_paths` to + `sandbox.filesystem.allowRead` / `allowWrite` / + `sandbox.exec.allowBinaries` respectively. **Do not** create a + `sandbox` block if absent. + - **Gemini** (`installers/gemini.py`): when a sandbox profile is + configured, write `~/.gemini/sandbox-profiles/gobby.sb` (macOS) or + `gobby.bwrap` (Linux) and register it via the profile include + mechanism. No-op otherwise. + - **Qwen** (`installers/qwen.py`): same as Gemini; when OpenSandbox is + configured, add a host-network bridge directive. +3. **Idempotence via manifest, not comment fences.** Strict-JSON (Claude, + Gemini, Qwen) and TOML (Codex) cannot carry inline ownership comments + reliably. + - Before writing, read current value, diff against adapter + requirements, apply only the delta. + - After writing, record the exact JSON path / TOML path in the + manifest's `owned_keys`. + - Re-running install diffs manifest against adapter requirements; + adds/removes accordingly — no duplication. +4. **`gobby install --dry-run` output** prints hook entries, sandbox + mutations (before → after), and manifest diff. Exits 0 without writing. + +## Out of scope + +- MCP-over-stdio subprocess sandboxing — separate plan after this lands. +- Full port of the Python daemon to Rust — unchanged by this plan. +- End-user docs. `docs/sandbox-compatibility.md` is internal. + +## Follow-up cleanup (filed as a gobby-task on execution kickoff) + +**Title:** Remove legacy `hook_dispatcher.py` and runtime-detection branch +once `ghook` is universal. + +**What gets removed:** +1. `src/gobby/install/shared/hooks/hook_dispatcher.py` — the Python hook + dispatcher itself. +2. The `branch == "legacy"` code path in every adapter/installer that + runtime-detects `ghook`. After cleanup, adapters register the `ghook` + command unconditionally. +3. The "ghook not installed" warning path in the installer transcript. +4. The one-release substring-match compatibility branch in the three + ownership probes (`installers/shared.py`, `installers/codex.py`, + `utils/deps.py`) — they simplify to only checking the + `--gobby-owned` marker. +5. The retrofit of `hook_dispatcher.py` that writes to the inbox on HTTP + failure (Phase 2.7, invariant 2) — no longer reachable. +6. The `branch` field in the install manifest (or keep it and make + `"ghook"` the only valid value; decide at cleanup time). + +**Sunset criteria (all must be true before this task runs):** +- `ghook` has been in GitHub Releases + crates.io for ≥ N releases + (propose N=3; revisit at task time). +- Telemetry (or a canary `gobby status` probe across active users) shows + the legacy branch is effectively unused. If no telemetry exists, use + "time elapsed since `ghook` first shipped ≥ 30 days" as a proxy. +- `_install_ghook()`'s fallback chain (GitHub → cargo-binstall → cargo + install) has a confirmed success rate on all four supported platforms + — no user population is stuck on legacy because their platform doesn't + get a binary. + +**Why file this now and not at cleanup time:** the runtime-detection +branch is the kind of thing that silently becomes permanent if nobody +owns its removal. Filing the ticket alongside the initial implementation +is the forcing function. + +## Verification + +1. **Rust side** (`gobby-cli`): `cargo test -p ghook`, `cargo clippy -p + ghook -- -D warnings`, `cargo build --release -p ghook`. +2. **Python side** (`gobby`): `uv run ruff check src/` + + `uv run mypy src/` clean. +3. `uv run pytest tests/cli/installers/ -v` — installer unit tests cover + idempotent writes, manifest round-trips, dry-run output, and the + ownership-marker migration. +4. `uv run pytest tests/integration/sandbox/ -v --run-sandbox` — all four + CLIs report all hook events firing in diagnose mode. +5. Manual end-to-end per CLI (only real way to catch sandbox drift): + - Fresh shell → `gobby install --dry-run` → inspect → `gobby install` + — verify the `ghook` install lane runs (GitHub tarball path first, + cargo fallbacks second), `~/.gobby/bin/ghook` appears with a stamp + file at `.ghook-version`, and PATH is set up. + - Start each CLI in default sandbox mode → quick prompt → confirm + `gobby sessions` and daemon log show hooks firing. + - Simulate a GitHub Releases outage (block the tarball URL) and confirm + cargo-binstall / cargo-install fallbacks produce a working + `~/.gobby/bin/ghook`. +6. Loss-free replay: `gobby stop`, fire hooks (including SessionEnd), + confirm envelopes in `~/.gobby/hooks/inbox/`, `gobby start`, confirm + drain processes every entry with correct project/session headers. +7. Idempotence: `gobby install` twice → second run is no-op (empty + manifest diff). +8. Uninstall: `gobby uninstall` consults manifest, removes only Gobby- + owned keys.