diff --git a/.github/scripts/repair-v8-prebuilt.sh b/.github/scripts/repair-v8-prebuilt.sh new file mode 100755 index 000000000..74caebc95 --- /dev/null +++ b/.github/scripts/repair-v8-prebuilt.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# v8-goose downloads a prebuilt librusty_v8 archive into target//gn_out +# and emits a rustc-link-search pointing at it. Cargo caches that build script +# output and replays it on later builds instead of re-running the script, but +# the Rust cache action prunes target/ before saving, so a restored cache can +# keep the replayed link-search while the 131MB archive is gone. Linking then +# fails with "could not find native static library `rusty_v8`". +# +# Drop the cached build script output whenever a directory it advertises is +# missing, which forces the script to re-run and download the archive again. +set -euo pipefail + +[ -d target ] || exit 0 + +find target -type f -path '*/build/v8-goose-*/output' -print0 | + while IFS= read -r -d '' output; do + while IFS= read -r dir; do + [ -n "$dir" ] || continue + [ -d "$dir" ] && continue + echo "v8-goose build output references missing $dir; forcing a rebuild" + rm -rf "$(dirname "$output")" + break + done < <(sed -n 's/^cargo:rustc-link-search=\(.*\)$/\1/p' "$output") + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96b659b52..504900770 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - name: Repair stale v8 prebuilt marker + run: .github/scripts/repair-v8-prebuilt.sh + - name: Install Dependencies run: | sudo apt update -y @@ -137,6 +140,37 @@ jobs: RUST_MIN_STACK: 8388608 + rust-build-and-test-roaming: + name: Build and Test Roaming Feature + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + + - name: Install Dependencies + run: | + sudo apt update -y + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: roaming + + - name: Build and Test with roaming + run: | + gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' + export CARGO_INCREMENTAL=0 + cargo test -p goose-roaming + cargo build -p goose-cli --features roaming + cargo test -p goose-cli --features roaming --test roam_acp_client + env: + RUST_MIN_STACK: 8388608 + rust-build-windows: name: Build Rust Project on Windows runs-on: windows-latest @@ -189,6 +223,9 @@ jobs: sudo apt update -y sudo apt install -y libdbus-1-dev libxcb1-dev + - name: Repair stale v8 prebuilt marker + run: .github/scripts/repair-v8-prebuilt.sh + - name: Check with MSRV toolchain run: cargo check --workspace --locked --all-targets env: @@ -204,6 +241,9 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - name: Repair stale v8 prebuilt marker + run: .github/scripts/repair-v8-prebuilt.sh + - name: Lint run: | source ./bin/activate-hermit diff --git a/Cargo.lock b/Cargo.lock index c633f5358..ae2d5304a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if 1.0.4", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes" version = "0.9.2" @@ -38,6 +49,20 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "agent-client-protocol" version = "2.0.0" @@ -555,6 +580,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atoi" version = "2.0.0" @@ -1176,6 +1212,17 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -1197,6 +1244,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base32" version = "0.5.1" @@ -2205,6 +2258,12 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -2239,9 +2298,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -2475,6 +2534,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -2671,6 +2739,16 @@ dependencies = [ "url", ] +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2780,6 +2858,12 @@ dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crmf" version = "0.2.0" @@ -2881,6 +2965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -2893,6 +2978,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -2951,12 +3045,30 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -3100,10 +3212,44 @@ dependencies = [ ] [[package]] -name = "data-encoding" -version = "2.11.0" +name = "dashmap" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if 1.0.4", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.3", +] [[package]] name = "data-url" @@ -3551,7 +3697,7 @@ dependencies = [ "const-oid 0.9.6", "der_derive", "flagset", - "pem-rfc7468", + "pem-rfc7468 0.7.0", "zeroize", ] @@ -3562,6 +3708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -3653,6 +3800,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "diff" version = "0.1.13" @@ -3743,6 +3896,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.0", + "block2", + "libc", "objc2", ] @@ -3763,6 +3918,17 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59f8e79d1fbf76bdfbde321e902714bf6c49df88a7dda6fc682fc2979226962d" +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "document-features" version = "0.2.12" @@ -3879,7 +4045,7 @@ dependencies = [ "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", + "signature 2.2.0", "spki 0.7.3", ] @@ -3899,7 +4065,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8 0.10.2", - "signature", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", ] [[package]] @@ -3908,14 +4085,30 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "serde", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -3931,14 +4124,14 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", "hkdf 0.12.4", - "pem-rfc7468", + "pem-rfc7468 0.7.0", "pkcs8 0.10.2", "rand_core 0.6.4", "sec1 0.7.3", @@ -3955,6 +4148,18 @@ dependencies = [ "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -3988,6 +4193,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enum-assoc" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -4183,6 +4399,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filetime" version = "0.2.29" @@ -4262,7 +4484,7 @@ checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.9", ] [[package]] @@ -4414,6 +4636,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.1", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -4771,6 +5006,21 @@ dependencies = [ "seq-macro", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if 1.0.4", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -4833,6 +5083,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.13.3" @@ -5085,6 +5345,7 @@ dependencies = [ name = "goose-cli" version = "1.47.0" dependencies = [ + "agent-client-protocol", "anstream", "anyhow", "async-trait", @@ -5104,14 +5365,17 @@ dependencies = [ "dotenvy", "env-lock", "etcetera", + "fs2", "futures", "goose", "goose-mcp", "goose-providers", + "goose-roaming", "indicatif", "libc", "ntapi", "open", + "qrcode", "rand 0.10.2", "regex", "reqwest 0.13.4", @@ -5293,6 +5557,26 @@ dependencies = [ "wiremock", ] +[[package]] +name = "goose-roaming" +version = "1.47.0" +dependencies = [ + "anyhow", + "base64 0.23.1", + "fs2", + "futures", + "iroh", + "iroh-relay", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "goose-sdk" version = "0.1.0-alpha.6" @@ -5587,6 +5871,83 @@ dependencies = [ "xet-runtime", ] +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if 1.0.4", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http 1.5.0", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "rustls", + "thiserror 2.0.20", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if 1.0.4", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hipstr" version = "0.6.0" @@ -5997,6 +6358,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -6193,11 +6560,214 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" +dependencies = [ + "axum", + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http 1.5.0", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "rand 0.10.2", + "reqwest 0.13.4", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum 0.28.0", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek 5.0.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.2", + "rustls", + "simple-dns", + "strum 0.28.0", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "reqwest 0.13.4", + "rustls", + "rustls-platform-verifier", + "ryu", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iroh-relay" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "clap", + "dashmap 6.2.1", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http 1.5.0", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.2", + "rcgen", + "reloadable-state", + "reqwest 0.13.4", + "rustls", + "rustls-cert-file-reader", + "rustls-cert-reloadable-resolver", + "rustls-pki-types", + "serde", + "serde_bytes", + "serde_json", + "sha1 0.11.0", + "simdutf8", + "strum 0.28.0", + "time", + "tokio", + "tokio-rustls", + "tokio-rustls-acme", + "tokio-util", + "tokio-websockets", + "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-subscriber", + "url", + "webpki-roots 1.0.8", + "ws_stream_wasm", +] [[package]] name = "is-docker" @@ -6323,6 +6893,22 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if 1.0.4", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -6332,7 +6918,7 @@ dependencies = [ "cfg-if 1.0.4", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.20", @@ -6353,6 +6939,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -6471,7 +7066,7 @@ checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" dependencies = [ "aws-lc-rs", "base64 0.22.1", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hmac 0.12.1", "js-sys", @@ -6483,7 +7078,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -6551,7 +7146,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.9", ] [[package]] @@ -6717,13 +7312,26 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if 1.0.4", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lopdf" version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" dependencies = [ - "aes", + "aes 0.9.2", "bitflags 2.13.0", "cbc 0.2.1", "ecb", @@ -6748,6 +7356,9 @@ name = "lru" version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "lru-slab" @@ -6764,6 +7375,12 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mach-sys" version = "0.5.4" @@ -6964,6 +7581,23 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "monch" version = "0.5.0" @@ -7008,6 +7642,59 @@ dependencies = [ "pxfm", ] +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + [[package]] name = "nanoid" version = "0.5.0" @@ -7046,6 +7733,119 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81c353b400a5503efdcf398f11a83fb7aa84f59f5d76fc4bf5bbc1e4f5366caa" +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.20", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -7083,7 +7883,7 @@ dependencies = [ "async-trait", "boxed_error", "capacity_builder", - "dashmap", + "dashmap 5.5.3", "deno_error", "deno_maybe_sync", "deno_media_type", @@ -7123,6 +7923,70 @@ dependencies = [ "memchr", ] +[[package]] +name = "noq" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "nostr" version = "0.45.2" @@ -7428,7 +8292,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -7445,6 +8311,20 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -7499,13 +8379,39 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", "objc2-core-foundation", + "objc2-security", ] [[package]] @@ -7531,6 +8437,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -7854,6 +8764,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "par-core" version = "2.0.0" @@ -8107,6 +9027,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -8155,6 +9084,16 @@ dependencies = [ "pest", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.11.3" @@ -8408,11 +9347,26 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" @@ -8423,6 +9377,30 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -8475,6 +9453,17 @@ dependencies = [ "termtree", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -8651,6 +9640,15 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image 0.25.10", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -9025,6 +10023,23 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reloadable-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0" + +[[package]] +name = "reloadable-state" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8" +dependencies = [ + "arc-swap", + "reloadable-core", + "tokio", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -9143,6 +10158,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -9287,7 +10308,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "spki 0.7.3", "subtle", "zeroize", @@ -9376,6 +10397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -9384,6 +10406,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-cert-file-reader" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff" +dependencies = [ + "rustls-cert-read", + "rustls-pki-types", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "rustls-cert-read" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-cert-reloadable-resolver" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8" +dependencies = [ + "futures-util", + "reloadable-state", + "rustls", + "rustls-cert-read", + "thiserror 2.0.20", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -9414,7 +10470,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -9725,7 +10781,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der 0.7.10", "generic-array", "pkcs8 0.10.2", @@ -9799,6 +10855,16 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "semver" version = "1.0.28" @@ -9809,6 +10875,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "seq-macro" version = "0.3.6" @@ -10013,6 +11085,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "serial_test" version = "4.0.1" @@ -10057,6 +11139,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -10137,6 +11225,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "sigstore-bundle" version = "0.11.0" @@ -10168,7 +11265,7 @@ dependencies = [ "pem 3.0.6", "rand_core 0.9.5", "sha2 0.10.9", - "signature", + "signature 2.2.0", "sigstore-types", "spki 0.7.3", "thiserror 2.0.20", @@ -10350,6 +11447,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -10435,6 +11541,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + [[package]] name = "sourcemap" version = "9.3.2" @@ -10453,6 +11565,17 @@ dependencies = [ "url", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "spin" version = "0.9.9" @@ -10462,6 +11585,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.7.3" @@ -11536,6 +12665,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -11794,6 +12929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -12037,6 +13173,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls-acme" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "log", + "num-bigint", + "pem 3.0.6", + "proc-macro2", + "rcgen", + "reqwest 0.13.4", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-rustls", + "webpki-roots 1.0.8", + "x509-parser", +] + [[package]] name = "tokio-socks" version = "0.5.3" @@ -12105,6 +13269,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http 1.5.0", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "tokio_with_wasm" version = "0.8.8" @@ -12674,7 +13862,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "560ef3816b56fce89975a58a7f271c57f7b3873aac8fba70572f93f3683be405" dependencies = [ - "aes", + "aes 0.9.2", "base64 0.22.1", "byteorder", "cbc 0.2.1", @@ -13265,7 +14453,7 @@ version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "jni", + "jni 0.22.4", "log", "ndk-context", "objc2", @@ -13369,6 +14557,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -13574,6 +14768,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -13610,6 +14813,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -13652,6 +14870,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -13664,6 +14888,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -13676,6 +14906,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -13700,6 +14936,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -13712,6 +14954,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -13724,6 +14972,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -13736,6 +14990,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -13798,12 +15058,46 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.20", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.20", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.5.1" @@ -13839,7 +15133,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "sha1 0.10.6", - "signature", + "signature 2.2.0", "spki 0.7.3", "tls_codec", ] diff --git a/Cargo.toml b/Cargo.toml index 84e3feac7..cc0888bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ http = { version = "1.1", default-features = false, features = ["std"] } ignore = { version = "0.4.12", default-features = false } include_dir = { version = "0.7", default-features = false } indoc = { version = "2", default-features = false } +iroh = { version = "=1.0.3", default-features = false } +iroh-relay = { version = "1", default-features = false } keyring = { version = "3.6.3", default-features = false, features = ["vendored"] } lru = { version = "0.18", default-features = false } once_cell = { version = "1.21.3", default-features = false, features = ["std"] } diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index fc863ef5e..85bfc1dd5 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -26,8 +26,10 @@ path = "src/bin/mcp_conformance_driver.rs" [dependencies] clap_mangen = { version = "0.3", default-features = false } goose = { path = "../goose", default-features = false } +agent-client-protocol = { workspace = true, features = ["unstable"], optional = true } goose-mcp = { path = "../goose-mcp", default-features = false } goose-providers = { path = "../goose-providers", default-features = false } +goose-roaming = { path = "../goose-roaming", optional = true } rmcp = { workspace = true } clap = { workspace = true } cliclack = { version = "0.5", default-features = false } @@ -72,6 +74,8 @@ snap = { workspace = true, optional = true } axum.workspace = true axum-server = { workspace = true, optional = true } clap_complete_nushell = { version = "4", default-features = false } +qrcode = { version = "0.14.1", optional = true } +fs2 = { workspace = true, optional = true } [target.'cfg(target_os = "windows")'.dependencies] anstream = { version = "1", default-features = false, features = ["wincon"] } @@ -98,6 +102,7 @@ default = [ "system-keyring", "update", ] +roaming = ["dep:goose-roaming", "dep:qrcode", "dep:fs2", "dep:agent-client-protocol"] code-mode = ["goose/code-mode"] local-inference = ["goose/local-inference"] aws-providers = ["goose/aws-providers"] @@ -130,7 +135,6 @@ native-tls = [ "goose-mcp/native-tls", "goose-providers/native-tls", ] - [dev-dependencies] env-lock.workspace = true tempfile = { workspace = true } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5252d7f7f..916ff75de 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -18,6 +18,8 @@ use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::plugin::{handle_plugin_install, handle_plugin_update}; use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; +#[cfg(feature = "roaming")] +use crate::commands::roam::{handle_roam_command, RoamCommand}; use crate::commands::term::{ handle_term_info, handle_term_init, handle_term_log, handle_term_run, Shell, }; @@ -840,6 +842,14 @@ enum Command { enable_scheduler: bool, }, + /// Share or connect to agents peer-to-peer over iroh + #[cfg(feature = "roaming")] + #[command(about = "Share or connect to agents peer-to-peer (roaming)")] + Roam { + #[command(subcommand)] + command: RoamCommand, + }, + /// Start ACP server over HTTP and WebSocket #[command(about = "Start ACP server over HTTP and WebSocket")] Serve { @@ -887,6 +897,14 @@ enum Command { #[arg(long, help = "Enable scheduled recipe execution")] enable_scheduler: bool, + + /// Also expose this server over goose roam (p2p) so paired devices can connect remotely + #[cfg(feature = "roaming")] + #[arg( + long, + help = "Also expose this server over goose roam (p2p) so paired devices can connect remotely" + )] + roam: bool, }, /// Start or resume interactive chat sessions @@ -1356,6 +1374,8 @@ fn get_command_name(command: &Option) -> &'static str { Some(Command::Info { .. }) => "info", Some(Command::Mcp { .. }) => "mcp", Some(Command::Acp { .. }) => "acp", + #[cfg(feature = "roaming")] + Some(Command::Roam { .. }) => "roam", Some(Command::Serve { .. }) => "serve", Some(Command::Session { .. }) => "session", Some(Command::Run { .. }) => "run", @@ -1606,6 +1626,127 @@ struct ServeCommandArgs { dangerously_unauthenticated: bool, allowed_origins: Vec, enable_scheduler: bool, + #[cfg(feature = "roaming")] + roam: bool, +} + +#[cfg(feature = "roaming")] +type RoamShareSlot = + std::sync::Arc>>>; + +#[cfg(feature = "roaming")] +fn spawn_roam_share( + server: std::sync::Arc, +) -> RoamShareSlot { + use crate::commands::roam::try_acquire_roam_lock_owner; + + let slot = RoamShareSlot::default(); + let task_slot = slot.clone(); + tokio::spawn(async move { + let mut standing_by = false; + loop { + match try_acquire_roam_lock_owner() { + Ok(Some(lock)) => match start_roam_share(server.clone()).await { + Ok(node) => { + *task_slot.write().await = Some(node); + let _lock = lock; + std::future::pending::<()>().await; + } + Err(error) => { + tracing::error!("roam share failed to start: {error}"); + drop(lock); + } + }, + Ok(None) => { + if !standing_by { + standing_by = true; + eprintln!( + "another goose process owns the roaming endpoint; standing by to take over if it exits" + ); + } + } + // A real failure (unwritable data dir, filesystem error) will + // not fix itself: surface it and stop instead of retrying as + // if the endpoint were merely busy. + Err(error) => { + eprintln!("roaming disabled: cannot acquire the endpoint lock: {error:#}"); + return; + } + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + }); + slot +} + +#[cfg(feature = "roaming")] +async fn start_roam_share( + server: std::sync::Arc, +) -> Result> { + use crate::commands::roam::{ + directory_path, load_identity, resolve_relay_settings, trust_path, + }; + use crate::commands::roam_full_bridge::FullAcpBridge; + use goose::config::paths::Paths; + use goose_roaming::{RoamingConfig, RoamingNode, TrustBook}; + use std::sync::Arc; + + let status_path = Paths::data_dir().join("roam/serve.json"); + let _ = std::fs::remove_file(&status_path); + + let identity = load_identity()?; + let node = RoamingNode::bind(RoamingConfig { + identity, + relay: resolve_relay_settings()?, + trust: TrustBook::new(), + trust_path: Some(trust_path()), + directory: goose_roaming::Directory::persistent_owned(directory_path()), + bind_addr: None, + relay_tls: None, + }) + .await?; + + let agent_id = node.endpoint_id().to_string(); + // Roaming sessions run where `goose serve` was started: the connector's + // machine-local path is meaningless on this host, and the serve-wide + // server keeps `session_cwd: None` for local ACP clients. + let session_cwd = + std::env::current_dir().map_err(|e| anyhow::anyhow!("could not determine cwd: {e}"))?; + node.share(Arc::new(FullAcpBridge::new(server, agent_id, session_cwd))) + .await?; + + if !node.wait_online(std::time::Duration::from_secs(15)).await { + tracing::warn!( + "roaming endpoint did not come online; the card may lack a reachable address" + ); + } + + let card = node.card(); + let card_encoded = card.encode()?; + let started_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let status = serde_json::json!({ + "card": card_encoded, + "endpointId": card.endpoint_id.to_string(), + "fingerprint": card.fingerprint(), + "startedAt": started_at, + }); + if let Some(dir) = status_path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp_path = status_path.with_extension("json.tmp"); + std::fs::write(&tmp_path, serde_json::to_vec_pretty(&status)?)?; + std::fs::rename(&tmp_path, &status_path)?; + + eprintln!("roam is enabled for this server"); + eprintln!(" endpoint id : {}", card.endpoint_id); + eprintln!(" fingerprint : {}", card.fingerprint()); + eprintln!("your connection card (share with a peer so it can reach you):"); + eprintln!("{card_encoded}"); + + Ok(node) } async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { @@ -1629,6 +1770,8 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { dangerously_unauthenticated, allowed_origins, enable_scheduler, + #[cfg(feature = "roaming")] + roam, } = args; let builtins = AcpBuiltinSelection::from_requested(builtins); @@ -1651,6 +1794,7 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { config_dir: Paths::config_dir(), goose_platform: platform.into(), additional_source_roots, + session_cwd: None, enable_scheduler, })); let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) @@ -1684,6 +1828,12 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { if let Err(error) = server.start_scheduler().await { warn!("Scheduler failed to start; scheduled jobs will not run until a client connects: {error}"); } + #[cfg(feature = "roaming")] + let roam_share = if roam { + Some(spawn_roam_share(server.clone())) + } else { + None + }; let router = create_router( server, secret_key, @@ -1741,6 +1891,13 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { .await?; } + #[cfg(feature = "roaming")] + if let Some(slot) = roam_share { + if let Some(node) = slot.write().await.take() { + let _ = node.shutdown().await; + } + } + Ok(()) } @@ -2652,6 +2809,8 @@ pub async fn cli() -> anyhow::Result<()> { builtins, enable_scheduler, }) => goose::acp::server::run(builtins, enable_scheduler).await, + #[cfg(feature = "roaming")] + Some(Command::Roam { command }) => handle_roam_command(command).await, Some(Command::Serve { host, port, @@ -2663,6 +2822,8 @@ pub async fn cli() -> anyhow::Result<()> { dangerously_unauthenticated, allowed_origins, enable_scheduler, + #[cfg(feature = "roaming")] + roam, }) => { handle_serve_command(ServeCommandArgs { host, @@ -2675,6 +2836,8 @@ pub async fn cli() -> anyhow::Result<()> { dangerously_unauthenticated, allowed_origins, enable_scheduler, + #[cfg(feature = "roaming")] + roam, }) .await } diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index e677c078b..cbfd7c29e 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -5,6 +5,14 @@ pub mod info; pub mod plugin; pub mod recipe; pub mod review; +#[cfg(feature = "roaming")] +pub mod roam; +#[cfg(feature = "roaming")] +pub mod roam_client; +#[cfg(feature = "roaming")] +pub mod roam_full_bridge; +#[cfg(feature = "roaming")] +pub mod roam_proxy; pub mod schedule; pub mod session; pub mod skills; diff --git a/crates/goose-cli/src/commands/roam.rs b/crates/goose-cli/src/commands/roam.rs new file mode 100644 index 000000000..ce1781e20 --- /dev/null +++ b/crates/goose-cli/src/commands/roam.rs @@ -0,0 +1,972 @@ +//! `goose roam` — peer-to-peer agent access over iroh. +//! +//! The model is deliberately infrastructural: roaming is just an authenticated +//! p2p ACP transport. Each node has one identity and produces a **connection +//! card** (`roam id`) — a non-secret string carrying its public key and how to +//! reach it. You swap cards with another node and each side chooses to **accept** +//! the other's key. A connection only succeeds when the host has accepted the +//! dialer's key; there is no bearer token that grants access by possession. An +//! accepted peer gets goose's full ACP surface. +//! +//! Subcommands: +//! * `id` — print this node's connection card (share it with a peer). +//! * `share` — serve this node's agent to accepted peers over ACP. +//! * `peers` — manage saved peer cards and which keys you accept. +//! * `connect` / `delegate` / `bridge` — reach a peer that has accepted you. +//! * `connections` (alias `list`) — show live/observed connections. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Subcommand; +use goose::acp::server::AcpBuiltinSelection; +use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; +use goose::agents::GoosePlatform; +use goose::config::paths::Paths; +use goose::config::{Config, ConfigError}; +use goose_roaming::{ + default_key_path, parse_endpoint_id, ConnectionCard, Directory, EndpointId, RelayEntry, + RelaySettings, RoamingConfig, RoamingIdentity, RoamingNode, TrustBook, +}; + +use crate::commands::roam_full_bridge::FullAcpBridge; + +const CARD_SCHEME: &str = "goose+roam://"; + +pub(crate) fn directory_path() -> std::path::PathBuf { + Paths::state_dir().join("roaming_directory.json") +} + +pub(crate) fn trust_path() -> std::path::PathBuf { + Paths::config_dir().join("roaming_trust.json") +} + +fn peerbook_path() -> std::path::PathBuf { + Paths::config_dir().join("roaming_peers.json") +} + +/// Config key (or `GOOSE_ROAM_RELAYS` env) overriding the relay URLs the +/// roaming endpoint uses. When unset, roaming uses the managed relays below — +/// never iroh's shared public n0 relays. +const CONFIG_ROAM_RELAYS_KEY: &str = "GOOSE_ROAM_RELAYS"; +/// Optional shared bearer token (secret) presented to every *explicitly +/// configured* relay (for gated / `AccessConfig::Restricted` relays). Not +/// applied to the default managed relays, which register open. +const CONFIG_ROAM_RELAY_TOKEN_KEY: &str = "GOOSE_ROAM_RELAY_TOKEN"; + +/// Default managed iroh relays — the same four dedicated relays mesh-llm uses +/// (provisioned via services.iroh.computer on the `iroh.link` domain), one per +/// region for global coverage. They register open (`AccessConfig::Everyone`, no +/// auth token), so a browser client can reach them over `wss://` with no auth +/// header. We default to these rather than iroh's public n0 relays so roaming +/// never depends on the shared, rate-limited, no-SLA public relays. +/// +/// Override with `GOOSE_ROAM_RELAYS` to point at other relays (e.g. a Block-run +/// deployment). +const DEFAULT_ROAM_RELAYS: &[&str] = &[ + "https://usw1-2.relay.michaelneale.mesh-llm.iroh.link./", // US West + "https://use1-1.relay.michaelneale.mesh-llm.iroh.link./", // US East + "https://euc1-1.relay.michaelneale.mesh-llm.iroh.link./", // EU Central + "https://aps1-1.relay.michaelneale.mesh-llm.iroh.link./", // Asia-Pacific South +]; + +/// Resolve the relay settings for a roaming endpoint. +/// +/// Uses `GOOSE_ROAM_RELAYS` (env or config file) when set — optionally +/// authenticated with the `GOOSE_ROAM_RELAY_TOKEN` secret applied to each — +/// otherwise the default managed relays. Never iroh's public n0 relays. +/// +/// Fails when `GOOSE_ROAM_RELAYS` is set but unreadable: a deployment that +/// configured private relays must not silently fall back to the managed ones. +pub(crate) fn resolve_relay_settings() -> Result { + let config = Config::global(); + let urls: Vec = match config.get_param::>(CONFIG_ROAM_RELAYS_KEY) { + Ok(urls) => urls, + Err(ConfigError::NotFound(_)) => Vec::new(), + Err(error) => { + return Err(anyhow::anyhow!( + "{CONFIG_ROAM_RELAYS_KEY} is set but could not be read as a list of relay \ + URLs; refusing to fall back to the default relays: {error}" + )) + } + }; + // Only explicit relay URLs use a token; skip the secret-store read (and + // any keyring stall it may involve) entirely on the default-relay path. + if urls.iter().all(|u| u.trim().is_empty()) { + return Ok(build_relay_settings(urls, None)); + } + let token = match config.get_secret::(CONFIG_ROAM_RELAY_TOKEN_KEY) { + Ok(token) => Some(token), + Err(ConfigError::NotFound(_)) => None, + // A configured token that cannot be read (keyring timeout, corrupt + // store) must not silently become unauthenticated relay dials. + Err(error) => { + return Err(anyhow::anyhow!( + "{CONFIG_ROAM_RELAY_TOKEN_KEY} could not be read; refusing to contact \ + relays without the configured token: {error}" + )) + } + }; + Ok(build_relay_settings(urls, token)) +} + +/// Pure relay-settings builder. With no configured URLs, use the default +/// managed relays (open, no token). With configured URLs (empty entries +/// filtered out), use those, applying a non-empty token to each. +fn build_relay_settings(urls: Vec, token: Option) -> RelaySettings { + let urls: Vec = urls.into_iter().filter(|u| !u.trim().is_empty()).collect(); + if urls.is_empty() { + let entries = DEFAULT_ROAM_RELAYS + .iter() + .map(|url| RelayEntry::new(*url)) + .collect(); + return RelaySettings::Custom(entries); + } + let token = token.filter(|t| !t.is_empty()); + let entries = urls + .into_iter() + .map(|url| match &token { + Some(token) => RelayEntry::with_auth(url, token.clone()), + None => RelayEntry::new(url), + }) + .collect(); + RelaySettings::Custom(entries) +} + +#[derive(Debug, Subcommand)] +pub enum RoamCommand { + /// Print this node's connection card — the non-secret string you share with + /// a peer so it can find and identify this node. Nothing in it is a secret; + /// a peer must still be accepted (`roam peers accept`) before it can connect. + #[command(visible_alias = "card")] + Id { + /// Also render the card as a QR code in the terminal (scan from a phone). + #[arg(long)] + qr: bool, + }, + + /// Pair a new device interactively: shows this node's card as a QR code, + /// then reads the device's card from stdin and accepts it in one step + /// (the equivalent of `roam peers add` + `roam peers accept`). + Pair { + /// Nickname to save the device under (defaults to `device-`). + #[arg(long)] + name: Option, + }, + + /// Serve this node's agent to accepted peers over ACP. + /// + /// Only peers whose key you have accepted (`roam peers accept`) can connect. + /// Each connected peer gets goose's full ACP surface — it drives its own + /// sessions (new/list/load/prompt) backed by this node's session store. + Share { + /// Builtin extensions to load into the hosted agent. + #[arg(long = "with-builtin", value_delimiter = ',')] + builtins: Vec, + + /// Working directory the hosted agent runs in. Defaults to the directory + /// `roam share` was started in. The connecting client's own path is + /// always ignored. + #[arg(long)] + cwd: Option, + + /// Also render the connection card as a QR code in the terminal. + #[arg(long)] + qr: bool, + }, + + /// Open a quick interactive REPL against a remote agent (debug/peek). + /// + /// This is a minimal built-in chat loop, handy for a quick sanity check. For + /// real work, prefer `bridge` (drive the remote agent from Zed or any other + /// ACP client) or `delegate` (scriptable one-shot tasks). + Connect { + /// A saved peer nickname (see `roam peers`) or a `goose+roam://...` card. + target: String, + + /// Optional label reported to the host's directory. + #[arg(long)] + label: Option, + }, + + /// Delegate a one-shot task to a remote agent and print its response. + /// + /// This is a thin ACP client: it connects, opens a session (new, or the one + /// named by `--session`), sends the task as a prompt, prints the reply, and + /// exits. Session enumeration and resume are plain ACP (`session/list` / + /// `session/load`) served by the remote's full ACP surface. + Delegate { + /// A saved peer nickname (see `roam peers`) or a `goose+roam://...` card. + target: String, + /// The task/question to send to the remote agent. Omit when using + /// `--list-sessions`. + task: Option, + /// Run the task against an existing remote session id (via `session/load`) + /// instead of a fresh session. List ids with `--list-sessions`. + #[arg(long, value_name = "SESSION_ID")] + session: Option, + /// List the remote agent's sessions (`session/list`) and exit. + #[arg(long)] + list_sessions: bool, + }, + + /// Expose a remote agent as a local ACP endpoint that any ACP client can drive. + /// + /// Unlike `connect` (which has its own terminal UI), `bridge` runs no UI and + /// no agent: it transparently proxies ACP between a local transport and the + /// remote agent. Point Zed or any other ACP client at it + /// and the remote agent behaves as if it were running locally. + /// + /// Defaults to stdio (for a client that spawns `goose roam bridge ...` as a + /// subprocess). Use `--listen` to accept a single TCP connection instead. + Bridge { + /// A saved peer nickname (see `roam peers`) or a `goose+roam://...` card. + target: String, + + /// Listen for one ACP client on this TCP address (e.g. `127.0.0.1:8900`) + /// instead of using stdio. Loopback only: the TCP side carries the + /// remote agent's full ACP surface with no authentication of its own. + #[arg(long, value_name = "ADDR")] + listen: Option, + + /// Allow `--listen` on a non-loopback address. Anyone who can reach + /// the socket gets the remote agent — put real authentication or a + /// private network in front of it. + #[arg(long, requires = "listen")] + allow_remote_clients: bool, + + /// Optional label reported to the host's directory. + #[arg(long)] + label: Option, + }, + + /// Manage saved peer cards and which peer keys this node accepts. + Peers { + #[command(subcommand)] + command: Option, + }, + + /// Show live/observed connections to and from this node. + #[command(visible_alias = "list")] + Connections, +} + +#[derive(Debug, Subcommand)] +pub enum PeersCommand { + /// Save a peer's connection card to the address book so you can reach it by + /// name. Does NOT let them connect to you — use `accept` for that. + Add { + /// The peer's `goose+roam://...` card. + card: String, + /// Friendly nickname (defaults to a short id if omitted). + name: Option, + }, + /// Accept inbound connections from a peer's key. The target is a saved + /// nickname or a `goose+roam://...` card (which is also saved to the address + /// book). An accepted peer gets goose's full ACP surface. + Accept { + /// A saved nickname or a `goose+roam://...` card. + target: String, + /// Nickname to save an inline card under (defaults to a short id). + /// Ignored when the target is already a saved nickname. + name: Option, + }, + /// Stop accepting a peer: a saved nickname, a card, or a raw endpoint id. + /// A running share force-closes the peer's live connections within seconds. + Revoke { target: String }, + /// Remove a saved peer from the address book (does not change acceptance). + Remove { name: String }, + /// Rename a saved peer. + Rename { from: String, to: String }, + /// List saved peers and which keys are accepted (default). + List, +} + +pub async fn handle_roam_command(command: RoamCommand) -> Result<()> { + match command { + RoamCommand::Id { qr } => handle_id(qr).await, + RoamCommand::Pair { name } => handle_pair(name).await, + RoamCommand::Share { builtins, cwd, qr } => handle_share(builtins, cwd, qr).await, + RoamCommand::Connect { target, label } => handle_connect(target, label).await, + RoamCommand::Delegate { + target, + task, + session, + list_sessions, + } => handle_delegate(target, task, session, list_sessions).await, + RoamCommand::Bridge { + target, + listen, + allow_remote_clients, + label, + } => handle_bridge(target, listen, allow_remote_clients, label).await, + RoamCommand::Peers { command } => handle_peers(command.unwrap_or(PeersCommand::List)).await, + RoamCommand::Connections => handle_list().await, + } +} + +/// Bind a node briefly to read its live card (id + relay URLs), waiting for a +/// relay so the card carries a reachable address. +/// Render a card as a terminal QR code (unicode half-blocks) to stderr. +fn print_qr(card: &str) { + match qrcode::QrCode::new(card.as_bytes()) { + Ok(code) => { + let art = code + .render::() + .quiet_zone(true) + .build(); + eprintln!("{art}"); + eprintln!("scan with a phone camera, then paste the decoded card into your client"); + } + Err(err) => eprintln!("could not render QR: {err}"), + } +} + +async fn handle_id(qr: bool) -> Result<()> { + let identity = load_identity()?; + let node = RoamingNode::bind(RoamingConfig { + identity, + relay: resolve_relay_settings()?, + trust: TrustBook::new(), + trust_path: None, + directory: Directory::new(), + bind_addr: None, + relay_tls: None, + }) + .await?; + eprintln!("contacting relay so the card carries a reachable address..."); + node.wait_online(std::time::Duration::from_secs(15)).await; + let card = node.card(); + eprintln!("your connection card (share this with a peer):"); + println!("{}", card.encode()?); + if qr { + eprintln!(); + print_qr(&card.encode()?); + } + eprintln!(); + eprintln!(" endpoint id : {}", card.endpoint_id); + eprintln!(" fingerprint : {}", card.fingerprint()); + eprintln!(); + eprintln!("the peer adds it with: goose roam peers add '' "); + eprintln!("and accepts you with: goose roam peers accept "); + node.shutdown().await?; + Ok(()) +} + +/// Interactive one-shot pairing: show this node's card + QR, read the device's +/// card from stdin, confirm its fingerprint, then save and accept it — the +/// same PeerBook/TrustBook writes as `peers add` + `peers accept`. +async fn handle_pair(name: Option) -> Result<()> { + let identity = load_identity()?; + let node = RoamingNode::bind(RoamingConfig { + identity, + relay: resolve_relay_settings()?, + trust: TrustBook::new(), + trust_path: None, + directory: Directory::new(), + bind_addr: None, + relay_tls: None, + }) + .await?; + eprintln!("contacting relay so the card carries a reachable address..."); + node.wait_online(std::time::Duration::from_secs(15)).await; + let card = node.card(); + let encoded = card.encode()?; + eprintln!("your connection card:"); + println!("{encoded}"); + eprintln!(); + print_qr(&encoded); + eprintln!(); + eprintln!(" endpoint id : {}", card.endpoint_id); + eprintln!(" fingerprint : {}", card.fingerprint()); + node.shutdown().await?; + eprintln!(); + eprintln!("on the new device: scan the QR with its client (or paste the card),"); + eprintln!("then copy the card from its pairing screen back here."); + eprintln!(); + + eprint!("paste the device's card (from its pairing screen): "); + let device_card = read_stdin_line()?; + let device_card = device_card.trim(); + if device_card.is_empty() { + anyhow::bail!("no card entered; pairing cancelled"); + } + let decoded = ConnectionCard::decode(device_card) + .context("that does not look like a goose+roam:// connection card")?; + let name = + name.unwrap_or_else(|| format!("device-{}", short_id(&decoded.endpoint_id.to_string()))); + + eprintln!(); + eprintln!(" endpoint id : {}", decoded.endpoint_id); + eprintln!(" fingerprint : {}", decoded.fingerprint()); + eprintln!("verify the fingerprint matches the one shown on the device."); + eprint!("accept this device as `{name}`? [y/N] "); + let answer = read_stdin_line()?; + if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") { + eprintln!("pairing cancelled; nothing was saved"); + return Ok(()); + } + + goose_roaming::PeerBook::update(peerbook_path(), |book| { + book.save(&name, device_card, now_ms()) + })?; + let path = trust_path(); + TrustBook::update(&path, |trust| trust.accept(&decoded.endpoint_id)).with_context(|| { + format!( + "trust file {} is unreadable or corrupt; refusing to modify it", + path.display() + ) + })?; + + eprintln!("saved and accepted `{name}` ({})", decoded.endpoint_id); + eprintln!("done — the device can now connect to any share/serve on this machine"); + Ok(()) +} + +fn read_stdin_line() -> Result { + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .context("failed to read from stdin")?; + Ok(line) +} + +async fn handle_peers(command: PeersCommand) -> Result<()> { + let book = goose_roaming::PeerBook::load(peerbook_path())?; + match command { + PeersCommand::Add { card, name } => { + let decoded = ConnectionCard::decode(&card)?; + let name = name.unwrap_or_else(|| short_id(&decoded.endpoint_id.to_string())); + goose_roaming::PeerBook::update(peerbook_path(), |book| { + book.save(&name, &card, now_ms()) + })?; + eprintln!( + "saved peer `{name}` -> {} (fingerprint {})", + decoded.endpoint_id, + decoded.fingerprint() + ); + eprintln!("accept connections from it with: goose roam peers accept {name}"); + Ok(()) + } + PeersCommand::Accept { target, name } => { + // Resolve to a card: a saved name, or an inline card we also save. + let card = match ConnectionCard::decode(&target) { + Ok(card) => { + let name = name.unwrap_or_else(|| short_id(&card.endpoint_id.to_string())); + goose_roaming::PeerBook::update(peerbook_path(), |book| { + book.save(&name, &target, now_ms()) + })?; + card + } + Err(_) => { + if name.is_some() { + eprintln!("note: `{target}` is a saved peer; ignoring the extra name arg"); + } + let rec = book.get(&target).ok_or_else(|| { + anyhow::anyhow!( + "no saved peer `{target}` and it is not a card; add it first with \ + `goose roam peers add`" + ) + })?; + rec.card.clone() + } + }; + let path = trust_path(); + TrustBook::update(&path, |trust| trust.accept(&card.endpoint_id)).with_context( + || { + format!( + "trust file {} is unreadable or corrupt; refusing to modify it", + path.display() + ) + }, + )?; + eprintln!("accepting connections from {}", card.endpoint_id); + eprintln!("verify the fingerprint out of band: {}", card.fingerprint()); + eprintln!("a running `goose roam share` picks this up on the next connection"); + Ok(()) + } + PeersCommand::Revoke { target } => { + let key = resolve_key(&book, &target)?; + let path = trust_path(); + TrustBook::update(&path, |trust| trust.revoke_key(&key)).with_context(|| { + format!( + "trust file {} is unreadable or corrupt; refusing to modify it", + path.display() + ) + })?; + eprintln!("revoked {key}; it can no longer connect"); + eprintln!("a running share also force-closes its live connections within seconds"); + Ok(()) + } + PeersCommand::Remove { name } => { + let existed = + goose_roaming::PeerBook::update(peerbook_path(), |book| book.remove(&name))?; + if existed { + eprintln!("removed peer `{name}` from the address book"); + } else { + eprintln!("no peer named `{name}`"); + } + Ok(()) + } + PeersCommand::Rename { from, to } => { + goose_roaming::PeerBook::update(peerbook_path(), |book| book.rename(&from, &to))?; + eprintln!("renamed `{from}` -> `{to}`"); + Ok(()) + } + PeersCommand::List => { + let trust = + TrustBook::load(&trust_path()).context("trust file is unreadable or corrupt")?; + let accepted: std::collections::HashSet = + trust.allowed_keys().into_iter().collect(); + let peers = book.list(); + if peers.is_empty() && accepted.is_empty() { + eprintln!("no saved peers; add one with `goose roam peers add '' `"); + return Ok(()); + } + println!("{:<16} {:<8} ENDPOINT ID", "NAME", "ACCEPT"); + for p in &peers { + let accept = if accepted.contains(&p.endpoint_id) { + "yes" + } else { + "no" + }; + println!("{:<16} {accept:<8} {}", p.name, p.endpoint_id); + } + // Accepted keys with no saved card (accepted by raw id). + let known: std::collections::HashSet = + peers.iter().map(|p| p.endpoint_id.clone()).collect(); + for id in &accepted { + if !known.contains(id) { + println!("{:<16} {:<8} {id}", "(unsaved)", "yes"); + } + } + Ok(()) + } + } +} + +/// Resolve a target (saved nickname, inline card, or raw endpoint id) to a key. +fn resolve_key(book: &goose_roaming::PeerBook, target: &str) -> Result { + if let Ok(card) = ConnectionCard::decode(target) { + return Ok(card.endpoint_id); + } + if let Some(rec) = book.get(target) { + return Ok(rec.card.endpoint_id); + } + parse_endpoint_id(target) + .map_err(|_| anyhow::anyhow!("`{target}` is not a saved peer, a card, or an endpoint id")) +} + +fn short_id(id: &str) -> String { + id.chars().take(12).collect() +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +async fn handle_list() -> Result<()> { + let entries = Directory::read_persisted(&directory_path()); + if entries.is_empty() { + eprintln!("no roaming peers recorded yet"); + return Ok(()); + } + println!("{:<10} {:<9} {:<20} ENDPOINT ID", "STATUS", "DIR", "AGENT"); + for e in entries { + let status = if e.connected { "connected" } else { "seen" }; + let dir = match e.direction { + goose_roaming::Direction::Inbound => "inbound", + goose_roaming::Direction::Outbound => "outbound", + }; + let agent = e.agent_id.unwrap_or_else(|| "-".to_string()); + let agent = if agent.chars().count() > 20 { + let truncated: String = agent.chars().take(19).collect(); + format!("{truncated}…") + } else { + agent + }; + println!("{status:<10} {dir:<9} {agent:<20} {}", e.endpoint_id); + } + Ok(()) +} + +/// This node's single long-lived identity. Its public key is what peers accept +/// and what the connection card advertises. +pub(crate) fn load_identity() -> Result { + let path = default_key_path(&Paths::config_dir()); + RoamingIdentity::load_or_create(&path).context("failed to load roaming identity") +} + +/// Roaming is an app-level service: every backend loads the same persisted +/// identity, so only one process may advertise the endpoint at a time. An OS +/// advisory lock decides ownership across all goose processes (desktop +/// windows, CLI serves, standalone shares); it auto-releases when the owner +/// dies — even on SIGKILL — so a standby can promote itself and paired +/// devices keep access. +/// `Ok(Some(file))` holds the lock; `Ok(None)` means another live process owns +/// the endpoint (standby is reasonable); `Err` is a real failure — unwritable +/// data dir, filesystem error — that must be surfaced, not retried silently. +pub(crate) fn try_acquire_roam_lock_owner() -> Result> { + use fs2::FileExt as _; + use std::io::Write as _; + + let lock_path = Paths::data_dir().join("roam/serve.lock"); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create roam lock dir {}", parent.display()))?; + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .with_context(|| format!("cannot open roam lock file {}", lock_path.display()))?; + if file.try_lock_exclusive().is_err() { + return Ok(None); + } + file.set_len(0)?; + writeln!(file, "{}", std::process::id())?; + Ok(Some(file)) +} + +pub(crate) fn try_acquire_roam_lock() -> Result { + try_acquire_roam_lock_owner()?.ok_or_else(|| { + anyhow::anyhow!("another goose process is already running the roaming endpoint") + }) +} + +async fn handle_share( + builtins: Vec, + cwd: Option, + qr: bool, +) -> Result<()> { + // Same app-level lock as `serve --roam`: both bind the one persisted + // identity, and two processes advertising the same endpoint ID would race + // for connections while hosting different working directories. + let _roam_lock = try_acquire_roam_lock()?; + let identity = load_identity()?; + + // The hosted agent runs in `--cwd` or the directory `roam share` was started + // in; the connecting client's own path is meaningless here and is ignored. + let session_cwd = match &cwd { + Some(dir) => std::fs::canonicalize(dir) + .with_context(|| format!("invalid --cwd: {}", dir.display()))?, + None => std::env::current_dir().context("could not determine current directory")?, + }; + // Fail here rather than advertising a share whose every session activation + // would reject the host-imposed path. + anyhow::ensure!( + session_cwd.is_dir(), + "--cwd must be a directory: {}", + session_cwd.display() + ); + + // Load the accepted-peer allowlist. Peers are accepted out of band with + // `roam peers accept`; this serve loop re-reads it per connection. + let trust = TrustBook::load(&trust_path()) + .context("trust file is unreadable or corrupt; no peer can connect until it is fixed")?; + let accepted_count = trust.allowed_keys().len(); + if accepted_count == 0 { + eprintln!( + "warning: no peers are accepted yet — no one can connect.\n\ + Accept a peer's key first: goose roam peers accept " + ); + } + + // The developer extension is on by default; explicitly requested builtins + // are always loaded on top. + let builtins = AcpBuiltinSelection { + defaults: vec!["developer".to_string()], + explicit: builtins, + }; + + let node = RoamingNode::bind(RoamingConfig { + identity, + relay: resolve_relay_settings()?, + trust, + // Re-read acceptance on each connection so `peers accept`/`revoke` from + // another process take effect against this live share without restart. + trust_path: Some(trust_path()), + directory: Directory::persistent_owned(directory_path()), + bind_addr: None, + relay_tls: None, + }) + .await?; + + let acp_server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins, + data_dir: Paths::data_dir(), + config_dir: Paths::config_dir(), + goose_platform: GoosePlatform::GooseCli, + additional_source_roots: Vec::new(), + session_cwd: Some(session_cwd.clone()), + enable_scheduler: false, + })); + let agent_id = node.endpoint_id().to_string(); + let bridge = Arc::new(FullAcpBridge::new( + acp_server, + agent_id, + session_cwd.clone(), + )); + node.share(bridge).await?; + + eprintln!("contacting relay..."); + if !node.wait_online(std::time::Duration::from_secs(15)).await { + eprintln!("warning: endpoint did not come online; the card may lack a reachable address"); + } + + eprintln!("roaming agent is live"); + eprintln!(" endpoint id : {}", node.endpoint_id()); + eprintln!(" working dir : {}", session_cwd.display()); + eprintln!(" accepted : {accepted_count} peer key(s)"); + eprintln!(); + eprintln!("your connection card (share with a peer so it can reach you):"); + println!("{}", node.card().encode()?); + if qr { + print_qr(&node.card().encode()?); + } + eprintln!(); + eprintln!("press Ctrl-C to stop sharing"); + + tokio::signal::ctrl_c().await?; + eprintln!("\nshutting down roaming endpoint..."); + node.shutdown().await?; + Ok(()) +} + +/// Resolve a target (saved peer nickname or inline card) to a [`ConnectionCard`]. +fn resolve_card(target: &str) -> Result { + if target.starts_with(CARD_SCHEME) { + return ConnectionCard::decode(target).map_err(Into::into); + } + let book = goose_roaming::PeerBook::load(peerbook_path())?; + match book.get(target) { + Some(rec) => Ok(rec.card.clone()), + None => anyhow::bail!( + "no saved peer named `{target}` (and it is not a card); see `goose roam peers`" + ), + } +} + +/// Bind this node and dial the target's card, returning the node + authorized +/// stream. The connection succeeds only if the remote has accepted this node's +/// key. +async fn dial_target( + target: &str, + label: Option, +) -> Result<( + std::sync::Arc, + goose_roaming::RoamingClientStream, +)> { + let card = resolve_card(target)?; + let node = RoamingNode::bind(RoamingConfig { + identity: load_identity()?, + relay: resolve_relay_settings()?, + trust: TrustBook::new(), + trust_path: None, + // Persist outbound observations so `roam connections` can show them. + directory: Directory::persistent(directory_path()), + bind_addr: None, + relay_tls: None, + }) + .await?; + eprintln!("connecting to {}...", card.endpoint_id); + let stream = node.connect(&card, label).await?; + Ok((node, stream)) +} + +async fn handle_connect(target: String, label: Option) -> Result<()> { + let (node, stream) = dial_target(&target, label).await?; + let agent_label = stream.agent_id.clone(); + eprintln!("connected to `{agent_label}`"); + let result = crate::commands::roam_client::run_interactive(stream, agent_label).await; + node.shutdown().await?; + result +} + +async fn handle_delegate( + target: String, + task: Option, + session: Option, + list_sessions: bool, +) -> Result<()> { + if list_sessions { + let (node, stream) = dial_target(&target, Some("delegate".to_string())).await?; + eprintln!("listing sessions on `{}`...", stream.agent_id); + let result = crate::commands::roam_client::list_sessions(stream).await; + node.shutdown().await?; + let sessions = result?; + if sessions.is_empty() { + eprintln!("no sessions on the remote agent"); + return Ok(()); + } + println!("{:<40} {:<20} UPDATED", "SESSION ID", "TITLE"); + for s in sessions { + let title = s.title.unwrap_or_default(); + let title = if title.chars().count() > 20 { + format!("{}…", title.chars().take(19).collect::()) + } else { + title + }; + println!( + "{:<40} {title:<20} {}", + s.session_id, + s.updated_at.unwrap_or_default() + ); + } + return Ok(()); + } + + let task = task.context("a task is required (or pass --list-sessions)")?; + let (node, stream) = dial_target(&target, Some("delegate".to_string())).await?; + match &session { + Some(id) => eprintln!("delegating to `{}` session {id}...", stream.agent_id), + None => eprintln!("delegating task to `{}`...", stream.agent_id), + } + let result = crate::commands::roam_client::delegate(stream, task, session).await; + node.shutdown().await?; + match result { + Ok(response) => { + println!("{response}"); + Ok(()) + } + Err(e) => Err(e), + } +} + +async fn handle_bridge( + target: String, + listen: Option, + allow_remote_clients: bool, + label: Option, +) -> Result<()> { + use tokio::io::AsyncWriteExt; + + // Refuse a non-loopback --listen unless explicitly overridden: the TCP + // side is an unauthenticated door to the remote agent's full ACP surface. + if let Some(addr) = &listen { + let parsed: std::net::SocketAddr = addr + .parse() + .with_context(|| format!("invalid --listen address `{addr}`"))?; + if !parsed.ip().is_loopback() && !allow_remote_clients { + anyhow::bail!( + "--listen {addr} is not a loopback address; anyone who can reach it gets the \ + remote agent with no authentication. Use 127.0.0.1/[::1], or pass \ + --allow-remote-clients if you really mean to expose it." + ); + } + } + + let label = label.or_else(|| Some("bridge".to_string())); + let (node, stream) = dial_target(&target, label).await?; + let agent_id = stream.agent_id.clone(); + // The raw iroh streams carry post-handshake ACP and already implement + // tokio's AsyncRead/AsyncWrite, so we splice them directly. `conn` must + // outlive the splice. + let goose_roaming::RoamingClientStream { + conn, + send: remote_send, + recv: remote_recv, + .. + } = stream; + + let result = match listen { + Some(addr) => { + let listener = tokio::net::TcpListener::bind(&addr).await?; + let local = listener.local_addr()?; + eprintln!("bridging remote agent `{agent_id}` on tcp://{local}"); + eprintln!("point an ACP client at this address; serving one connection"); + let (socket, peer) = listener.accept().await?; + eprintln!("ACP client connected from {peer}"); + let (lr, lw) = socket.into_split(); + crate::commands::roam_proxy::splice(lr, lw, remote_send, remote_recv).await + } + None => { + // stdio is a pure ACP transport: ONLY the splice may touch stdout. + // All status goes to stderr so an ACP client reading stdout sees a + // clean protocol stream. + eprintln!("bridging remote agent `{agent_id}` over stdio; speak ACP on stdin/stdout"); + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + crate::commands::roam_proxy::splice(stdin, stdout, remote_send, remote_recv).await + } + }; + + let _ = tokio::io::stderr().flush().await; + drop(conn); + node.shutdown().await?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_urls_uses_default_managed_relays_not_public() { + match build_relay_settings(vec![], None) { + RelaySettings::Custom(entries) => { + assert_eq!(entries.len(), DEFAULT_ROAM_RELAYS.len()); + // Managed relays register open — no auth token attached. + assert!(entries.iter().all(|e| e.auth_token.is_none())); + assert!(entries.iter().all(|e| e.url.contains("iroh.link"))); + } + other => panic!("expected Custom managed relays, got {other:?}"), + } + } + + #[test] + fn blank_urls_are_filtered_and_fall_back_to_managed() { + match build_relay_settings(vec![" ".into(), "".into()], None) { + RelaySettings::Custom(entries) => { + assert_eq!(entries.len(), DEFAULT_ROAM_RELAYS.len()); + } + other => panic!("expected Custom managed relays, got {other:?}"), + } + } + + #[test] + fn custom_urls_without_token() { + let settings = build_relay_settings(vec!["https://relay.example./".into()], None); + match settings { + RelaySettings::Custom(entries) => { + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].url, "https://relay.example./"); + assert!(entries[0].auth_token.is_none()); + } + other => panic!("expected Custom, got {other:?}"), + } + } + + #[test] + fn custom_urls_apply_nonempty_token_to_each() { + let settings = build_relay_settings( + vec!["https://a.example./".into(), "https://b.example./".into()], + Some("tok".into()), + ); + match settings { + RelaySettings::Custom(entries) => { + assert_eq!(entries.len(), 2); + assert!(entries + .iter() + .all(|e| e.auth_token.as_deref() == Some("tok"))); + } + other => panic!("expected Custom, got {other:?}"), + } + } + + #[test] + fn empty_token_is_ignored() { + let settings = + build_relay_settings(vec!["https://relay.example./".into()], Some(String::new())); + match settings { + RelaySettings::Custom(entries) => { + assert!(entries[0].auth_token.is_none()); + } + other => panic!("expected Custom, got {other:?}"), + } + } +} diff --git a/crates/goose-cli/src/commands/roam_client.rs b/crates/goose-cli/src/commands/roam_client.rs new file mode 100644 index 000000000..a4de14c2e --- /dev/null +++ b/crates/goose-cli/src/commands/roam_client.rs @@ -0,0 +1,283 @@ +//! Thin ACP client UI onto a remote roaming agent. +//! +//! Per design doc §9: `roam connect` is NOT a provider wrapper for a local +//! agent loop. The **host** runs the real agent (its tools, working dir, +//! shell); this side is just an ACP *client* that opens a session, sends +//! prompts, and renders `session/update` notifications to the terminal. +//! +//! We deliberately advertise no client filesystem/terminal capabilities and do +//! not send our local cwd — the host imposes the `share` working directory. + +use std::io::Write; + +use tokio::io::{AsyncBufReadExt, BufReader}; + +use agent_client_protocol::schema::v1::{ + ContentBlock, InitializeRequest, ListSessionsRequest, LoadSessionRequest, PromptRequest, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + SelectedPermissionOutcome, SessionId, SessionInfo, SessionNotification, SessionUpdate, +}; +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::{Agent, Client, ConnectionTo}; +use anyhow::Result; +use goose_roaming::RoamingClientStream; + +/// Run an interactive ACP session over an authorized roaming stream, reading +/// prompts from stdin until EOF / quit. +pub async fn run_interactive(stream: RoamingClientStream, agent_label: String) -> Result<()> { + let (send, recv, conn) = stream.into_futures_io(); + let transport = agent_client_protocol::ByteStreams::new(send, recv); + + Client + .builder() + .name("goose-roam") + .on_receive_notification( + async move |notification: SessionNotification, _cx| { + render_update(¬ification.update); + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + async move |request: RequestPermissionRequest, responder, _cx| { + // The host runs the agent, so tool-permission prompts originate + // there. Present them to the local user and forward the choice. + let outcome = prompt_permission(&request); + responder.respond(RequestPermissionResponse::new(outcome)) + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |cx: ConnectionTo| { + let init = cx + .send_request(InitializeRequest::new(ProtocolVersion::LATEST)) + .block_task() + .await?; + eprintln!( + "connected to remote agent `{agent_label}` (protocol {:?})", + init.protocol_version + ); + eprintln!("type a message and press enter; Ctrl-D or /quit to end.\n"); + + // The host imposes its own working directory and ignores whatever we + // send here (our local path is meaningless on the host machine). ACP + // still requires a syntactically-absolute cwd, so send a placeholder. + let cwd = std::path::PathBuf::from("/"); + let result = cx + .build_session(cwd) + .block_task() + .run_until(async |mut session| { + tracing::debug!(session_id = ?session.session_id(), "roam client: session created"); + let mut stdin = BufReader::new(tokio::io::stdin()).lines(); + loop { + eprint!("› "); + let _ = std::io::stderr().flush(); + let line = match stdin.next_line().await { + Ok(Some(l)) => l, + Ok(None) | Err(_) => break, // EOF + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if line == "/quit" || line == "/exit" { + break; + } + session.send_prompt(line)?; + // Drain updates until the turn completes; chunks are + // rendered live via the notification handler above. + let _ = session.read_to_string().await?; + println!(); + } + Ok(()) + }) + .await; + if let Err(e) = &result { + tracing::warn!("roam client: session ended with error: {e:?}"); + } + result + }) + .await?; + + drop(conn); + Ok(()) +} + +/// One-shot delegation: open a remote session, send a single task, return the +/// agent's final text response. No interactive loop, no local stdin. +/// +/// This is the reusable core a future `roam__delegate` model tool will call. +/// Permission requests are auto-cancelled: a delegated (agent-driven) session +/// must not block waiting for a human, and the caller isn't a person who can +/// answer. Loop/cost safety is the caller's concern (bounded turns/deadline). +pub async fn delegate( + stream: RoamingClientStream, + task: String, + session: Option, +) -> Result { + let (send, recv, conn) = stream.into_futures_io(); + let transport = agent_client_protocol::ByteStreams::new(send, recv); + let collected = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let sink = collected.clone(); + let replay_sink = collected.clone(); + + Client + .builder() + .name("goose-roam-delegate") + .on_receive_notification( + async move |notification: SessionNotification, _cx| { + if let SessionUpdate::AgentMessageChunk(chunk) = ¬ification.update { + if let ContentBlock::Text(text) = &chunk.content { + sink.lock().unwrap().push_str(&text.text); + } + } + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + async move |_request: RequestPermissionRequest, responder, _cx| { + // Agent-driven session: never wait on a human. Auto-cancel. + responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )) + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |cx: ConnectionTo| { + cx.send_request(InitializeRequest::new(ProtocolVersion::LATEST)) + .block_task() + .await?; + // The host imposes its own working directory and ignores whatever we + // send; ACP still requires a syntactically-absolute cwd. + let cwd = std::path::PathBuf::from("/"); + match session { + // Resume an existing remote session. The final text is + // collected by the notification handler above, so a raw + // session/prompt awaited to completion is all we need — no + // ActiveSession attachment required. + Some(id) => { + let session_id = SessionId::from(id); + cx.send_request(LoadSessionRequest::new(session_id.clone(), cwd)) + .block_task() + .await?; + // Loading replays the session's history as message chunks, + // which the notification handler collected. Discard them so + // the result is only the new response to this task. + replay_sink.lock().unwrap().clear(); + cx.send_request(PromptRequest::new(session_id, vec![task.clone().into()])) + .block_task() + .await?; + Ok(()) + } + None => { + cx.build_session(cwd) + .block_task() + .run_until(async |mut session| { + session.send_prompt(&task)?; + let _ = session.read_to_string().await?; + Ok(()) + }) + .await + } + } + }) + .await?; + + drop(conn); + let result = collected.lock().unwrap().clone(); + Ok(result) +} + +/// List the remote agent's sessions via ACP `session/list`. Roaming adds no +/// session semantics — this is plain ACP over the authorized stream. +pub async fn list_sessions(stream: RoamingClientStream) -> Result> { + let (send, recv, conn) = stream.into_futures_io(); + let transport = agent_client_protocol::ByteStreams::new(send, recv); + + let sessions = Client + .builder() + .name("goose-roam-list") + .on_receive_notification( + async move |_notification: SessionNotification, _cx| Ok(()), + agent_client_protocol::on_receive_notification!(), + ) + .connect_with(transport, async move |cx: ConnectionTo| { + cx.send_request(InitializeRequest::new(ProtocolVersion::LATEST)) + .block_task() + .await?; + let response = cx + .send_request(ListSessionsRequest::default()) + .block_task() + .await?; + Ok(response.sessions) + }) + .await?; + + drop(conn); + Ok(sessions) +} + +fn render_update(update: &SessionUpdate) { + match update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let ContentBlock::Text(text) = &chunk.content { + print!("{}", text.text); + let _ = std::io::stdout().flush(); + } + } + SessionUpdate::AgentThoughtChunk(chunk) => { + if let ContentBlock::Text(text) = &chunk.content { + eprint!("\x1b[2m{}\x1b[0m", text.text); + } + } + SessionUpdate::ToolCall(tool_call) => { + eprintln!("\n🔧 {}", tool_call.title); + } + SessionUpdate::ToolCallUpdate(update) => { + if let Some(status) = &update.fields.status { + eprintln!(" [{status:?}]"); + } + } + _ => {} + } +} + +fn prompt_permission(request: &RequestPermissionRequest) -> RequestPermissionOutcome { + eprintln!("\n⚠️ the remote agent requests permission:"); + for (i, opt) in request.options.iter().enumerate() { + eprintln!(" {}) {}", i + 1, opt.name); + } + eprint!("choose a number (anything else cancels): "); + let _ = std::io::stderr().flush(); + + // Fail closed: option 1 is allow-always for goose hosts, so EOF, an empty + // line, or a typo must cancel rather than silently granting permission. + let Some(choice) = read_line().and_then(|l| l.trim().parse::().ok()) else { + eprintln!(" cancelled"); + return RequestPermissionOutcome::Cancelled; + }; + + match choice + .checked_sub(1) + .and_then(|idx| request.options.get(idx)) + { + Some(opt) => RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + opt.option_id.clone(), + )), + None => { + eprintln!(" cancelled"); + RequestPermissionOutcome::Cancelled + } + } +} + +fn read_line() -> Option { + eprint!("› "); + let _ = std::io::stderr().flush(); + let mut buf = String::new(); + match std::io::stdin().read_line(&mut buf) { + Ok(0) => None, // EOF + Ok(_) => Some(buf), + Err(_) => None, + } +} diff --git a/crates/goose-cli/src/commands/roam_full_bridge.rs b/crates/goose-cli/src/commands/roam_full_bridge.rs new file mode 100644 index 000000000..457d4760b --- /dev/null +++ b/crates/goose-cli/src/commands/roam_full_bridge.rs @@ -0,0 +1,72 @@ +//! The roaming host adapter: serve goose's **full** ACP surface to each +//! connecting peer. +//! +//! Roaming is just an authenticated p2p ACP transport. This adapter is the thin +//! seam that hands an authorized iroh stream straight to goose's real +//! `acp::server::serve`, so a connected client gets the entire ACP surface — +//! `session/new`, `session/list`, `session/load`, `session/prompt` — backed by +//! the host's own `SessionManager`. Anything "session-shaped" is therefore plain +//! ACP that happens to run over a roaming connection; roaming adds no session +//! semantics of its own. +//! +//! Each accepted connection gets a **fresh** agent (never one shared across +//! clients); every client drives its own independent sessions. + +use std::sync::Arc; + +use futures::future::BoxFuture; +use futures::io::{AsyncRead, AsyncWrite}; + +use goose::acp::server::serve; +use goose::acp::server_factory::AcpServer; +use goose_roaming::{AcpStreamServer, EndpointId}; + +/// An [`AcpStreamServer`] that serves goose's full ACP surface, a fresh agent +/// per connection. +pub struct FullAcpBridge { + server: Arc, + agent_id: String, + /// Host-controlled working directory for sessions created over roaming. + /// The connector's machine-local absolute path is meaningless on this + /// host, so every roaming agent gets this instead — even when the shared + /// `AcpServer` (e.g. `goose serve`) leaves `session_cwd` unset for its + /// local clients. + session_cwd: std::path::PathBuf, +} + +impl FullAcpBridge { + pub fn new( + server: Arc, + agent_id: impl Into, + session_cwd: std::path::PathBuf, + ) -> Self { + Self { + server, + agent_id: agent_id.into(), + session_cwd, + } + } +} + +impl AcpStreamServer for FullAcpBridge { + fn serve_stream( + &self, + client: EndpointId, + recv: Box, + send: Box, + ) -> BoxFuture<'static, anyhow::Result<()>> { + let server = self.server.clone(); + let session_cwd = self.session_cwd.clone(); + Box::pin(async move { + tracing::info!(%client, "roaming: serving full ACP surface"); + let agent = server + .create_agent_with_session_cwd(Some(session_cwd)) + .await?; + serve(agent, recv, send).await + }) + } + + fn agent_id(&self) -> String { + self.agent_id.clone() + } +} diff --git a/crates/goose-cli/src/commands/roam_proxy.rs b/crates/goose-cli/src/commands/roam_proxy.rs new file mode 100644 index 000000000..3cf1c5dd1 --- /dev/null +++ b/crates/goose-cli/src/commands/roam_proxy.rs @@ -0,0 +1,86 @@ +//! Transparent ACP proxy: splice a local ACP transport to a remote roaming stream. +//! +//! `roam connect`/`delegate` embed an ACP *client* with a built-in terminal UI. +//! This module is the opposite composition: it exposes a remote agent as a +//! *local ACP endpoint* so any ACP client (Zed, another editor) +//! can drive it as if it were local. +//! +//! The trick is that once the roaming handshake completes, the stream carries +//! raw ACP JSON-RPC framing — byte-for-byte what `goose acp` speaks over stdio. +//! So bridging is a pure copy in both directions: nothing runs an agent here and +//! nothing is deserialized. We just pump bytes local↔remote until both halves +//! close. + +use anyhow::Result; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; + +/// Splice a local ACP transport (`local_read`/`local_write`) to a remote +/// roaming stream (`remote_write`/`remote_read`). +/// +/// Runs both directions to completion: when the local client closes its input +/// we finish the remote's send half, and when the remote agent closes we finish +/// the local output half. This avoids truncating an in-flight ACP turn, which a +/// "return on first close" splice would do. +pub async fn splice( + mut local_read: LR, + mut local_write: LW, + mut remote_write: RW, + mut remote_read: RR, +) -> Result<()> +where + LR: AsyncRead + Unpin + Send, + LW: AsyncWrite + Unpin + Send, + RW: AsyncWrite + Unpin + Send, + RR: AsyncRead + Unpin + Send, +{ + let client_to_host = async { + tokio::io::copy(&mut local_read, &mut remote_write).await?; + remote_write.shutdown().await + }; + let host_to_client = async { + tokio::io::copy(&mut remote_read, &mut local_write).await?; + local_write.shutdown().await + }; + + tokio::try_join!(client_to_host, host_to_client)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncReadExt; + + /// Bytes written by the local client reach the remote, and bytes from the + /// remote reach the local client — both directions, to completion. + /// + /// The test ends are used as whole `DuplexStream`s (not split): a split end + /// only reaches EOF once *both* halves are dropped, so `splice`'s copies + /// would never observe EOF and the bridge would hang. Dropping the whole + /// endpoint is what signals EOF and lets `splice` finish cleanly. + #[tokio::test] + async fn splices_both_directions() { + let (mut local_client, local_endpoint) = tokio::io::duplex(64); + let (remote_endpoint, mut remote_agent) = tokio::io::duplex(64); + let (local_read, local_write) = tokio::io::split(local_endpoint); + let (remote_read, remote_write) = tokio::io::split(remote_endpoint); + + let bridge = tokio::spawn(async move { + splice(local_read, local_write, remote_write, remote_read).await + }); + + local_client.write_all(b"initialize").await.unwrap(); + let mut buf = [0u8; 10]; + remote_agent.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"initialize"); + + remote_agent.write_all(b"session-ok").await.unwrap(); + let mut buf = [0u8; 10]; + local_client.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"session-ok"); + + drop(local_client); + drop(remote_agent); + bridge.await.unwrap().unwrap(); + } +} diff --git a/crates/goose-cli/tests/roam_acp_client.rs b/crates/goose-cli/tests/roam_acp_client.rs new file mode 100644 index 000000000..5c83a0bcb --- /dev/null +++ b/crates/goose-cli/tests/roam_acp_client.rs @@ -0,0 +1,188 @@ +//! End-to-end tests for the roaming ACP *client* helpers over the real iroh +//! transport. +//! +//! Roaming is just an authenticated p2p ACP transport, so a stub ACP *agent* +//! stands in for goose's real `serve` and implements the session surface the +//! client exercises: `session/list`, `session/new`, `session/load`, +//! `session/prompt`. This proves `roam_client::list_sessions` and the +//! session-aware `roam_client::delegate` drive plain ACP correctly across a +//! roaming stream — no LLM/provider required. + +#![cfg(feature = "roaming")] + +use std::sync::Arc; + +use agent_client_protocol::schema::v1::{ + ContentBlock, ContentChunk, InitializeRequest, InitializeResponse, ListSessionsRequest, + ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, + NewSessionResponse, PromptRequest, PromptResponse, SessionId, SessionInfo, SessionNotification, + SessionUpdate, StopReason, +}; +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::{Agent as SacpAgent, Client, ConnectionTo}; +use anyhow::{anyhow, Result}; +use futures::future::BoxFuture; +use futures::io::{AsyncRead, AsyncWrite}; + +use goose_cli::commands::roam_client; +use goose_roaming::{ + AcpStreamServer, Directory, EndpointId, RelaySettings, RoamingConfig, RoamingIdentity, + RoamingNode, TrustBook, +}; + +/// A stub ACP agent serving the session surface the client uses. It reports one +/// fixed session in `session/list`, echoes the prompt back prefixed so tests can +/// assert what was sent, and tags the reply with whether the session was created +/// (`session/new`) or loaded (`session/load`). +struct StubAcpAgent; + +impl AcpStreamServer for StubAcpAgent { + fn serve_stream( + &self, + _client: EndpointId, + recv: Box, + send: Box, + ) -> BoxFuture<'static, Result<()>> { + Box::pin(async move { + let transport = agent_client_protocol::ByteStreams::new(send, recv); + + SacpAgent + .builder() + .name("stub-acp-agent") + .on_receive_request( + async move |_req: InitializeRequest, responder, _cx| { + responder.respond(InitializeResponse::new(ProtocolVersion::LATEST)) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: ListSessionsRequest, responder, _cx| { + let info = SessionInfo::new(SessionId::from("sess-42"), "/work") + .title("Existing session"); + responder.respond(ListSessionsResponse::new(vec![info])) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: NewSessionRequest, responder, _cx| { + responder.respond(NewSessionResponse::new(SessionId::from("sess-new"))) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: LoadSessionRequest, responder, _cx| { + responder.respond(LoadSessionResponse::default()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: PromptRequest, responder, cx| { + // Reflect the loaded/new session id + the prompt text so + // the client's collected output is assertable. + let text = req + .prompt + .iter() + .filter_map(|b| match b { + ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join(" "); + let reply = format!("session={} prompt={text}", req.session_id); + cx.send_notification(SessionNotification::new( + req.session_id.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new( + ContentBlock::from(reply), + )), + ))?; + responder.respond(PromptResponse::new(StopReason::EndTurn)) + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |_cx: ConnectionTo| { + // Keep the connection alive until the client hangs up. + std::future::pending::<()>().await; + Ok(()) + }) + .await + .map_err(|e| anyhow!(e))?; + Ok(()) + }) + } + + fn agent_id(&self) -> String { + "stub-acp-agent".to_string() + } +} + +fn loopback() -> std::net::SocketAddr { + "127.0.0.1:0".parse().unwrap() +} + +async fn bind_node(trust: TrustBook) -> Arc { + RoamingNode::bind(RoamingConfig { + identity: RoamingIdentity::generate(), + relay: RelaySettings::Disabled, + trust, + trust_path: None, + directory: Directory::new(), + bind_addr: Some(loopback()), + relay_tls: None, + }) + .await + .expect("bind node") +} + +/// Bind a host serving the stub ACP agent and return a client stream connected +/// to it over the real (relay-disabled, loopback) iroh transport. The host +/// accepts the client's key first (mutual, key-based trust). +async fn connect_to_stub() -> (Arc, goose_roaming::RoamingClientStream) { + let host = bind_node(TrustBook::new()).await; + host.share(Arc::new(StubAcpAgent)).await.expect("share"); + + let client = bind_node(TrustBook::new()).await; + host.trust().lock().await.accept(&client.endpoint_id()); + + let stream = client + .connect_with_addr(host.endpoint().addr(), Some("test".into())) + .await + .expect("client connects"); + (client, stream) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_sessions_over_roaming() { + let (client, stream) = connect_to_stub().await; + let sessions = roam_client::list_sessions(stream).await.expect("list"); + client.shutdown().await.ok(); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, SessionId::from("sess-42")); + assert_eq!(sessions[0].title.as_deref(), Some("Existing session")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn delegate_new_session_over_roaming() { + let (client, stream) = connect_to_stub().await; + let out = roam_client::delegate(stream, "do the thing".into(), None) + .await + .expect("delegate"); + client.shutdown().await.ok(); + + // A fresh session (session/new) was used, and our prompt reached the agent. + assert!(out.contains("session=sess-new"), "got: {out}"); + assert!(out.contains("prompt=do the thing"), "got: {out}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn delegate_loads_named_session_over_roaming() { + let (client, stream) = connect_to_stub().await; + let out = roam_client::delegate(stream, "continue".into(), Some("sess-42".into())) + .await + .expect("delegate"); + client.shutdown().await.ok(); + + // The named session was loaded (session/load) and driven, not a fresh one. + assert!(out.contains("session=sess-42"), "got: {out}"); + assert!(out.contains("prompt=continue"), "got: {out}"); +} diff --git a/crates/goose-roaming/Cargo.toml b/crates/goose-roaming/Cargo.toml new file mode 100644 index 000000000..ed19fe398 --- /dev/null +++ b/crates/goose-roaming/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "goose-roaming" +edition.workspace = true +rust-version.workspace = true +version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Peer-to-peer roaming transport for goose agents (iroh-based)" + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +fs2.workspace = true +futures = { workspace = true } +iroh = { workspace = true, features = ["tls-aws-lc-rs"] } +iroh-relay = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2.workspace = true +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync", "macros", "time", "io-util"] } +tokio-util = { workspace = true, features = ["compat"] } +tracing = { workspace = true } + +[dev-dependencies] +iroh = { workspace = true, features = ["test-utils"] } +iroh-relay = { version = "1.0.3", default-features = false, features = ["server", "test-utils"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] } + +[lints] +workspace = true diff --git a/crates/goose-roaming/README.md b/crates/goose-roaming/README.md new file mode 100644 index 000000000..b08d0a2d7 --- /dev/null +++ b/crates/goose-roaming/README.md @@ -0,0 +1,176 @@ +# goose-roaming + +Peer-to-peer transport for goose agents, built on +[iroh](https://iroh.computer) (QUIC, using iroh's public relays for NAT +traversal). + +It lets a goose agent accept connections from a remote ACP client (another +goose, or any other ACP client) that drives it, and lets a client +dial a remote agent to hold an interactive session, delegate a one-shot task, or +bridge it to a local ACP client — typically without port-forwarding. + +This crate is a **standalone library** with no dependency on `goose` core (so +the iroh dependency stays out of core): it knows nothing about agents or +sessions, only identity, trust, and authenticated byte streams, and can be +embedded in any Rust application. The consumer surface is `RoamingNode` +(bind/share/connect), `RoamingConfig`, the two-method `AcpStreamServer` trait to +plug in whatever serves a stream, `TrustBook`, and `ConnectionCard` — see +`examples/echo_roundtrip.rs` for the whole flow in one file +(`cargo run -p goose-roaming --example echo_roundtrip`). The code that bridges +the transport to goose's agent machinery lives in `goose-cli` behind an optional +`roaming` feature; it isn't compiled unless that feature is enabled. + +## The model: an authenticated ACP transport with mutual key trust + +Roaming does one thing: provide an **authenticated peer-to-peer ACP transport**. +The host runs goose's real ACP server; the connecting side is an ACP client. +Everything "session-shaped" (list/load/new/prompt) is therefore plain ACP that +happens to run over a roaming connection — roaming adds no session semantics. + +Trust is a **mutual, public-key allowlist** — WireGuard / SSH-known-hosts style, +not a capability token: + +- Each node has **one** ed25519 identity that *is* its iroh `EndpointId`. The + QUIC-TLS handshake proves a peer holds the secret for the id it claims, so a + key cannot be impersonated. Persisted as hex in a `0600` file in the config dir. +- A node produces a **connection card** (`ConnectionCard`) — a non-secret string + carrying its public key + relay URLs, plus a short fingerprint for out-of-band + verification. It never expires and grants nothing on its own. +- You **swap cards** and each side **accepts** the other's key. A connection + succeeds only if the host has accepted the dialer's key, and an accepted peer + gets goose's full ACP surface. A leaked card lets no one in; there is no bearer + token that works by possession. + +## Concepts + +- **`ConnectionCard`** — the shareable, non-secret identity + reachability string + (`goose+roam://…`). Encodes public key + relay URLs; exposes `fingerprint()`. +- **`TrustBook`** — the local, mutual allowlist of accepted peer keys, plus + revocations. Access exists *only* by accepting a key. Persisted atomically and + re-read on each inbound connection, so `accept`/`revoke` take effect against a + running `share` without a restart. Reload failure fails **closed**. +- **`Directory`** — an out-of-band record of connections that actually happened + (inbound and outbound), built purely from observed connections. No gossip. +- **`PeerBook`** — a user-managed address book of remotes, by nickname; stores + the peer's (non-secret) card. + +## Flow + +``` +both: bind endpoint ──▶ `roam id` prints a connection card ──▶ swap cards +host: `roam peers accept ` ──▶ `roam share` (serve to accepted keys) +client: `roam peers add ` ──▶ dial via relay ──▶ handshake (label only) +host: authorize by TLS-authenticated key ──▶ ACP serve() (full surface) +client: run an ACP client over the same bi-stream +``` + +An iroh bidirectional stream is the byte transport for goose's existing +transport-agnostic ACP `serve` / `ByteStreams` seam, so hosting reuses the ACP +server and the client reuses the ACP client. + +## CLI + +Exposed via `goose roam` (in `goose-cli`, feature `roaming`): + +| Command | Purpose | +|---|---| +| `roam id` (alias `card`) | Print this node's connection card | +| `roam peers add [name]` | Save a peer's card to the address book | +| `roam peers accept [name]` | Accept inbound connections from a key (names an inline card) | +| `roam peers revoke ` | Stop accepting a key | +| `roam peers list` | Saved peers + which keys are accepted | +| `roam share [--cwd] [--with-builtin]` | Host this agent to accepted peers | +| `roam connect ` | Quick interactive REPL (debug/peek) | +| `roam delegate [""] [--session ] [--list-sessions]` | One-shot task, or list/continue remote sessions | +| `roam bridge [--listen ]` | Expose the remote agent as a local ACP endpoint | +| `roam connections` | Live/observed connections (no gossip) | + +## Testing across two disconnected machines + +Build both with the roaming feature (`cargo build -p goose-cli --features +roaming`) — no shared network, VPN, or port-forwarding needed; the public n0 +relays bridge them. On **each** machine run `goose roam id` and send the printed +`goose+roam://…` card to the other out of band (paste it in chat, etc.). + +On **machine A** (the host): `goose roam peers accept ''` then +`goose roam share` (optionally `--cwd `; it defaults to where `share` +started, and the connector's own path is always ignored). On **machine B**: +`goose roam peers add '' boxA`, then either drive A interactively with +`goose roam connect boxA` (a prompt that runs on A's agent — its tools, files, +shell), hand it a one-shot task with `goose roam delegate boxA "what is 2+2?"`, +or `goose roam delegate boxA --list-sessions` / `--session ""` to +enumerate and continue A's sessions. Verify it's truly A doing the work by asking +something machine-specific (e.g. "what's your hostname and cwd?"). On the host, +`goose roam connections` shows who connected. If session creation hangs on +macOS, prefix with `GOOSE_DISABLE_KEYRING=1`. + +## Design decisions & rationale + +**Roaming is just an ACP transport.** The host runs the agent loop (its tools, +working directory, shell); the connecting side is an ACP client. Each connection +gets a fresh agent driving its own sessions (`FullAcpBridge` hands the stream to +goose's real `serve`). `connect` is a thin ACP client UI — not a provider +wrapper; wrapping the remote as a provider for a second local agent loop would +double the loop and defeat the point. + +**The host controls the working directory.** ACP's `session/new` carries a cwd, +but the connector's absolute path is meaningless on the host machine. So the host +ignores the sent cwd and imposes its own (the directory `roam share` was started +in, or `--cwd`); the client sends only a placeholder. + +**Trust is mutual and key-based, with no bearer path.** A card is non-secret and +grants nothing; a share admits no one until a key is explicitly accepted, so the +safe default (admit nobody) is the built-in one. Authorization uses the full +TLS-authenticated key; the handshake carries only a display label (not trusted). +Acceptance re-reads per connection (fail-closed) so revoke takes effect on a live +share. + +**Acceptance is all-or-nothing.** An accepted peer gets goose's full ACP +surface (there is no per-request gate, so no finer-grained roles). Simultaneous +multi-viewer co-driving of one live session is a possible future feature; it is +not expressible over plain 1:1 ACP and is intentionally out of scope here. + +**Delegation guardrails are about cost, not authorization.** The peer is already +trusted, so the concern with agent-to-agent delegation is runaway cost from loops +(A → B → A …). The `delegate` path auto-cancels tool-permission requests, since +there is no human present to answer them. + +## What's deferred + +- **Live multi-viewer co-driving** (paseo-style): several clients watching and + steering *one* in-flight session at once. This isn't expressible over plain + 1:1 ACP — it needs a purpose-built multi-party session protocol (subscribe / + snapshot / broadcast / steer with an explicit controller) layered over this + transport. A future feature, deliberately not emulated via an ACP broker. +- Self-hosted relays (public n0 relays are rate-limited). + +## Surfacing delegation to the model + +The agent can reach other agents with **no new code**: a builtin skill +(`roam-delegate`) documents how to call `goose roam delegate ""` via +the shell. It ships in core but is inert unless the `roaming` CLI feature is +built in, keeping iroh out of core. + +## Browser web client + +The **official browser client for roam** lives in a separate repo: +[aaif-goose/goose-mobile](https://github.com/aaif-goose/goose-mobile/tree/main/mobile-web) +(`mobile-web/`). It is a pure-browser React app that connects to a +`goose roam share` agent — iroh compiled to wasm runs *inside the browser tab*, +driving the agent over ACP. No Tauri, no local bridge; the tab is the roam peer. +The stock iroh wasm build tunnels QUIC over WebSocket to the relay (its UDP +transport is compiled out in browsers; a WebRTC custom transport could add +direct paths later). + +It is fully decoupled from this crate: the `goose-roaming-web` wasm crate there +**mirrors** this crate's connection-card and frame wire format by copying its +constants (`CARD_VERSION`, `MAX_FRAME_BYTES`, card bounds). When you change the +wire format here, update goose-mobile in the same change — a drift will not fail +to compile there, it will break pairing at runtime. + +## Prior art + +Patterns here were informed by studying a sibling production project that runs +iroh 1.0 for distributed LLM inference: minimal-preset endpoints with custom +relay maps, ALPN-based stream dispatch, and reachability via relay-routing by +node id (a card needs only key + relay, not a fixed address). diff --git a/crates/goose-roaming/SECURITY.md b/crates/goose-roaming/SECURITY.md new file mode 100644 index 000000000..e3e255c10 --- /dev/null +++ b/crates/goose-roaming/SECURITY.md @@ -0,0 +1,164 @@ +# goose-roaming trust-model audit + +A written audit of the card / allowlist trust model, done as a pre-undraft +gate. File references are to this crate unless noted. + +## The model in one paragraph + +Each node has one ed25519 keypair; the public key **is** its iroh +`EndpointId`, so identity is self-certifying — iroh's QUIC-TLS handshake +proves a peer holds the secret for the id it claims (`identity.rs`). +Authorization is a local, public-key allowlist (`trust.rs`): a host admits an +inbound connection only if the transport-authenticated key is on its +allowlist and not revoked. There is no bearer token, no capability string, +and nothing in a `ConnectionCard` grants access by possession. + +## What an attacker gets at each position + +| Position | What they can do | +|---|---| +| Holds a leaked card | Nothing. A card is public key + relay URLs; connecting with an unaccepted key is rejected before any ACP bytes flow (`node.rs` `authorize`). They can cause cheap handshake work (see DoS). | +| Controls a relay | Sees ciphertext and connection metadata (who talks to whom, when, volume). Cannot read or modify traffic — QUIC-TLS is end-to-end between the endpoints. Can drop/deny service. Relay auth tokens never travel in cards (`relay.rs`). | +| On-path network attacker | Same as a malicious relay: metadata + denial. Cannot impersonate either endpoint. | +| Steals a *device's* secret key | Full impersonation of that device. If the key was accepted by a host, they get that host's full ACP surface until revoked. This is the crown jewel; see key storage. | +| Local process as the host user | Can edit `roaming_trust.json` to accept any key — the same trust boundary as `~/.ssh/authorized_keys`. Not defended, by design: local user compromise is out of scope. | + +## Control is one-way by construction + +Although the transport is p2p, **control is never symmetric**. A node only +exposes an ACP surface by calling `RoamingNode::share()`, which is what +registers the `goose-acp/1` protocol handler — and the only callers are +`goose roam share` and `goose serve --roam`. Pure clients (the browser +webapp, `roam client`/`bridge`/`delegate`) bind an endpoint but never share: +they register no accept handler, so a host dialing back at them finds no +protocol to connect to. There is nothing to authorize or block — the surface +does not exist on the client side. + +Relatedly, the allowlist gates **inbound only**: a host accepting a client's +key grants the client access to the host, and grants the host nothing in +return. "Mutual trust" in the docs means mutual *consent* (host authorizes +the key; client chooses whom to dial and verifies the fingerprint), not +mutual control — the same asymmetry as SSH's `authorized_keys` vs +`known_hosts`. Two machines that each want to drive the other run two +independent shares with two independent allowlists, so A→B without B→A is +the natural configuration, not a special mode. + +## The blunt truth about authorization granularity + +An accepted peer gets goose's **full ACP surface** with a fresh agent per +connection (`goose-cli/src/commands/roam_full_bridge.rs`), backed by the +host's session store, tools, and shell. **Accepting a key is equivalent to +granting shell access as the host user.** There is no per-peer capability +scoping, read-only mode, or session sandboxing. This is stated in the README +and must stay prominent in user-facing docs. Revocation force-closes live +connections within ~2s (`node.rs` `watch_revocations` / `enforce_trust`) but +cannot undo actions already taken. + +Precise in-flight semantics of a force-close: the peer's ACP serving future +ends when its stream errors out, and any in-flight prompt turn is dropped at +its next await point — the agent loop stops mid-turn, within moments. The +residual is narrower than "work continues": an OS process a tool has +*already spawned* (e.g. a running shell command) is not `kill_on_drop` and +is only killed via the run's cancellation token, which a plain drop does not +fire — so an already-forked process may run to completion as an orphan. No +new work can start after the close. + +## Findings by surface + +### Identity & key storage +- Host/CLI: secret persisted as hex in a `0600` file, `0700` parent dir, + atomic write (`identity.rs`). Plaintext on disk rather than OS keychain — a + deliberate tradeoff (headless hosts, no keychain prompts); same posture as + `~/.ssh/id_ed25519`. +- Browser: secret hex in `localStorage` (`web/webapp/src/main.tsx`). Weaker + than the host: any XSS on the origin exfiltrates the key. Mitigations: the + webapp has no third-party script; a stolen browser key grants access only + to hosts that accepted it, and shows in `roam connections` / paired-devices + UI where it can be revoked. Follow-up (nice-to-have): non-extractable + WebCrypto keys are not usable here because iroh needs the raw ed25519 key; + IndexedDB adds no secrecy over localStorage. Documented as a known + limitation rather than fixed. + +### Authentication +- Done entirely by iroh QUIC-TLS; `connection.remote_id()` is the + authenticated key. The roaming handshake carries **no** credential — the + `ClientHello` is just a display label (`handshake.rs`). Correct: nothing in + the hello is trusted for authorization. +- Dialing: the client connects to the `EndpointAddr` derived from the card, + and QUIC-TLS verifies the host presents the key matching that endpoint id. + So the client's trust decision is "I trust the card I was handed" — which + is why the out-of-band fingerprint exists. + +### Authorization path (`node.rs` `authorize`) +- Allowlist is **re-read from disk on every inbound connection**, so + accept/revoke from another process take effect on a running share without + restart. Reads are atomic (writers temp+rename). ✅ +- Trust reload failure **fails closed** ("unavailable"), never falls back to + a stale in-memory book. ✅ +- `revoked_keys` is checked before `allowed`, and `revoke_key` pins the key + in the revoked set so a stale card can't silently re-add it; only an + explicit `accept` clears a revocation. ✅ +- Live revocation: `watch_revocations` polls the trust file mtime (~2s) and + `enforce_trust` force-closes connections for keys no longer allowed, + covered by two integration tests (`tests/end_to_end.rs`). ✅ + +### Handshake hardening +- Length-prefixed frames capped at 64 KiB (`frame.rs`) — a peer can't + announce a huge frame. ✅ +- Whole handshake bounded by a timeout (Slowloris guard) — a peer that + connects and stalls is dropped. ✅ +- Authorization happens **before** the ack, so `Accepted` is truthful. ✅ +- The client-supplied label is sanitized (control chars stripped, 64-char + cap) before it reaches terminal output (`sanitize_label`). ✅ + +### Connection card (`card.rs`) +- Non-secret by construction; safe to show as a QR code. +- Relay URLs from an untrusted card are constrained to `http(s)` schemes so a + malicious card can't smuggle another scheme into the dialer. ✅ +- Version-pinned decode; unknown versions rejected. ✅ +- Fingerprint: first 128 bits of SHA-256 over the raw 32-byte endpoint key, + displayed as eight 4-hex groups. 128 bits is second-preimage resistant + against an attacker grinding keys to match a fingerprint a human compares + out of band (the previous 48-bit form was not — fixed). +- Scope note: the fingerprint covers only the key, not the relay URLs. This + is fine — relay URLs are untrusted reachability hints, and a tampered relay + list yields at worst denial or metadata exposure, never impersonation. + +### Trust state files +- `roaming_trust.json` / `roaming_peers.json` are `0644`: they contain only + public keys and nicknames, nothing secret. Write access = user-level + compromise (out of scope, above). +- `TrustBook::save` uses atomic temp+rename, so the per-connection reader and + the revocation watcher never observe a torn file. ✅ +- Read-modify-write is serialized: `TrustBook::update` holds a cross-process + `fs2` advisory lock across load+mutate+save, so concurrent accept and revoke + cannot lose an edit. All CLI accept/revoke/pair paths go through it. ✅ + +### `serve.json` (embedding) +- `0644` in the data dir, contains card / endpoint id / fingerprint — all + non-secret. Removed on startup, written atomically once online. ✅ + +### DoS surface +- Anyone holding a card (or who learns the endpoint id) can connect and + force TLS + one frame read; rejected peers never reach ACP, frames are + capped, and the handshake is timeout-bounded. Rate limiting is delegated to + the relay layer (managed relays are gated; n0 relays are rate-limited). + Residual: accepted-peer resource exhaustion is untreated — an accepted peer + is trusted, per the granularity note above. + +## Non-goals / accepted gaps + +- **No per-peer capability scoping** — accepted = full agent. Documented. +- **No per-peer action audit log** — the `Directory` records connections + (who/when/direction), not what an accepted peer did. Session history is the + audit trail, keyed to the host, not the peer. +- **No automatic key rotation / expiry** — keys live until revoked, like SSH. +- **No defense against the host user's own processes.** + +## Verdict + +The model is coherent and small: one keypair per node, transport-proven +identity, local allowlist, fail-closed reload, live revocation, non-secret +cards. The two things a reviewer must not lose sight of are (1) accept = +shell, and (2) the browser key sits in localStorage. Both are inherent to +the current design and are documented rather than mitigated. diff --git a/crates/goose-roaming/examples/echo_roundtrip.rs b/crates/goose-roaming/examples/echo_roundtrip.rs new file mode 100644 index 000000000..c82d6bfae --- /dev/null +++ b/crates/goose-roaming/examples/echo_roundtrip.rs @@ -0,0 +1,111 @@ +//! Minimal end-to-end example of the `goose-roaming` library API. +//! +//! It shows the whole surface a consumer touches — identity, bind, share, +//! accept a peer's key, exchange cards, dial, and exchange bytes over the +//! authorized stream — without any dependency on goose's agent internals. The +//! "agent" here is a trivial echo server plugged in via the [`AcpStreamServer`] +//! trait; a real consumer would call goose's ACP `serve` instead (see +//! `goose-cli`'s bridge). +//! +//! Run it: +//! +//! ```bash +//! cargo run -p goose-roaming --example echo_roundtrip +//! ``` +//! +//! It runs both ends in one process over loopback (relays disabled), so it +//! needs no network. For a real two-machine test, use the `goose roam` CLI. + +use std::sync::Arc; + +use futures::io::{AsyncReadExt, AsyncWriteExt}; +use goose_roaming::{ + AcpStreamServer, EndpointId, RelaySettings, RoamingConfig, RoamingIdentity, RoamingNode, +}; + +const MSG: &[u8] = b"hello from the client"; + +/// A stand-in "agent": echoes back whatever the client sends, upper-cased. +struct EchoServer; + +impl AcpStreamServer for EchoServer { + fn serve_stream( + &self, + _client: EndpointId, + mut recv: Box, + mut send: Box, + ) -> futures::future::BoxFuture<'static, anyhow::Result<()>> { + Box::pin(async move { + // Read the client's fixed-size message, reply upper-cased, then stay + // alive by draining until the client closes its send half. Returning + // early would tear down the QUIC connection before delivery (a real + // ACP `serve` runs a long-lived duplex loop, so this doesn't arise). + let mut buf = [0u8; MSG.len()]; + recv.read_exact(&mut buf).await?; + send.write_all(&buf.to_ascii_uppercase()).await?; + send.flush().await?; + let mut drain = Vec::new(); + let _ = recv.read_to_end(&mut drain).await; + Ok(()) + }) + } + + fn agent_id(&self) -> String { + "echo-agent".to_string() + } +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() -> anyhow::Result<()> { + let loopback = "127.0.0.1:0".parse().unwrap(); + + // --- Host side --------------------------------------------------------- + // Bind a node with the lean config builder, then start sharing the agent. + let host = RoamingNode::bind( + RoamingConfig::new(RoamingIdentity::generate()) + .with_relay(RelaySettings::Disabled) + .with_bind_addr(loopback), + ) + .await?; + host.share(Arc::new(EchoServer)).await?; + + // The host's shareable card: public identity + reachability, nothing secret. + let card = host.card(); + println!("host endpoint id : {}", host.endpoint_id()); + println!("host card : {}", card.encode()?); + + // --- Client side ------------------------------------------------------- + // A separate node dials the host directly (relays disabled → LAN address). + let client = RoamingNode::bind( + RoamingConfig::new(RoamingIdentity::generate()) + .with_relay(RelaySettings::Disabled) + .with_bind_addr(loopback), + ) + .await?; + + // The host accepts the client's key into its allowlist — the mutual, + // key-based trust step. Without this the connection is refused. + host.trust().lock().await.accept(&client.endpoint_id()); + + let stream = client + .connect_with_addr(host.endpoint().addr(), Some("example".into())) + .await?; + println!("connected to `{}`", stream.agent_id); + + // Use the ergonomic helper: no manual tokio-compat dance. + let (mut send, mut recv, _conn) = stream.into_futures_io(); + send.write_all(MSG).await?; + send.flush().await?; + let mut out = [0u8; MSG.len()]; + recv.read_exact(&mut out).await?; + // Close our send half so the host's drain read completes cleanly. + send.close().await?; + let reply = String::from_utf8_lossy(&out); + println!("agent replied : {reply}"); + assert_eq!(out, MSG.to_ascii_uppercase().as_slice()); + + host.shutdown().await?; + client.shutdown().await?; + println!("ok"); + Ok(()) +} diff --git a/crates/goose-roaming/src/card.rs b/crates/goose-roaming/src/card.rs new file mode 100644 index 000000000..5b6130386 --- /dev/null +++ b/crates/goose-roaming/src/card.rs @@ -0,0 +1,230 @@ +//! A **connection card**: the single, non-secret string you share with another +//! node so it can find and identify you. +//! +//! A card carries only: +//! * the node's **identity** (its public key, which in iroh *is* its endpoint +//! id), and +//! * how to **reach** it (relay URLs — the connective tissue; a node registers +//! with these relays, which forward by node id regardless of the node's +//! current IP/NAT). +//! +//! There is nothing secret in a card. Possessing one grants no access: iroh's +//! QUIC-TLS handshake proves a peer holds the private key for the identity in +//! the card (so it cannot be impersonated), and a connection is only authorized +//! if the peer's key is on the *other* side's allowlist. Trust is therefore a +//! mutual, public-key relationship — you exchange cards, and each side chooses +//! to accept the other. This is the WireGuard / SSH-known-hosts model. +//! +//! A card is versioned but deliberately **not** a capability token: it never +//! expires and confers no permission on its own. + +use base64::Engine; +use iroh::{EndpointAddr, EndpointId, TransportAddr}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::RoamingError; + +const CARD_VERSION: u32 = 1; +const CARD_SCHEME: &str = "goose+roam://"; + +/// Decode bounds, shared by the native and wasm decoders. A card is a tiny +/// identity+relay-list blob; anything near these limits is garbage or an +/// attack, and the caps stop allocation before it starts. +const MAX_CARD_TEXT_BYTES: usize = 8 * 1024; +const MAX_RELAY_URLS: usize = 16; +const MAX_RELAY_URL_BYTES: usize = 512; + +/// A shareable, non-secret identity-plus-reachability card for a node. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConnectionCard { + pub version: u32, + /// The node's public key (== its iroh endpoint id). + pub endpoint_id: EndpointId, + /// Relay URLs the node registers with; how peers reach it across network + /// changes. May be empty for a LAN-only / directly-reachable node. + pub relay_urls: Vec, +} + +impl ConnectionCard { + pub fn new(endpoint_id: EndpointId, relay_urls: Vec) -> Self { + Self { + version: CARD_VERSION, + endpoint_id, + relay_urls, + } + } + + /// A short, human-comparable fingerprint of the identity, for out-of-band + /// verification ("does the code you added end in `…3f9a`?"). Derived from + /// the public key, so it is stable and needs no secret. + pub fn fingerprint(&self) -> String { + let digest = Sha256::digest(self.endpoint_id.as_bytes()); + // First 16 bytes (128 bits) -> eight 4-hex-char groups. 48 bits was + // brute-forceable for a second-preimage against a human comparing + // out of band; 128 bits is not. + digest[..16] + .chunks(2) + .map(|pair| format!("{:02x}{:02x}", pair[0], pair[1])) + .collect::>() + .join("-") + } + + /// Encode to a compact, URL-safe string with the `goose+roam://` scheme. + pub fn encode(&self) -> Result { + let json = serde_json::to_vec(self) + .map_err(|e| RoamingError::Card(format!("encode card: {e}")))?; + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json); + Ok(format!("{CARD_SCHEME}{b64}")) + } + + /// Decode a card produced by [`Self::encode`]. + /// + /// This is the card-decoding contract, applied identically by the native + /// and wasm decoders: bounded input (text/relay count/URL length caps + /// before allocation), required `version == 1`, and http(s)-only relay + /// URLs validated at decode time — a malformed card is rejected here, not + /// when dialing. + pub fn decode(text: &str) -> Result { + let text = text.trim(); + if text.len() > MAX_CARD_TEXT_BYTES { + return Err(RoamingError::Card("card too large".into())); + } + let b64 = text + .strip_prefix(CARD_SCHEME) + .ok_or_else(|| RoamingError::Card(format!("missing {CARD_SCHEME} scheme")))?; + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .map_err(|e| RoamingError::Card(format!("bad base64: {e}")))?; + let card: ConnectionCard = serde_json::from_slice(&json) + .map_err(|e| RoamingError::Card(format!("bad card: {e}")))?; + if card.version != CARD_VERSION { + return Err(RoamingError::Card(format!( + "unsupported card version {}", + card.version + ))); + } + if card.relay_urls.len() > MAX_RELAY_URLS { + return Err(RoamingError::Card("too many relay urls".into())); + } + for url in &card.relay_urls { + if url.len() > MAX_RELAY_URL_BYTES { + return Err(RoamingError::Card("relay url too long".into())); + } + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(RoamingError::Card(format!( + "relay url must be http(s): {url}" + ))); + } + } + Ok(card) + } + + /// Build a dialable [`EndpointAddr`] from the card (id + relay URLs). + pub(crate) fn endpoint_addr(&self) -> Result { + let mut addr = EndpointAddr::new(self.endpoint_id); + for url in &self.relay_urls { + // Relay URLs come from an untrusted card. Constrain the scheme to + // http(s) so a malicious card can't smuggle some other URL scheme + // into the dialer. (iroh relays are HTTP(S) endpoints.) + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(RoamingError::Card(format!( + "relay url must be http(s): {url}" + ))); + } + let parsed = url + .parse() + .map_err(|_| RoamingError::Card(format!("bad relay url {url}")))?; + addr.addrs.insert(TransportAddr::Relay(parsed)); + } + Ok(addr) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iroh::SecretKey; + + #[test] + fn round_trips() { + let id = SecretKey::generate().public(); + let card = ConnectionCard::new(id, vec!["https://relay.example./".to_string()]); + let encoded = card.encode().unwrap(); + assert!(encoded.starts_with(CARD_SCHEME)); + let decoded = ConnectionCard::decode(&encoded).unwrap(); + assert_eq!(card, decoded); + } + + #[test] + fn fingerprint_is_stable_and_grouped() { + let id = SecretKey::generate().public(); + let card = ConnectionCard::new(id, vec![]); + let fp = card.fingerprint(); + assert_eq!(fp, card.fingerprint()); + assert_eq!(fp.len(), 39); // eight 4-hex groups + seven dashes + assert_eq!(fp.matches('-').count(), 7); + } + + #[test] + fn rejects_foreign_scheme() { + assert!(ConnectionCard::decode("https://example./abc").is_err()); + } + + fn encode_raw(json: &serde_json::Value) -> String { + use base64::Engine; + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(json).unwrap()); + format!("{CARD_SCHEME}{b64}") + } + + #[test] + fn decode_enforces_bounds_and_version() { + let id = SecretKey::generate().public().to_string(); + + // Wrong version. + let bad_version = encode_raw(&serde_json::json!({ + "version": 2, "endpoint_id": id, "relay_urls": [] + })); + assert!(ConnectionCard::decode(&bad_version).is_err()); + + // Non-http(s) relay scheme is rejected at decode, not just at dial. + let bad_scheme = encode_raw(&serde_json::json!({ + "version": 1, "endpoint_id": id, "relay_urls": ["file:///etc/passwd"] + })); + assert!(ConnectionCard::decode(&bad_scheme).is_err()); + + // Too many relay URLs. + let urls: Vec = (0..MAX_RELAY_URLS + 1) + .map(|i| format!("https://r{i}.example/")) + .collect(); + let too_many = encode_raw(&serde_json::json!({ + "version": 1, "endpoint_id": id, "relay_urls": urls + })); + assert!(ConnectionCard::decode(&too_many).is_err()); + + // One overlong relay URL. + let long_url = format!("https://{}.example/", "x".repeat(MAX_RELAY_URL_BYTES)); + let too_long = encode_raw(&serde_json::json!({ + "version": 1, "endpoint_id": id, "relay_urls": [long_url] + })); + assert!(ConnectionCard::decode(&too_long).is_err()); + + // Oversized card text is rejected before any base64/JSON allocation. + let huge = format!("{CARD_SCHEME}{}", "A".repeat(MAX_CARD_TEXT_BYTES + 1)); + assert!(ConnectionCard::decode(&huge).is_err()); + + // Missing fields fail to deserialize. + let missing = encode_raw(&serde_json::json!({ "version": 1 })); + assert!(ConnectionCard::decode(&missing).is_err()); + } + + #[test] + fn endpoint_addr_rejects_non_http_relay() { + let id = SecretKey::generate().public(); + let bad = ConnectionCard::new(id, vec!["file:///etc/passwd".to_string()]); + assert!(bad.endpoint_addr().is_err()); + let ok = ConnectionCard::new(id, vec!["https://relay.example./".to_string()]); + assert!(ok.endpoint_addr().is_ok()); + } +} diff --git a/crates/goose-roaming/src/directory.rs b/crates/goose-roaming/src/directory.rs new file mode 100644 index 000000000..c4e4a5a5c --- /dev/null +++ b/crates/goose-roaming/src/directory.rs @@ -0,0 +1,268 @@ +//! An out-of-band directory of roaming peers. +//! +//! There is deliberately **no gossip**: the directory is built purely from +//! connections this node observes. Inbound connections are recorded when a peer +//! is authorized; outbound connections are recorded when this node dials a +//! remote agent. This gives `goose roam list`-style visibility without any +//! ambient network discovery. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use iroh::EndpointId; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +/// Which way a connection was established. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + /// A remote peer connected to us. + Inbound, + /// We connected to a remote peer. + Outbound, +} + +/// A single directory entry describing a peer we have interacted with. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeerEntry { + pub endpoint_id: String, + pub label: Option, + pub direction: Direction, + /// Best-effort agent id reported during the handshake. + pub agent_id: Option, + pub first_seen_ms: u64, + pub last_seen_ms: u64, + /// Whether a session is currently active with this peer. + pub connected: bool, + /// Live connections with this peer in the current process. Not persisted: + /// only the process owning the endpoint knows what is live, so a restart + /// starts from zero rather than trusting a stale flag from disk. + #[serde(skip)] + live_connections: u32, +} + +/// A shared directory of peers, optionally persisted to disk so that a separate +/// process (e.g. `goose roam list`) can read what a running `share` has seen. +#[derive(Clone, Default)] +pub struct Directory { + inner: Arc>>, + path: Option, +} + +impl Directory { + pub fn new() -> Self { + Self::default() + } + + /// Create a directory backed by a JSON file at `path`, loading any existing + /// entries. All mutations are flushed back to the file (best effort). + pub fn persistent(path: PathBuf) -> Self { + let entries = std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .unwrap_or_default() + .into_iter() + .map(|mut e| { + // A persisted `connected` flag outlives the process that owned + // the connection (crash, SIGKILL, reboot). Only this process's + // own observations may claim a peer is live. + e.connected = false; + (e.endpoint_id.clone(), e) + }) + .collect::>(); + Self { + inner: Arc::new(Mutex::new(entries)), + path: Some(path), + } + } + + /// Like [`persistent`](Self::persistent), for the process that *owns* the + /// roaming endpoint (holds the exclusive endpoint lock). Because no other + /// owner can be alive, any persisted `connected` flags are stale by + /// definition — from a crash, SIGKILL, or reboot — so the cleared state is + /// flushed straight back to disk, making `goose roam connections` stop + /// reporting phantom live peers immediately after a restart. + pub fn persistent_owned(path: PathBuf) -> Self { + let dir = Self::persistent(path.clone()); + // The directory was just constructed and is not yet shared, so the + // lock is always free (`try_lock` cannot fail); this also stays safe + // inside an async runtime where a blocking lock would panic. + if let Ok(map) = dir.inner.try_lock() { + let mut entries: Vec<&PeerEntry> = map.values().collect(); + entries.sort_by_key(|e| std::cmp::Reverse(e.last_seen_ms)); + if let Ok(json) = serde_json::to_vec_pretty(&entries) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } + } + } + dir + } + + /// Read the persisted directory at `path` without holding the endpoint. + pub fn read_persisted(path: &std::path::Path) -> Vec { + let mut entries = std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .unwrap_or_default(); + entries.sort_by_key(|e| std::cmp::Reverse(e.last_seen_ms)); + entries + } + + /// Flush this node's view to disk, merged with whatever other processes + /// have written (a share and an outbound `connect`/`delegate` may run + /// concurrently). The whole reload-merge-write runs under a cross-process + /// advisory lock and lands via a unique temp file renamed into place, so + /// concurrent flushes can neither interleave bytes nor drop each other's + /// observations. + async fn flush(&self, map: &HashMap) { + use fs2::FileExt as _; + + let Some(path) = &self.path else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + let lock_path = path.with_extension("json.lock"); + let Ok(lock) = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + else { + return; + }; + if lock.lock_exclusive().is_err() { + return; + } + + let mut merged: HashMap = std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .unwrap_or_default() + .into_iter() + .map(|e| (e.endpoint_id.clone(), e)) + .collect(); + for entry in map.values() { + match merged.get(&entry.endpoint_id) { + // Keep the on-disk entry unless this process owns live + // connections with the peer or has a strictly fresher + // observation. Equal timestamps mean this process merely + // loaded the entry and has nothing new to say — overwriting + // would clobber another process's live state with our stale + // snapshot. + Some(existing) + if entry.live_connections == 0 + && existing.last_seen_ms >= entry.last_seen_ms => {} + _ => { + merged.insert(entry.endpoint_id.clone(), entry.clone()); + } + } + } + + let mut entries: Vec<&PeerEntry> = merged.values().collect(); + entries.sort_by_key(|e| std::cmp::Reverse(e.last_seen_ms)); + if let Ok(json) = serde_json::to_vec_pretty(&entries) { + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } + + let _ = fs2::FileExt::unlock(&lock); + } + + /// Record the start of a connection, creating or updating the entry. + pub(crate) async fn record_connect( + &self, + endpoint_id: EndpointId, + label: Option, + direction: Direction, + agent_id: Option, + now_ms: u64, + ) { + let key = endpoint_id.to_string(); + let mut map = self.inner.lock().await; + map.entry(key.clone()) + .and_modify(|e| { + e.last_seen_ms = now_ms; + e.connected = true; + e.live_connections = e.live_connections.saturating_add(1); + if label.is_some() { + e.label = label.clone(); + } + if agent_id.is_some() { + e.agent_id = agent_id.clone(); + } + }) + .or_insert(PeerEntry { + endpoint_id: key, + label, + direction, + agent_id, + first_seen_ms: now_ms, + last_seen_ms: now_ms, + connected: true, + live_connections: 1, + }); + self.flush(&map).await; + } + + /// Record that a connection with a peer has ended. The peer is only marked + /// disconnected when its *last* live connection ends; a peer with several + /// simultaneous sessions stays `connected` until all of them close. + pub(crate) async fn record_disconnect(&self, endpoint_id: EndpointId, now_ms: u64) { + let key = endpoint_id.to_string(); + let mut map = self.inner.lock().await; + if let Some(entry) = map.get_mut(&key) { + entry.live_connections = entry.live_connections.saturating_sub(1); + entry.connected = entry.live_connections > 0; + entry.last_seen_ms = now_ms; + } + self.flush(&map).await; + } + + /// Snapshot the directory, most-recently-seen first. + pub async fn list(&self) -> Vec { + let map = self.inner.lock().await; + let mut entries: Vec = map.values().cloned().collect(); + entries.sort_by_key(|e| std::cmp::Reverse(e.last_seen_ms)); + entries + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iroh::SecretKey; + + #[tokio::test] + async fn records_and_lists() { + let dir = Directory::new(); + let peer = SecretKey::generate().public(); + dir.record_connect( + peer, + Some("laptop".into()), + Direction::Inbound, + Some("agent-1".into()), + 1_000, + ) + .await; + + let list = dir.list().await; + assert_eq!(list.len(), 1); + assert_eq!(list[0].label.as_deref(), Some("laptop")); + assert!(list[0].connected); + + dir.record_disconnect(peer, 2_000).await; + let after = dir.list().await; + assert!(!after[0].connected); + assert_eq!(after[0].last_seen_ms, 2_000); + } +} diff --git a/crates/goose-roaming/src/error.rs b/crates/goose-roaming/src/error.rs new file mode 100644 index 000000000..b5cc9acc9 --- /dev/null +++ b/crates/goose-roaming/src/error.rs @@ -0,0 +1,22 @@ +//! Error types for the roaming transport. + +use thiserror::Error; + +/// Errors produced by the roaming subsystem. +#[derive(Debug, Error)] +pub enum RoamingError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("identity error: {0}")] + Identity(String), + + #[error("card error: {0}")] + Card(String), + + #[error("connection rejected: {0}")] + Rejected(String), + + #[error("transport error: {0}")] + Transport(String), +} diff --git a/crates/goose-roaming/src/frame.rs b/crates/goose-roaming/src/frame.rs new file mode 100644 index 000000000..52419bb0d --- /dev/null +++ b/crates/goose-roaming/src/frame.rs @@ -0,0 +1,79 @@ +//! Minimal length-prefixed framing used for the roaming handshake. +//! +//! Only the handshake (hello + accept/reject) is framed this way. +//! Once the handshake succeeds the raw stream is handed to the ACP protocol, +//! which does its own JSON-RPC framing. + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::error::RoamingError; + +/// Guard against a malicious peer announcing a huge handshake frame. +const MAX_FRAME_BYTES: u32 = 64 * 1024; + +/// Write a `u32`-length-prefixed frame. +pub async fn write_frame(w: &mut W, body: &[u8]) -> Result<(), RoamingError> +where + W: AsyncWrite + Unpin, +{ + if body.len() as u64 > MAX_FRAME_BYTES as u64 { + return Err(RoamingError::Transport("handshake frame too large".into())); + } + w.write_all(&(body.len() as u32).to_le_bytes()) + .await + .map_err(RoamingError::Io)?; + w.write_all(body).await.map_err(RoamingError::Io)?; + w.flush().await.map_err(RoamingError::Io)?; + Ok(()) +} + +/// Read a `u32`-length-prefixed frame. +pub async fn read_frame(r: &mut R) -> Result, RoamingError> +where + R: AsyncRead + Unpin, +{ + let mut len_buf = [0u8; 4]; + r.read_exact(&mut len_buf).await.map_err(RoamingError::Io)?; + let len = u32::from_le_bytes(len_buf); + if len > MAX_FRAME_BYTES { + return Err(RoamingError::Transport("handshake frame too large".into())); + } + let mut body = vec![0u8; len as usize]; + r.read_exact(&mut body).await.map_err(RoamingError::Io)?; + Ok(body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn round_trips() { + let (mut client, mut server) = tokio::io::duplex(1024); + let payload = b"hello roaming".to_vec(); + let p2 = payload.clone(); + let writer = tokio::spawn(async move { + write_frame(&mut client, &p2).await.unwrap(); + }); + let got = read_frame(&mut server).await.unwrap(); + writer.await.unwrap(); + assert_eq!(got, payload); + } + + #[tokio::test] + async fn write_rejects_oversized_frame() { + let (mut client, _server) = tokio::io::duplex(1024); + let body = vec![0u8; MAX_FRAME_BYTES as usize + 1]; + assert!(write_frame(&mut client, &body).await.is_err()); + } + + #[tokio::test] + async fn read_rejects_oversized_announcement() { + // A peer announcing a huge frame must be rejected before allocation. + let (mut client, mut server) = tokio::io::duplex(64); + tokio::io::AsyncWriteExt::write_all(&mut client, &(MAX_FRAME_BYTES + 1).to_le_bytes()) + .await + .unwrap(); + assert!(read_frame(&mut server).await.is_err()); + } +} diff --git a/crates/goose-roaming/src/handshake.rs b/crates/goose-roaming/src/handshake.rs new file mode 100644 index 000000000..3413de738 --- /dev/null +++ b/crates/goose-roaming/src/handshake.rs @@ -0,0 +1,43 @@ +//! The roaming handshake exchanged on a freshly-accepted bi-stream, before the +//! stream is handed to ACP. +//! +//! There is **no capability token**. A connecting node is identified by the +//! public key that iroh's QUIC-TLS handshake already authenticated +//! (`connection.remote_id()`), and the host authorizes purely by whether that +//! key is on its allowlist. Trust is mutual and key-based: you exchange +//! [`crate::ConnectionCard`]s out of band and each side chooses to accept the +//! other. +//! +//! Flow: +//! 1. Client opens a bi-stream and sends [`ClientHello`] (just a display label; +//! its identity is already proven by the transport). +//! 2. Host checks the authenticated remote key against its allowlist/revocations +//! and replies with [`HostAck`]. +//! 3. On accept, both sides treat the remainder of the stream as an ACP byte +//! stream. + +use serde::{Deserialize, Serialize}; + +/// First message a connecting client sends. Carries no credential — the +/// client's identity is the key the transport authenticated. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ClientHello { + /// Human-readable client label for the host's directory (best-effort, not + /// trusted for authorization). + pub label: Option, +} + +/// Host's response to a [`ClientHello`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HostAck { + /// Connection accepted; the client gets goose's full ACP surface. + Accepted { agent_id: String }, + /// Connection refused with a coarse reason code. + Rejected { code: String }, +} + +impl ClientHello { + pub fn new(label: Option) -> Self { + Self { label } + } +} diff --git a/crates/goose-roaming/src/identity.rs b/crates/goose-roaming/src/identity.rs new file mode 100644 index 000000000..7473d0a05 --- /dev/null +++ b/crates/goose-roaming/src/identity.rs @@ -0,0 +1,208 @@ +//! Persisted roaming node identity. +//! +//! A roaming node is identified by an ed25519 keypair. The public key doubles +//! as the iroh [`EndpointId`], so the identity is self-certifying: iroh proves +//! at the QUIC-TLS handshake that a peer holds the secret for the id it claims. +//! +//! The secret key is persisted as hex in a `0600` file inside goose's config +//! directory. This mirrors the storage approach used by a sibling production +//! iroh project. + +use std::path::{Path, PathBuf}; + +use iroh::{PublicKey, SecretKey}; + +use crate::error::RoamingError; + +const KEY_FILE_NAME: &str = "roaming_node_key"; + +/// A roaming node's long-lived identity. +#[derive(Clone)] +pub struct RoamingIdentity { + secret: SecretKey, +} + +impl RoamingIdentity { + /// Wrap an existing secret key. + pub fn from_secret(secret: SecretKey) -> Self { + Self { secret } + } + + /// Generate a fresh, ephemeral identity (not persisted). + pub fn generate() -> Self { + Self { + secret: SecretKey::generate(), + } + } + + /// Load the node identity from `path`, creating and persisting a new one if + /// the file does not exist. + /// + /// First creation is serialized on a sidecar advisory lock and lands via a + /// fully written temporary file renamed into place, so racing processes + /// agree on one key and a crash mid-write can never leave a truncated key + /// at the final path (which would permanently disable roaming until the + /// user deletes it — changing the endpoint ID peers trusted). + pub fn load_or_create(path: &Path) -> Result { + use fs2::FileExt as _; + + if path.exists() { + return Self::load(path); + } + + let parent = path.parent().ok_or_else(|| { + RoamingError::Identity(format!("key path {} has no parent", path.display())) + })?; + ensure_private_dir(parent)?; + + let lock_path = path.with_extension("lock"); + let lock = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + lock.lock_exclusive()?; + + let result = (|| { + // A racer may have created the key while we waited for the lock. + if path.exists() { + return Self::load(path); + } + let identity = Self::generate(); + identity.save(path)?; + Ok(identity) + })(); + + let _ = fs2::FileExt::unlock(&lock); + result + } + + /// Load the node identity from a hex-encoded key file. + pub fn load(path: &Path) -> Result { + ensure_private_file(path)?; + let hex = std::fs::read_to_string(path)?; + let bytes = decode_hex_key(hex.trim())?; + Ok(Self { + secret: SecretKey::from_bytes(&bytes), + }) + } + + /// Persist the identity to `path` with `0600` permissions. + pub fn save(&self, path: &Path) -> Result<(), RoamingError> { + let parent = path.parent().ok_or_else(|| { + RoamingError::Identity(format!("key path {} has no parent", path.display())) + })?; + ensure_private_dir(parent)?; + let encoded = encode_hex_key(&self.secret.to_bytes()); + write_atomically(path, encoded.as_bytes())?; + ensure_private_file(path)?; + Ok(()) + } + + /// The iroh secret key. + pub(crate) fn secret_key(&self) -> &SecretKey { + &self.secret + } + + /// The node's public key / [`iroh::EndpointId`]. + pub fn public_key(&self) -> PublicKey { + self.secret.public() + } +} + +/// The default node key path inside goose's config directory. +pub fn default_key_path(config_dir: &Path) -> PathBuf { + config_dir.join(KEY_FILE_NAME) +} + +fn encode_hex_key(bytes: &[u8; 32]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + out +} + +fn decode_hex_key(hex: &str) -> Result<[u8; 32], RoamingError> { + if hex.len() != 64 { + return Err(RoamingError::Identity(format!( + "node key must be 64 hex chars, got {}", + hex.len() + ))); + } + let mut bytes = [0u8; 32]; + for (i, chunk) in hex.as_bytes().chunks(2).enumerate() { + let s = std::str::from_utf8(chunk) + .map_err(|_| RoamingError::Identity("node key is not valid utf-8".into()))?; + bytes[i] = u8::from_str_radix(s, 16) + .map_err(|_| RoamingError::Identity("node key has invalid hex".into()))?; + } + Ok(bytes) +} + +fn write_atomically(path: &Path, contents: &[u8]) -> Result<(), RoamingError> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(unix)] +fn ensure_private_dir(dir: &Path) -> Result<(), RoamingError> { + use std::os::unix::fs::PermissionsExt; + std::fs::create_dir_all(dir)?; + let mut perms = std::fs::metadata(dir)?.permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(dir, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_private_dir(dir: &Path) -> Result<(), RoamingError> { + std::fs::create_dir_all(dir)?; + Ok(()) +} + +#[cfg(unix)] +fn ensure_private_file(path: &Path) -> Result<(), RoamingError> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_private_file(_path: &Path) -> Result<(), RoamingError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_hex_key() { + let secret = SecretKey::generate(); + let bytes = secret.to_bytes(); + let hex = encode_hex_key(&bytes); + assert_eq!(decode_hex_key(&hex).unwrap(), bytes); + } + + #[test] + fn load_or_create_persists_stable_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = default_key_path(dir.path()); + + let first = RoamingIdentity::load_or_create(&path).unwrap(); + let second = RoamingIdentity::load_or_create(&path).unwrap(); + + assert_eq!(first.public_key(), second.public_key()); + } + + #[test] + fn rejects_malformed_key() { + assert!(decode_hex_key("nothex").is_err()); + assert!(decode_hex_key("ab").is_err()); + } +} diff --git a/crates/goose-roaming/src/lib.rs b/crates/goose-roaming/src/lib.rs new file mode 100644 index 000000000..c7e518dfc --- /dev/null +++ b/crates/goose-roaming/src/lib.rs @@ -0,0 +1,54 @@ +//! Peer-to-peer roaming transport for goose agents. +//! +//! This crate lets a goose agent expose itself over the internet via +//! [iroh](https://iroh.computer) so that a remote ACP client (another goose, or +//! any other ACP client) can connect to and drive it through a +//! relay, with no open ports. +//! +//! # Building blocks +//! +//! * [`RoamingIdentity`] — a persisted ed25519 node key whose public half is +//! the iroh endpoint id (self-certifying at the QUIC-TLS handshake). +//! * [`ConnectionCard`] — a non-secret, shareable string carrying a node's +//! public key + relay URLs (plus a short fingerprint for out-of-band +//! verification). It never expires and grants nothing on its own. +//! * [`TrustBook`] — the local, mutual allowlist: which peer keys this node +//! accepts, plus revocations. Access exists only by accepting a key; there is +//! no bearer token. An accepted peer gets goose's full ACP surface. +//! * [`RoamingNode`] — owns the iroh endpoint + router, hosts agents over the +//! `goose-acp/1` ALPN, and dials remote agents. +//! +//! The crate deliberately knows nothing about goose's agent internals: hosting +//! is driven through the [`AcpStreamServer`] trait, which the integration layer +//! implements by calling goose's generic `acp::server::serve`. This keeps the +//! heavy iroh dependency out of the `goose` core crate entirely. + +mod card; +mod directory; +mod error; +mod frame; +mod handshake; +mod identity; +mod node; +mod peerbook; +mod relay; +mod trust; + +pub use card::ConnectionCard; +pub use directory::{Direction, Directory, PeerEntry}; +#[doc(inline)] +pub use iroh::EndpointId; + +/// Parse an [`EndpointId`] (a peer's public key) from its string form. +pub fn parse_endpoint_id(s: &str) -> Result { + s.parse() + .map_err(|e| RoamingError::Identity(format!("invalid endpoint id `{s}`: {e}"))) +} +pub use error::RoamingError; +pub use identity::{default_key_path, RoamingIdentity}; +pub use node::{ + AcpStreamServer, RoamingClientStream, RoamingConfig, RoamingNode, ROAMING_ACP_ALPN, +}; +pub use peerbook::{PeerBook, PeerRecord}; +pub use relay::{RelayEntry, RelaySettings}; +pub use trust::TrustBook; diff --git a/crates/goose-roaming/src/node.rs b/crates/goose-roaming/src/node.rs new file mode 100644 index 000000000..f51817790 --- /dev/null +++ b/crates/goose-roaming/src/node.rs @@ -0,0 +1,681 @@ +//! The roaming node: owns the iroh [`Endpoint`] and [`Router`], hosts agents +//! over the `goose-acp/1` ALPN, and dials remote agents as a client. + +use std::sync::Arc; + +use futures::io::{AsyncRead, AsyncWrite}; +use iroh::{ + endpoint::Connection, + protocol::{AcceptError, ProtocolHandler, Router}, + Endpoint, EndpointId, +}; +use tokio::sync::Mutex; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use crate::card::ConnectionCard; +use crate::directory::{Direction, Directory}; +use crate::error::RoamingError; +use crate::frame::{read_frame, write_frame}; +use crate::handshake::{ClientHello, HostAck}; +use crate::identity::RoamingIdentity; +use crate::relay::RelaySettings; +use crate::trust::TrustBook; + +/// ALPN identifying the goose ACP-over-iroh protocol. +pub const ROAMING_ACP_ALPN: &[u8] = b"goose-acp/1"; + +/// Cap on the handshake phase (open bi-stream + read the client hello). A peer +/// that connects and then stalls without completing the handshake is dropped +/// rather than parking the accept task indefinitely (Slowloris guard). +const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// How often a sharing node re-checks the persisted allowlist to force-close +/// connections for revoked keys. Armed automatically by [`RoamingNode::share`]. +const DEFAULT_REVOCATION_POLL: std::time::Duration = std::time::Duration::from_secs(2); + +/// Serves an accepted, authorized ACP byte stream. Implemented by the +/// integration layer (e.g. `goose-cli`) so this crate does not depend on the +/// concrete agent/session machinery. +pub trait AcpStreamServer: Send + Sync + 'static { + /// Drive the ACP protocol to completion over the given stream for the + /// accepted peer identified by `client`. + fn serve_stream( + &self, + client: EndpointId, + recv: Box, + send: Box, + ) -> futures::future::BoxFuture<'static, anyhow::Result<()>>; + + /// A stable, human-facing id for the agent being shared, surfaced to + /// clients in the handshake ack. + fn agent_id(&self) -> String; +} + +/// Configuration for binding a roaming node. +/// +/// For the common case use [`RoamingConfig::new`] and the `with_*` chainers, +/// which default to iroh's public relays, an empty allowlist (accepts no +/// one), and an in-memory directory: +/// +/// ```no_run +/// use goose_roaming::{RoamingConfig, RoamingIdentity, RoamingNode}; +/// # async fn f() -> anyhow::Result<()> { +/// let node = RoamingNode::bind(RoamingConfig::new(RoamingIdentity::generate())).await?; +/// # Ok(()) } +/// ``` +pub struct RoamingConfig { + pub identity: RoamingIdentity, + pub relay: RelaySettings, + pub trust: TrustBook, + /// Optional path to the persisted trust allowlist. When set, it is + /// re-read on every inbound connection so `peers accept`/`revoke` from a + /// separate process take effect against a running `share` without a + /// restart. When `None` only the in-memory `trust` is consulted. + pub trust_path: Option, + /// Directory used to track observed connections. Defaults to an in-memory + /// directory; pass [`Directory::persistent`] to make `roam list` work from + /// a separate process. + pub directory: Directory, + /// Optional explicit socket address to bind the QUIC endpoint to. When set + /// with relays disabled, the default IP transports are cleared first so a + /// single-family local path is used — iroh's multipath negotiation + /// otherwise stalls (`MultipathNotNegotiated`) when both the specified IPv4 + /// and a default `[::]` IPv6 socket are candidates with no relay fallback. + pub bind_addr: Option, + /// Override the CA trust used for relay TLS. `None` (the default) uses + /// the system roots. Tests pass `CaTlsConfig::insecure_skip_verify()` + /// (available under iroh's `test-utils` feature) to run against a local + /// self-signed relay. + pub relay_tls: Option, +} + +impl RoamingConfig { + /// A config for `identity` with sensible defaults: iroh's public relays, + /// an empty trust allowlist (accepts no one until a peer key is accepted), + /// an in-memory directory, and no explicit bind address. + pub fn new(identity: RoamingIdentity) -> Self { + Self { + identity, + relay: RelaySettings::N0Default, + trust: TrustBook::new(), + trust_path: None, + directory: Directory::new(), + bind_addr: None, + relay_tls: None, + } + } + + /// Use a specific relay configuration (default: iroh's public relays). + pub fn with_relay(mut self, relay: RelaySettings) -> Self { + self.relay = relay; + self + } + + /// Bind the QUIC endpoint to a specific socket address. + pub fn with_bind_addr(mut self, addr: std::net::SocketAddr) -> Self { + self.bind_addr = Some(addr); + self + } +} + +/// A bound roaming node. +pub struct RoamingNode { + endpoint: Endpoint, + router: Mutex>, + trust: Arc>, + trust_path: Option, + directory: Directory, + relay: RelaySettings, + /// Live authorized inbound connections, by peer key. Lets revocation reach + /// into the open data plane: a key that leaves the allowlist gets its + /// connections force-closed, not just refused on the next dial. + live: Mutex>>, + revocation_watcher: Mutex>>, +} + +impl RoamingNode { + /// Bind the iroh endpoint. Does not start accepting until [`Self::share`] + /// (or a manual router) is set up. + pub async fn bind(config: RoamingConfig) -> Result, RoamingError> { + let relay_mode = config.relay.to_relay_mode()?; + let relay = config.relay.clone(); + let relays_disabled = matches!(config.relay, RelaySettings::Disabled); + let mut builder = Endpoint::builder(iroh::endpoint::presets::Minimal) + .secret_key(config.identity.secret_key().clone()) + .relay_mode(relay_mode); + if let Some(addr) = config.bind_addr { + if relays_disabled && addr.is_ipv4() { + builder = builder.clear_ip_transports(); + } + builder = builder + .bind_addr(addr) + .map_err(|e| RoamingError::Transport(format!("invalid bind address: {e}")))?; + } + if let Some(relay_tls) = config.relay_tls { + builder = builder.ca_tls_config(relay_tls); + } + let endpoint = builder + .bind() + .await + .map_err(|e| RoamingError::Transport(format!("failed to bind endpoint: {e}")))?; + + Ok(Arc::new(Self { + endpoint, + router: Mutex::new(None), + trust: Arc::new(Mutex::new(config.trust)), + trust_path: config.trust_path, + directory: config.directory, + relay, + live: Mutex::new(std::collections::HashMap::new()), + revocation_watcher: Mutex::new(None), + })) + } + + /// The node's public key / endpoint id. + pub fn endpoint_id(&self) -> EndpointId { + self.endpoint.id() + } + + /// Access the underlying iroh endpoint (advanced use). + pub fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + /// Shared trust book (for CLI commands to inspect/mutate). + pub fn trust(&self) -> Arc> { + self.trust.clone() + } + + /// Start accepting inbound ACP connections, serving each authorized stream + /// via `server`. Returns once the router is spawned; it runs in the + /// background until [`Self::shutdown`]. + /// + /// If a `trust_path` is configured, live revocation is armed automatically: + /// a watcher force-closes existing connections when their key leaves the + /// allowlist. This is a crate invariant, not something callers opt into — + /// a share that only checked trust at connect time would let a revoked + /// peer keep an already-open session. + pub async fn share( + self: &Arc, + server: Arc, + ) -> Result<(), RoamingError> { + let handler = RoamingAcpHandler { + node: self.clone(), + server, + }; + let router = Router::builder(self.endpoint.clone()) + .accept(ROAMING_ACP_ALPN, handler) + .spawn(); + *self.router.lock().await = Some(router); + self.watch_revocations(DEFAULT_REVOCATION_POLL).await; + Ok(()) + } + + /// Wait (up to `timeout`) for the endpoint to contact a relay and be + /// reachable. Returns `true` if it came online. + pub async fn wait_online(&self, timeout: std::time::Duration) -> bool { + tokio::time::timeout(timeout, self.endpoint.online()) + .await + .is_ok() + } + + /// The endpoint's currently-known relay URLs, read from its live address. + /// These are what let a client reach this node when no static relay URLs + /// are configured (e.g. under [`RelaySettings::N0Default`]). + pub fn live_relay_urls(&self) -> Vec { + self.endpoint + .addr() + .addrs + .into_iter() + .filter_map(|addr| match addr { + iroh::TransportAddr::Relay(url) => Some(url.to_string()), + _ => None, + }) + .collect() + } + + /// Produce this node's [`ConnectionCard`]: its public identity plus the + /// relay URLs a peer needs to reach it. The card carries nothing secret and + /// grants no access — a peer must also be accepted into this node's trust + /// allowlist before it can connect. + /// + /// The relays advertised merge the configured relays with any live relay the + /// endpoint has since registered with. Call [`Self::wait_online`] first so a + /// live relay URL is available. + pub fn card(&self) -> ConnectionCard { + let mut relay_urls = self.relay.advertised_urls(); + for url in self.live_relay_urls() { + if !relay_urls.contains(&url) { + relay_urls.push(url); + } + } + ConnectionCard::new(self.endpoint_id(), relay_urls) + } + + /// Cleanly shut the router and endpoint down. + pub async fn shutdown(&self) -> Result<(), RoamingError> { + if let Some(watcher) = self.revocation_watcher.lock().await.take() { + watcher.abort(); + } + if let Some(router) = self.router.lock().await.take() { + router + .shutdown() + .await + .map_err(|e| RoamingError::Transport(format!("router shutdown: {e}")))?; + } + self.endpoint.close().await; + Ok(()) + } + + async fn register_live(&self, client: EndpointId, connection: Connection) { + self.live + .lock() + .await + .entry(client) + .or_default() + .push(connection); + } + + async fn unregister_live(&self, client: EndpointId, connection: &Connection) { + let mut live = self.live.lock().await; + if let Some(conns) = live.get_mut(&client) { + conns.retain(|c| c.stable_id() != connection.stable_id()); + if conns.is_empty() { + live.remove(&client); + } + } + } + + /// Force-close any live connections whose peer key is no longer allowed by + /// `trust`. Revocation reaches into the open data plane: the allowlist is + /// not just a gate on new dials, it is authority over connections that + /// already exist. Returns the number of connections closed. + pub async fn enforce_trust(&self, trust: &TrustBook) -> usize { + let to_close: Vec<(EndpointId, Vec)> = { + let mut live = self.live.lock().await; + let revoked: Vec = live + .keys() + .filter(|key| !trust.is_allowed(key)) + .copied() + .collect(); + revoked + .into_iter() + .filter_map(|key| live.remove(&key).map(|conns| (key, conns))) + .collect() + }; + let mut closed = 0; + for (client, conns) in to_close { + for conn in conns { + conn.close(0u32.into(), b"revoked"); + closed += 1; + // One disconnect per closed connection so the directory's + // per-peer live count reaches zero. + self.directory.record_disconnect(client, now_ms()).await; + } + tracing::info!(%client, "roaming: force-closed live connection(s) for revoked key"); + } + closed + } + + /// Watch the persisted trust allowlist and force-close live connections + /// for any key that leaves it. Complements the per-connection re-read in + /// the accept path: that gates *new* dials, this revokes *existing* ones. + /// No-op unless a `trust_path` is configured. + pub async fn watch_revocations(self: &Arc, poll: std::time::Duration) { + let Some(path) = self.trust_path.clone() else { + return; + }; + let node = Arc::downgrade(self); + let handle = tokio::spawn(async move { + let mut last_modified: Option = None; + loop { + tokio::time::sleep(poll).await; + let Some(node) = node.upgrade() else { break }; + let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); + if modified == last_modified { + continue; + } + match TrustBook::load(&path) { + Ok(book) => { + // Only advance the watermark on a successful load so a + // transient failure is retried on the next poll rather + // than leaving revoked connections open. + last_modified = modified; + node.enforce_trust(&book).await; + } + Err(e) => { + tracing::warn!("roaming: trust reload failed in revocation watcher: {e}"); + } + } + } + }); + if let Some(old) = self.revocation_watcher.lock().await.replace(handle) { + old.abort(); + } + } + + /// Dial a remote node using its [`ConnectionCard`], returning the authorized + /// bi-stream halves ready to feed to an ACP client transport. + /// + /// The dial target is reconstructed from the card (endpoint id + relay + /// URLs). The connection only succeeds if the remote has accepted *this* + /// node's key into its allowlist. Use [`Self::connect_with_addr`] when the + /// caller already has a dialable [`EndpointAddr`] (e.g. a direct LAN address + /// learned out of band). + pub async fn connect( + &self, + card: &ConnectionCard, + label: Option, + ) -> Result { + let addr = card.endpoint_addr()?; + self.connect_with_addr(addr, label).await + } + + /// Dial a remote node at an explicit [`EndpointAddr`]. Authorization happens + /// on the remote side purely by this node's authenticated key. + pub async fn connect_with_addr( + &self, + addr: iroh::EndpointAddr, + label: Option, + ) -> Result { + let conn = self + .endpoint + .connect(addr, ROAMING_ACP_ALPN) + .await + .map_err(|e| RoamingError::Transport(format!("connect failed: {e}")))?; + + // Bound the client side of the handshake too: a host that accepts the + // connection but never acks (or stalls mid-frame) must not park the + // dialer forever. Mirrors the host-side Slowloris guard. + let handshake = async { + let (mut send, mut recv) = conn + .open_bi() + .await + .map_err(|e| RoamingError::Transport(format!("open_bi failed: {e}")))?; + + let hello = ClientHello::new(label); + let hello_bytes = serde_json::to_vec(&hello) + .map_err(|e| RoamingError::Transport(format!("encode hello: {e}")))?; + write_frame(&mut send, &hello_bytes).await?; + + let ack_bytes = read_frame(&mut recv).await?; + let ack: HostAck = serde_json::from_slice(&ack_bytes) + .map_err(|e| RoamingError::Transport(format!("decode ack: {e}")))?; + Ok::<_, RoamingError>((send, recv, ack)) + }; + let (send, recv, ack) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) + .await + .map_err(|_| RoamingError::Transport("handshake timed out".into()))??; + + match ack { + HostAck::Accepted { agent_id } => { + // Host-controlled display text: printed by connect/delegate + // and persisted for `roam connections`. Sanitize like client + // labels so a malicious host can't inject terminal control + // sequences. + let agent_id = + sanitize_label(agent_id).unwrap_or_else(|| "remote-agent".to_string()); + self.directory + .record_connect( + conn.remote_id(), + None, + Direction::Outbound, + Some(agent_id.clone()), + now_ms(), + ) + .await; + // Balance the connect when the dialed connection closes for + // any reason (normal command teardown included), so the + // directory doesn't report the remote connected forever. + { + let directory = self.directory.clone(); + let remote = conn.remote_id(); + let watched = conn.clone(); + tokio::spawn(async move { + watched.closed().await; + directory.record_disconnect(remote, now_ms()).await; + }); + } + Ok(RoamingClientStream { + agent_id, + conn, + send, + recv, + }) + } + HostAck::Rejected { code } => Err(RoamingError::Rejected(code)), + } + } +} + +/// A dialed, authorized client stream to a remote agent. +pub struct RoamingClientStream { + pub agent_id: String, + /// Kept alive so the connection isn't dropped while the stream is in use. + pub conn: Connection, + pub send: iroh::endpoint::SendStream, + pub recv: iroh::endpoint::RecvStream, +} + +impl RoamingClientStream { + /// Consume the stream into `futures::io` read/write halves ready to feed to + /// an ACP client transport (e.g. `ByteStreams::new(send, recv)`), plus the + /// live [`Connection`] which the caller must keep alive for the duration of + /// the session. This saves consumers from repeating the tokio-compat dance. + pub fn into_futures_io( + self, + ) -> ( + impl AsyncWrite + Send + Unpin, + impl AsyncRead + Send + Unpin, + Connection, + ) { + (self.send.compat_write(), self.recv.compat(), self.conn) + } +} + +struct RoamingAcpHandler { + node: Arc, + server: Arc, +} + +impl std::fmt::Debug for RoamingAcpHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RoamingAcpHandler") + } +} + +impl ProtocolHandler for RoamingAcpHandler { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let client = connection.remote_id(); + + // Bound the whole handshake phase so a peer that connects and then + // stalls (never opening its stream or never sending a hello) is dropped + // instead of parking this task forever (Slowloris guard). + let handshake = async { + let (send, mut recv) = connection.accept_bi().await?; + // Authorize the key *before* acking so "Accepted" is truthful. + let decision = self.authorize(client, &mut recv).await; + Ok::<_, AcceptError>((send, recv, decision)) + }; + let (mut send, recv, decision) = + match tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake).await { + Ok(Ok(parts)) => parts, + Ok(Err(e)) => return Err(e), + Err(_) => { + tracing::info!(%client, "roaming: handshake timed out; dropping connection"); + return Ok(()); + } + }; + match decision { + Ok(label) => { + let agent_id = self.server.agent_id(); + let ack = HostAck::Accepted { + agent_id: agent_id.clone(), + }; + if let Err(e) = send_ack(&mut send, &ack).await { + tracing::warn!("roaming: failed to send accept ack: {e}"); + return Ok(()); + } + self.node.register_live(client, connection.clone()).await; + + // Close the accept/register TOCTOU: a revocation that lands + // after `authorize` read the allowlist but before the line + // above can be processed by the revocation watcher while this + // connection is still absent from `live`, so it would never be + // force-closed. Re-read trust now that we are registered; if + // the key is no longer allowed, drop the connection here. A + // failed reload fails closed, matching `authorize`. + let still_allowed = match &self.node.trust_path { + Some(path) => match TrustBook::load(path) { + Ok(book) => book.is_allowed(&client), + Err(e) => { + tracing::warn!(%client, "roaming: trust recheck failed, closing: {e}"); + false + } + }, + None => self.node.trust.lock().await.is_allowed(&client), + }; + if !still_allowed { + self.node.unregister_live(client, &connection).await; + connection.close(0u32.into(), b"revoked"); + tracing::info!(%client, "roaming: revoked during accept; closed connection"); + return Ok(()); + } + + self.node + .directory + .record_connect(client, label, Direction::Inbound, Some(agent_id), now_ms()) + .await; + let recv_box: Box = Box::new(recv.compat()); + let send_box: Box = Box::new(send.compat_write()); + if let Err(e) = self.server.serve_stream(client, recv_box, send_box).await { + tracing::warn!("roaming: ACP session ended with error: {e}"); + } + self.node.unregister_live(client, &connection).await; + self.node + .directory + .record_disconnect(client, now_ms()) + .await; + } + Err(reason) => { + let ack = HostAck::Rejected { + code: reason.to_string(), + }; + let _ = send_ack(&mut send, &ack).await; + // Returning drops the connection, and a QUIC close can beat + // the ack to the peer — which would turn a precise "rejected: + // not_allowlisted" into a generic "connection lost" on the + // client. Finish the stream and give the client a moment to + // read the ack and close first. + let _ = send.finish(); + let _ = + tokio::time::timeout(std::time::Duration::from_secs(3), connection.closed()) + .await; + tracing::info!(%client, reason = %reason, "roaming: rejected connection"); + } + } + Ok(()) + } +} + +impl RoamingAcpHandler { + /// Authorize a connection purely by the transport-authenticated peer key. + /// The peer's identity is already proven by QUIC-TLS; this only checks the + /// allowlist. The [`ClientHello`] carries just a display label — nothing + /// trusted for authorization. Returns the sanitized label on success. + async fn authorize( + &self, + client: EndpointId, + recv: &mut iroh::endpoint::RecvStream, + ) -> Result, String> { + let hello_bytes = read_frame(recv).await.map_err(|e| e.to_string())?; + let hello: ClientHello = + serde_json::from_slice(&hello_bytes).map_err(|e| format!("bad hello: {e}"))?; + + // Re-read the persisted allowlist so `peers accept`/`revoke` from a + // separate process take effect against this running share without a + // restart. Reads are atomic (writers rename into place), so we never see + // a half-written file. If a path is set but the read genuinely fails we + // fail *closed* rather than fall back to a stale in-memory book — a + // just-revoked peer must not slip through. We snapshot under the lock and + // drop it before any decision so the mutex isn't held across I/O. + let refreshed = match &self.node.trust_path { + Some(path) => match TrustBook::load(path) { + Ok(book) => Some(book), + Err(e) => { + tracing::warn!(%client, "roaming: trust reload failed, refusing: {e}"); + return Err("unavailable".to_string()); + } + }, + None => None, + }; + let trust = match &refreshed { + Some(book) => book, + None => &*self.node.trust.lock().await, + }; + if trust.is_key_revoked(&client) { + return Err("revoked".to_string()); + } + if !trust.is_allowed(&client) { + return Err("not_allowlisted".to_string()); + } + + Ok(hello.label.and_then(sanitize_label)) + } +} + +async fn send_ack( + send: &mut iroh::endpoint::SendStream, + ack: &HostAck, +) -> Result<(), RoamingError> { + let bytes = + serde_json::to_vec(ack).map_err(|e| RoamingError::Transport(format!("encode ack: {e}")))?; + write_frame(send, &bytes).await +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// The label is attacker-controlled display text (it comes from the connecting +/// peer's hello) and is surfaced in `roam connections`. Strip control +/// characters and cap the length so it can't corrupt terminal output; drop it +/// entirely if nothing printable remains. +fn sanitize_label(label: String) -> Option { + const MAX_LABEL_CHARS: usize = 64; + let cleaned: String = label + .chars() + .filter(|c| !c.is_control()) + .take(MAX_LABEL_CHARS) + .collect(); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::sanitize_label; + + #[test] + fn sanitize_label_strips_control_chars_and_caps_length() { + assert_eq!(sanitize_label("laptop".into()), Some("laptop".into())); + // Control chars (incl. ANSI escape) are removed. + assert_eq!( + sanitize_label("lap\x1b[31mtop\n".into()), + Some("lap[31mtop".into()) + ); + // Whitespace-only / empty collapses to None. + assert_eq!(sanitize_label(" ".into()), None); + assert_eq!(sanitize_label("\n\t".into()), None); + // Length is capped. + let long = "x".repeat(200); + assert_eq!(sanitize_label(long).unwrap().chars().count(), 64); + } +} diff --git a/crates/goose-roaming/src/peerbook.rs b/crates/goose-roaming/src/peerbook.rs new file mode 100644 index 000000000..95659f304 --- /dev/null +++ b/crates/goose-roaming/src/peerbook.rs @@ -0,0 +1,241 @@ +//! A user-managed address book of remote nodes you can connect to. +//! +//! Unlike [`crate::Directory`] (which records connections that actually +//! happened), the [`PeerBook`] holds saved remotes you *may* connect to, +//! addressed by a friendly nickname. Each entry stores the remote's +//! [`ConnectionCard`] — its public identity and how to reach it. A card is +//! **not** a secret and confers no access on its own; a connection only +//! succeeds if the remote has also chosen to accept this node's key. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::card::ConnectionCard; +use crate::error::RoamingError; + +/// A single saved remote node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeerRecord { + /// Friendly nickname used to `connect `. + pub name: String, + /// The remote's connection card (public identity + reachability). + pub card: ConnectionCard, + /// Cached for display without re-decoding. + pub endpoint_id: String, + /// Short fingerprint for out-of-band verification. + pub fingerprint: String, + pub added_ms: u64, +} + +/// A persisted map of nickname -> saved remote. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PeerBook { + peers: BTreeMap, + #[serde(skip)] + path: Option, +} + +impl PeerBook { + /// Load the peer book from `path`, or start empty if it does not exist. + /// Mutations are flushed back to `path`. + pub fn load(path: PathBuf) -> Result { + let mut book = match std::fs::read(&path) { + // Surface corrupt JSON instead of starting empty: the next mutating + // command would flush that empty book back and permanently lose + // every saved peer card and nickname. + Ok(bytes) => serde_json::from_slice::(&bytes) + .map_err(|e| RoamingError::Io(std::io::Error::other(e)))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => PeerBook::default(), + Err(e) => return Err(RoamingError::Io(e)), + }; + book.path = Some(path); + Ok(book) + } + + /// Save a remote under `name` from its shared card string. Returns an error + /// if the card is malformed. Overwrites an existing entry with the same + /// name (used to refresh a card whose relays changed). + pub fn save(&mut self, name: &str, card_str: &str, now_ms: u64) -> Result<(), RoamingError> { + let card = ConnectionCard::decode(card_str)?; + let record = PeerRecord { + name: name.to_string(), + endpoint_id: card.endpoint_id.to_string(), + fingerprint: card.fingerprint(), + card, + added_ms: now_ms, + }; + self.peers.insert(name.to_string(), record); + self.flush() + } + + /// Remove a saved remote. Returns whether it existed. + pub fn remove(&mut self, name: &str) -> Result { + let existed = self.peers.remove(name).is_some(); + if existed { + self.flush()?; + } + Ok(existed) + } + + /// Rename a saved remote. Returns an error if `from` is missing or `to` + /// already exists. + pub fn rename(&mut self, from: &str, to: &str) -> Result<(), RoamingError> { + if self.peers.contains_key(to) { + return Err(RoamingError::Card(format!("peer `{to}` already exists"))); + } + let mut record = self + .peers + .remove(from) + .ok_or_else(|| RoamingError::Card(format!("no peer named `{from}`")))?; + record.name = to.to_string(); + self.peers.insert(to.to_string(), record); + self.flush() + } + + /// Look up a saved remote by nickname. + pub fn get(&self, name: &str) -> Option<&PeerRecord> { + self.peers.get(name) + } + + /// All saved remotes, sorted by nickname. + pub fn list(&self) -> Vec<&PeerRecord> { + self.peers.values().collect() + } + + /// Read-modify-write the peer book under a cross-process advisory lock. + /// + /// Mirrors [`crate::TrustBook::update`]: atomic replacement protects + /// readers from partial JSON but not writers from lost updates — two + /// concurrent `goose roam peers`/pairing commands each load the whole + /// book, mutate, and save, so the last writer clobbers the other's add, + /// remove, or rename. The lock is held on a sidecar `.lock` file and + /// auto-releases if the holder dies. + pub fn update( + path: PathBuf, + mutate: impl FnOnce(&mut Self) -> Result, + ) -> Result { + use fs2::FileExt as _; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock_path = path.with_extension("json.lock"); + let lock = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + lock.lock_exclusive()?; + + let result = (|| { + let mut book = Self::load(path)?; + mutate(&mut book) + })(); + + let _ = fs2::FileExt::unlock(&lock); + result + } + + fn flush(&self) -> Result<(), RoamingError> { + let Some(path) = &self.path else { + return Ok(()); + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_vec_pretty(self) + .map_err(|e| RoamingError::Card(format!("serialize peer book: {e}")))?; + write_file(path, &json) + } +} + +fn write_file(path: &Path, contents: &[u8]) -> Result<(), RoamingError> { + // Unique per writer so concurrent flushes cannot rename each other's + // half-written bytes into place or fail on a stolen temporary file. + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::RoamingIdentity; + + fn make_card() -> String { + let host = RoamingIdentity::generate(); + ConnectionCard::new(host.public_key(), vec!["https://relay.example./".into()]) + .encode() + .unwrap() + } + + #[test] + fn corrupt_book_is_an_error_not_an_empty_book() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("peers.json"); + + let mut book = PeerBook::load(path.clone()).unwrap(); + book.save("work", &make_card(), 1_000).unwrap(); + + std::fs::write(&path, b"{ truncated").unwrap(); + + assert!( + PeerBook::load(path).is_err(), + "a corrupt peer book must fail to load; starting empty would let the \ + next mutating command flush the empty book back and lose every peer" + ); + } + + #[test] + fn save_get_remove() { + let dir = tempfile::tempdir().unwrap(); + let mut book = PeerBook::load(dir.path().join("peers.json")).unwrap(); + let card = make_card(); + + book.save("work", &card, 1_000).unwrap(); + let rec = book.get("work").unwrap(); + assert_eq!(rec.name, "work"); + assert!(!rec.fingerprint.is_empty()); + + assert!(book.remove("work").unwrap()); + assert!(book.get("work").is_none()); + assert!(!book.remove("work").unwrap()); + } + + #[test] + fn rename_rules() { + let dir = tempfile::tempdir().unwrap(); + let mut book = PeerBook::load(dir.path().join("peers.json")).unwrap(); + book.save("a", &make_card(), 1).unwrap(); + book.save("b", &make_card(), 1).unwrap(); + + assert!(book.rename("a", "b").is_err()); // target exists + assert!(book.rename("missing", "c").is_err()); // source missing + book.rename("a", "c").unwrap(); + assert!(book.get("a").is_none()); + assert_eq!(book.get("c").unwrap().name, "c"); + } + + #[test] + fn persists_across_loads() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("peers.json"); + { + let mut book = PeerBook::load(path.clone()).unwrap(); + book.save("work", &make_card(), 1).unwrap(); + } + let book = PeerBook::load(path).unwrap(); + assert_eq!(book.list().len(), 1); + assert_eq!(book.get("work").unwrap().name, "work"); + } + + #[test] + fn rejects_malformed_card() { + let dir = tempfile::tempdir().unwrap(); + let mut book = PeerBook::load(dir.path().join("peers.json")).unwrap(); + assert!(book.save("bad", "not-a-card", 1).is_err()); + } +} diff --git a/crates/goose-roaming/src/relay.rs b/crates/goose-roaming/src/relay.rs new file mode 100644 index 000000000..b07b5dfaf --- /dev/null +++ b/crates/goose-roaming/src/relay.rs @@ -0,0 +1,139 @@ +//! Relay configuration for the roaming endpoint. +//! +//! Relays give NAT traversal and a fallback path when a direct QUIC route +//! cannot be established. We use a *custom* relay map rather than iroh's default +//! n0 relays so a deployment can point at its own zero-trust relays. +//! +//! Per security review: relay URLs travel in connection cards, but relay +//! *auth credentials* never do — each endpoint must obtain its own. + +use iroh::{RelayConfig, RelayMap, RelayMode, RelayUrl}; + +use crate::error::RoamingError; + +/// How the roaming endpoint reaches relays. +#[derive(Debug, Clone, Default)] +pub enum RelaySettings { + /// Use iroh's built-in n0 production relays. Convenient for getting + /// started; n0 documents these as rate-limited and not for production. + #[default] + N0Default, + /// Use a custom set of relay URLs, optionally with a bearer auth token + /// per URL (for gated / zero-trust relays). + Custom(Vec), + /// No relays: direct connectivity only (LAN / already-reachable hosts). + Disabled, +} + +/// A single relay URL plus optional auth token. +#[derive(Debug, Clone)] +pub struct RelayEntry { + pub url: String, + pub auth_token: Option, + /// Port for the relay's QUIC address discovery (QAD) endpoint. `None` + /// means iroh's default QAD port — the same assumption iroh makes for its + /// own default relays. Address discovery is how a NAT'd peer learns its + /// reflexive address; without it, connections through a custom relay can + /// never upgrade to a direct path (found by @pstayets' real-NAT harness, + /// issue #10906). + pub qad_port: Option, +} + +impl RelayEntry { + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + auth_token: None, + qad_port: None, + } + } + + pub fn with_auth(url: impl Into, token: impl Into) -> Self { + Self { + url: url.into(), + auth_token: Some(token.into()), + qad_port: None, + } + } +} + +impl RelaySettings { + /// The relay URLs advertised to peers in connection cards (auth tokens stripped). + pub fn advertised_urls(&self) -> Vec { + match self { + RelaySettings::Custom(entries) => entries.iter().map(|e| e.url.clone()).collect(), + RelaySettings::N0Default | RelaySettings::Disabled => Vec::new(), + } + } + + /// Convert to an iroh [`RelayMode`]. + pub fn to_relay_mode(&self) -> Result { + match self { + RelaySettings::N0Default => Ok(RelayMode::Default), + RelaySettings::Disabled => Ok(RelayMode::Disabled), + RelaySettings::Custom(entries) => { + let mut configs = Vec::with_capacity(entries.len()); + for entry in entries { + let url: RelayUrl = entry.url.parse().map_err(|_| { + RoamingError::Transport(format!("bad relay url {}", entry.url)) + })?; + // Enable QUIC address discovery: without it a NAT'd peer + // never learns its reflexive address and the connection + // stays relay-only forever (no direct upgrade). Default + // port matches iroh's own relay defaults. + let quic = Some(match entry.qad_port { + Some(port) => iroh_relay::RelayQuicConfig::new(port), + None => iroh_relay::RelayQuicConfig::default(), + }); + let cfg = RelayConfig::new(url, quic); + let cfg = match &entry.auth_token { + Some(token) => { + // Never send a bearer token in cleartext: a + // token on an http:// relay would be readable by + // anyone on the path. + if !entry.url.trim_start().starts_with("https://") { + return Err(RoamingError::Transport(format!( + "relay {} has an auth token but is not https; \ + refusing to send the token in cleartext", + entry.url + ))); + } + cfg.with_auth_token(token.clone()) + } + None => cfg, + }; + configs.push(cfg); + } + Ok(RelayMode::Custom(RelayMap::from_iter(configs))) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_advertises_no_urls() { + assert!(RelaySettings::default().advertised_urls().is_empty()); + } + + #[test] + fn custom_strips_auth_from_advertised() { + let settings = RelaySettings::Custom(vec![RelayEntry::with_auth( + "https://relay.example./", + "secret", + )]); + assert_eq!(settings.advertised_urls(), vec!["https://relay.example./"]); + } + + #[test] + fn custom_builds_relay_mode() { + let settings = RelaySettings::Custom(vec![RelayEntry::new("https://relay.example./")]); + assert!(matches!( + settings.to_relay_mode().unwrap(), + RelayMode::Custom(_) + )); + } +} diff --git a/crates/goose-roaming/src/trust.rs b/crates/goose-roaming/src/trust.rs new file mode 100644 index 000000000..f991f9145 --- /dev/null +++ b/crates/goose-roaming/src/trust.rs @@ -0,0 +1,232 @@ +//! Local access-control state: which peer keys this node accepts inbound +//! connections from, and which are revoked. +//! +//! Trust is a **mutual, public-key allowlist**. A peer is identified by the key +//! iroh's QUIC-TLS handshake authenticated, and is admitted only if that key is +//! on this node's allowlist. There is no bearer/token mode: sharing a +//! [`crate::ConnectionCard`] grants nothing until the recipient explicitly +//! accepts the sender's key. An accepted peer gets goose's full ACP surface. +//! +//! This is deliberately local, unsigned admin state: it lives on the host under +//! the user's control. Authentication of *who* a peer is comes from the +//! transport; this layer decides *whether* they are authorized. + +use std::collections::BTreeSet; + +use iroh::EndpointId; +use serde::{Deserialize, Serialize}; + +/// Persisted trust state: the inbound allowlist plus revocations. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TrustBook { + /// Peer keys allowed to connect. + allowed: BTreeSet, + /// Peer keys that are refused regardless of anything else. + revoked_keys: BTreeSet, +} + +impl TrustBook { + pub fn new() -> Self { + Self::default() + } + + /// Accept inbound connections from `key`. Clears any prior revocation. + pub fn accept(&mut self, key: &EndpointId) { + let s = key_str(key); + self.revoked_keys.remove(&s); + self.allowed.insert(s); + } + + /// Stop accepting `key` and record it as revoked so a stale card can't + /// silently re-add it. + pub fn revoke_key(&mut self, key: &EndpointId) { + let s = key_str(key); + self.allowed.remove(&s); + self.revoked_keys.insert(s); + } + + /// Whether `key` is allowed to connect (on the allowlist and not revoked). + pub fn is_allowed(&self, key: &EndpointId) -> bool { + let s = key_str(key); + !self.revoked_keys.contains(&s) && self.allowed.contains(&s) + } + + pub(crate) fn is_key_revoked(&self, key: &EndpointId) -> bool { + self.revoked_keys.contains(&key_str(key)) + } + + /// Allowed peer keys, sorted. + pub fn allowed_keys(&self) -> Vec { + self.allowed.iter().cloned().collect() + } + + /// Load the trust book. A missing file is an empty book; a *malformed* + /// file is a hard error — silently treating corruption as "no one is + /// allowed" would strand peers, and treating it as "keep going" would be + /// worse. Callers on the authorization path fail closed on this error. + pub fn load(path: &std::path::Path) -> Result { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(std::io::Error::other), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e), + } + } + + /// Persist atomically (unique temp file + rename) so a concurrent reader + /// on the authorization path never observes a half-written file, and + /// concurrent writers never truncate each other's in-flight temp file. + pub fn save(&self, path: &std::path::Path) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + let tmp = path.with_extension(format!("json.tmp-{}", std::process::id())); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, path) + } + + /// Read-modify-write the trust book under a cross-process advisory lock. + /// + /// Atomic replacement in [`save`] protects readers from partial JSON but + /// not writers from lost updates: two `goose roam peers` commands (or any + /// other embedder) each load the whole book, mutate, and save, so the last + /// writer clobbers the other's change with a stale snapshot — + /// e.g. a concurrent accept resurrects a peer that was just revoked. This + /// serializes the whole load+mutate+save so those edits can't race. The + /// lock is held on a sidecar `.lock` file (never the book itself) and + /// auto-releases if the holder dies. + pub fn update( + path: &std::path::Path, + mutate: impl FnOnce(&mut Self), + ) -> Result { + use fs2::FileExt as _; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock_path = path.with_extension("json.lock"); + let lock = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + lock.lock_exclusive()?; + + let result = (|| { + let mut book = Self::load(path)?; + mutate(&mut book); + book.save(path)?; + Ok(book) + })(); + + let _ = fs2::FileExt::unlock(&lock); + result + } +} + +fn key_str(key: &EndpointId) -> String { + key.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use iroh::SecretKey; + + #[test] + fn allowlist_gates() { + let mut book = TrustBook::new(); + let key = SecretKey::generate().public(); + assert!(!book.is_allowed(&key)); + + book.accept(&key); + assert!(book.is_allowed(&key)); + } + + #[test] + fn revocation_removes_and_blocks() { + let mut book = TrustBook::new(); + let key = SecretKey::generate().public(); + book.accept(&key); + book.revoke_key(&key); + assert!(!book.is_allowed(&key)); + assert!(book.is_key_revoked(&key)); + } + + #[test] + fn accept_clears_prior_revocation() { + let mut book = TrustBook::new(); + let key = SecretKey::generate().public(); + book.revoke_key(&key); + book.accept(&key); + assert!(book.is_allowed(&key)); + assert!(!book.is_key_revoked(&key)); + } + + #[test] + fn persists_across_reload() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("trust.json"); + let key = SecretKey::generate().public(); + { + let mut book = TrustBook::new(); + book.accept(&key); + book.save(&path).unwrap(); + } + let reloaded = TrustBook::load(&path).unwrap(); + assert!(reloaded.is_allowed(&key)); + } + + #[test] + fn corrupt_file_is_a_hard_error_not_an_empty_book() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("trust.json"); + std::fs::write(&path, b"{ not json").unwrap(); + assert!(TrustBook::load(&path).is_err()); + } + + #[test] + fn missing_file_is_an_empty_book() { + let dir = tempfile::tempdir().unwrap(); + let book = TrustBook::load(&dir.path().join("nope.json")).unwrap(); + assert!(book.allowed_keys().is_empty()); + } + + #[test] + fn update_persists_the_mutation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("trust.json"); + let key = SecretKey::generate().public(); + TrustBook::update(&path, |book| book.accept(&key)).unwrap(); + assert!(TrustBook::load(&path).unwrap().is_allowed(&key)); + } + + #[test] + fn concurrent_updates_do_not_lose_writes() { + let dir = tempfile::tempdir().unwrap(); + let path = std::sync::Arc::new(dir.path().join("trust.json")); + let keys: Vec<_> = (0..8).map(|_| SecretKey::generate().public()).collect(); + + // Each thread does its own lock-guarded load+mutate+save. Without the + // cross-process lock these interleave and clobber each other; with it, + // every accepted key must survive. + let handles: Vec<_> = keys + .iter() + .map(|key| { + let path = std::sync::Arc::clone(&path); + let key = *key; + std::thread::spawn(move || { + TrustBook::update(&path, |book| book.accept(&key)).unwrap(); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + + let book = TrustBook::load(&path).unwrap(); + for key in &keys { + assert!(book.is_allowed(key), "lost an accepted key"); + } + } +} diff --git a/crates/goose-roaming/tests/end_to_end.rs b/crates/goose-roaming/tests/end_to_end.rs new file mode 100644 index 000000000..3be231638 --- /dev/null +++ b/crates/goose-roaming/tests/end_to_end.rs @@ -0,0 +1,294 @@ +//! End-to-end test: two roaming nodes connect over iroh (direct, relays +//! disabled) and exchange bytes through an authorized ACP stream. +//! +//! This validates the whole seam: bind -> swap identities -> accept key -> dial +//! -> handshake -> authorize -> stream hand-off. It uses a trivial echo "ACP +//! server" in place of goose's real ACP protocol, since this crate has no +//! dependency on the agent machinery. + +use std::sync::Arc; + +use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use goose_roaming::{ + AcpStreamServer, Directory, RelaySettings, RoamingConfig, RoamingIdentity, RoamingNode, + TrustBook, +}; +use iroh::EndpointId; + +/// A stand-in ACP server that echoes one line back, upper-cased. +#[derive(Debug)] +struct EchoServer; + +impl AcpStreamServer for EchoServer { + fn serve_stream( + &self, + _client: EndpointId, + mut recv: Box, + mut send: Box, + ) -> futures::future::BoxFuture<'static, anyhow::Result<()>> { + Box::pin(async move { + let mut buf = [0u8; 5]; + recv.read_exact(&mut buf).await?; + let upper: Vec = buf.iter().map(|b| b.to_ascii_uppercase()).collect(); + send.write_all(&upper).await?; + send.flush().await?; + // Keep the connection alive until the client is done reading and + // closes its side. Real ACP `serve` naturally runs a long-lived + // duplex loop; the echo stub must not return early or the QUIC + // connection would be torn down before delivery completes. + let mut drain = Vec::new(); + let _ = recv.read_to_end(&mut drain).await; + Ok(()) + }) + } + + fn agent_id(&self) -> String { + "echo-agent".to_string() + } +} + +/// Bind to an ephemeral loopback IPv4 port so relay-disabled tests use a single +/// local path (avoids iroh's dual-stack MultipathNotNegotiated stall). +fn loopback() -> std::net::SocketAddr { + "127.0.0.1:0".parse().unwrap() +} + +async fn bind_node() -> Arc { + RoamingNode::bind(RoamingConfig { + identity: RoamingIdentity::generate(), + relay: RelaySettings::Disabled, + trust: TrustBook::new(), + trust_path: None, + directory: Directory::new(), + bind_addr: Some(loopback()), + relay_tls: None, + }) + .await + .expect("bind node") +} + +/// Accept `client`'s key into `host`'s allowlist — the out-of-band "I will +/// accept connections from this node" step. +async fn host_accepts(host: &RoamingNode, client: &RoamingNode) { + host.trust().lock().await.accept(&client.endpoint_id()); +} + +/// A running share re-reads its trust file per connection, so acceptance +/// written out of band (as `roam peers accept` does) takes effect without a +/// restart. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn trust_file_refresh_takes_effect_on_running_share() { + let dir = tempfile::tempdir().unwrap(); + let trust_file = dir.path().join("trust.json"); + // Start with an empty trust file. + TrustBook::new().save(&trust_file).unwrap(); + + let host = RoamingNode::bind(RoamingConfig { + identity: RoamingIdentity::generate(), + relay: RelaySettings::Disabled, + trust: TrustBook::new(), + trust_path: Some(trust_file.clone()), + directory: Directory::new(), + bind_addr: Some(loopback()), + relay_tls: None, + }) + .await + .expect("bind host"); + host.share(Arc::new(EchoServer)).await.expect("share"); + + let client = bind_node().await; + + // Not accepted yet: refused. + assert!(connect_direct(&client, &host).await.is_err()); + + // Accept out of band by writing the trust file (as the CLI does). + let mut book = TrustBook::load(&trust_file).unwrap(); + book.accept(&client.endpoint_id()); + book.save(&trust_file).unwrap(); + + // Now it connects against the SAME running share — no restart. + let mut stream = connect_direct(&client, &host) + .await + .expect("accepted after file refresh"); + stream.send.finish().unwrap(); + + host.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn accepted_key_connects_and_streams() { + let host = bind_node().await; + host.share(Arc::new(EchoServer)).await.expect("share"); + + let client = bind_node().await; + // Host accepts the client's key (mutual, key-based trust). + host_accepts(&host, &client).await; + + let mut stream = connect_direct(&client, &host) + .await + .expect("client connects"); + + assert_eq!(stream.agent_id, "echo-agent"); + + { + stream.send.write_all(b"hello").await.unwrap(); + let mut out = [0u8; 5]; + stream.recv.read_exact(&mut out).await.unwrap(); + assert_eq!(&out, b"HELLO"); + // Close the client's send side so the host's drain read completes. + stream.send.finish().unwrap(); + } + + host.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unaccepted_key_is_rejected() { + let host = bind_node().await; + host.share(Arc::new(EchoServer)).await.expect("share"); + + // Client's key was never accepted: connection must be refused. + let client = bind_node().await; + let result = connect_direct(&client, &host).await; + assert!(result.is_err(), "unaccepted client should be rejected"); + + host.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn revoked_key_is_rejected() { + let host = bind_node().await; + host.share(Arc::new(EchoServer)).await.expect("share"); + + let client = bind_node().await; + host_accepts(&host, &client).await; + // Revoke after accepting: connection must now be refused. + host.trust().lock().await.revoke_key(&client.endpoint_id()); + + let result = connect_direct(&client, &host).await; + assert!(result.is_err(), "revoked client should be rejected"); + + host.shutdown().await.unwrap(); +} + +/// Revocation reaches into the open data plane: revoking a key while its +/// connection is live force-closes that connection — the peer cannot keep +/// using a capability it no longer holds. (The allowlist gating only new +/// dials is not enough; see #10906.) +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn revocation_closes_live_connection() { + let host = bind_node().await; + host.share(Arc::new(EchoServer)).await.expect("share"); + + let client = bind_node().await; + host_accepts(&host, &client).await; + + let mut stream = connect_direct(&client, &host) + .await + .expect("client connects while accepted"); + + // Prove the duplex is live mid-"prompt". + stream.send.write_all(b"hello").await.unwrap(); + let mut out = [0u8; 5]; + stream.recv.read_exact(&mut out).await.unwrap(); + assert_eq!(&out, b"HELLO"); + + // Revoke while the connection is open, then enforce. + let trust = host.trust(); + let book = { + let mut trust = trust.lock().await; + trust.revoke_key(&client.endpoint_id()); + trust.clone() + }; + let closed = host.enforce_trust(&book).await; + assert_eq!(closed, 1, "the live connection should be force-closed"); + + // The tab-side stream dies: the next read fails rather than hanging. + let mut more = [0u8; 1]; + let read = tokio::time::timeout( + std::time::Duration::from_secs(5), + stream.recv.read_exact(&mut more), + ) + .await; + assert!( + matches!(read, Ok(Err(_))), + "read after revocation should fail, got {read:?}" + ); + + // And the next dial is refused. + assert!( + connect_direct(&client, &host).await.is_err(), + "revoked client must not reconnect" + ); + + host.shutdown().await.unwrap(); +} + +/// The end-to-end shape of `roam peers revoke` against a running share: the +/// trust *file* changes out of band, the watcher notices, and the live +/// connection is closed without a restart. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn revocation_watcher_closes_live_connection_from_file() { + let dir = tempfile::tempdir().unwrap(); + let trust_file = dir.path().join("trust.json"); + + let client = bind_node().await; + + let mut book = TrustBook::new(); + book.accept(&client.endpoint_id()); + book.save(&trust_file).unwrap(); + + let host = RoamingNode::bind(RoamingConfig { + identity: RoamingIdentity::generate(), + relay: RelaySettings::Disabled, + trust: TrustBook::new(), + trust_path: Some(trust_file.clone()), + directory: Directory::new(), + bind_addr: Some(loopback()), + relay_tls: None, + }) + .await + .expect("bind host"); + host.share(Arc::new(EchoServer)).await.expect("share"); + host.watch_revocations(std::time::Duration::from_millis(100)) + .await; + + let mut stream = connect_direct(&client, &host) + .await + .expect("client connects while accepted"); + stream.send.write_all(b"hello").await.unwrap(); + let mut out = [0u8; 5]; + stream.recv.read_exact(&mut out).await.unwrap(); + + // Revoke out of band by rewriting the trust file (as the CLI does). + let mut book = TrustBook::load(&trust_file).unwrap(); + book.revoke_key(&client.endpoint_id()); + book.save(&trust_file).unwrap(); + + // The watcher should notice and kill the live connection. + let mut more = [0u8; 1]; + let read = tokio::time::timeout( + std::time::Duration::from_secs(10), + stream.recv.read_exact(&mut more), + ) + .await; + assert!( + matches!(read, Ok(Err(_))), + "watcher should force-close the live connection, got {read:?}" + ); + + host.shutdown().await.unwrap(); +} + +/// Dial the host on its live endpoint address (bypassing relay-based discovery, +/// since the test runs relay-disabled on localhost). Authorization is by the +/// client's authenticated key, which the host has accepted. +async fn connect_direct( + client: &RoamingNode, + host: &RoamingNode, +) -> Result { + let addr = host.endpoint().addr(); + client + .connect_with_addr(addr, Some("test-client".into())) + .await +} diff --git a/crates/goose-roaming/tests/path_upgrade.rs b/crates/goose-roaming/tests/path_upgrade.rs new file mode 100644 index 000000000..db0a7c47c --- /dev/null +++ b/crates/goose-roaming/tests/path_upgrade.rs @@ -0,0 +1,263 @@ +//! Relay -> direct path upgrade under continuous traffic. +//! +//! Field report on #10906 (via discussion #11024): on same-NAT/hairpin +//! topologies, a comparable overlay stack saw the relay path work and then a +//! direct-upgrade rekey desync silently drop queued messages until restart. +//! This test pins the equivalent seam in roam: two nodes meet through a relay +//! (an in-process iroh test relay), the client dials with a relay-only +//! address (exactly what a browser card connect does), traffic flows, iroh +//! holepunches a direct localhost path mid-stream, and every frame sent +//! before, during, and after the migration must come back intact and in +//! order. +//! +//! Localhost holepunching is the closest CI-runnable stand-in for the +//! same-NAT hairpin case: both sides observe reflexive addresses that end up +//! on the loopback/LAN, and the upgrade + rekey machinery is the same code +//! path that runs behind a hairpinning NAT. + +use std::sync::Arc; + +use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use goose_roaming::{ + AcpStreamServer, Directory, RelayEntry, RelaySettings, RoamingConfig, RoamingIdentity, + RoamingNode, TrustBook, +}; +use iroh::{EndpointId, TransportAddr}; + +/// Echoes every byte back until the client closes its send side. Unlike the +/// one-shot echo in `end_to_end.rs`, this keeps the duplex busy across the +/// path migration so a rekey desync would surface as lost or corrupted data. +#[derive(Debug)] +struct StreamingEchoServer; + +impl AcpStreamServer for StreamingEchoServer { + fn serve_stream( + &self, + _client: EndpointId, + mut recv: Box, + mut send: Box, + ) -> futures::future::BoxFuture<'static, anyhow::Result<()>> { + Box::pin(async move { + let mut buf = [0u8; 4096]; + loop { + let n = recv.read(&mut buf).await?; + if n == 0 { + return Ok(()); + } + send.write_all(&buf[..n]).await?; + send.flush().await?; + } + }) + } + + fn agent_id(&self) -> String { + "streaming-echo".to_string() + } +} + +async fn bind_node_with_relay(relay: RelaySettings) -> Arc { + RoamingNode::bind(RoamingConfig { + identity: RoamingIdentity::generate(), + relay, + trust: TrustBook::new(), + trust_path: None, + directory: Directory::new(), + bind_addr: None, + relay_tls: Some(iroh::tls::CaTlsConfig::insecure_skip_verify()), + }) + .await + .expect("bind node") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn relay_to_direct_upgrade_loses_no_data() { + let (_relay_map, relay_url, _relay_guard) = iroh::test_utils::run_relay_server() + .await + .expect("run test relay"); + let relay = RelaySettings::Custom(vec![RelayEntry::new(relay_url.to_string())]); + + let host = bind_node_with_relay(relay.clone()).await; + host.share(Arc::new(StreamingEchoServer)) + .await + .expect("share"); + + let client = bind_node_with_relay(relay).await; + host.trust().lock().await.accept(&client.endpoint_id()); + + assert!( + host.wait_online(std::time::Duration::from_secs(15)).await, + "host never reached the test relay" + ); + assert!( + client.wait_online(std::time::Duration::from_secs(15)).await, + "client never reached the test relay" + ); + + // Dial with a RELAY-ONLY address — the browser-card shape. The direct + // path must be discovered by holepunching, not seeded by the dialer. + let mut addr = iroh::EndpointAddr::new(host.endpoint_id()); + addr.addrs.insert(TransportAddr::Relay( + relay_url_of(&host).expect("host has a relay addr"), + )); + let mut stream = client + .connect_with_addr(addr, Some("hairpin-test".into())) + .await + .expect("connect through relay"); + + // Watch path events on the client side of the connection. + let mut path_events = stream.conn.path_events(); + + // Prove we really started on the relay: the dial address was relay-only, + // so a relay path must exist on the connection right now. Without this + // the test could silently pass on a direct-from-the-start connection and + // never exercise the migration at all. + let has_relay_path = stream + .conn + .paths() + .iter() + .any(|p| matches!(p.remote_addr(), TransportAddr::Relay(_))); + assert!( + has_relay_path, + "expected the connection to start with a relay path (dialed relay-only)" + ); + + // Phase 1: traffic while on the relay path. + let mut counter: u64 = 0; + exchange_frames(&mut stream, &mut counter, 50).await; + + // Wait for a direct (IP) path to open and be selected, pumping traffic + // the whole time so the migration happens under load. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let mut direct_selected = false; + while !direct_selected { + tokio::select! { + event = futures::StreamExt::next(&mut path_events) => { + match event { + Some(iroh::endpoint::PathEvent::Selected { remote_addr: TransportAddr::Ip(_), .. }) => { + direct_selected = true; + } + Some(_) => {} + None => panic!("path event stream ended before direct upgrade"), + } + } + _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { + exchange_frames(&mut stream, &mut counter, 5).await; + } + } + assert!( + tokio::time::Instant::now() < deadline, + "no direct path was selected within 30s (holepunching failed on localhost)" + ); + } + + // Phase 2: the rekey/migration just happened under load. Everything must + // still round-trip exactly. + exchange_frames(&mut stream, &mut counter, 50).await; + + assert!(counter >= 100, "test exchanged {counter} frames"); + stream.send.finish().unwrap(); + host.shutdown().await.unwrap(); +} + +/// Send `n` numbered frames and require each to echo back verbatim, in order. +/// Any dropped or reordered frame during path migration fails loudly here. +async fn exchange_frames( + stream: &mut goose_roaming::RoamingClientStream, + counter: &mut u64, + n: usize, +) { + for _ in 0..n { + let msg = format!("frame-{:08}", *counter); + stream + .send + .write_all(msg.as_bytes()) + .await + .unwrap_or_else(|e| panic!("write failed at frame {counter}: {e}")); + let mut buf = vec![0u8; msg.len()]; + tokio::time::timeout( + std::time::Duration::from_secs(10), + stream.recv.read_exact(&mut buf), + ) + .await + .unwrap_or_else(|_| panic!("echo timed out at frame {counter} — data lost in migration")) + .unwrap_or_else(|e| panic!("read failed at frame {counter}: {e}")); + assert_eq!( + buf, + msg.as_bytes(), + "frame {counter} corrupted across path migration" + ); + *counter += 1; + } +} + +/// Burst of concurrent dials to one host (field report: a comparable stack +/// lost replies under parallel opens until dials were serialized per +/// process). Roam multiplexes streams over one QUIC connection per peer +/// pair, so parallel connects must all succeed and each stream must echo +/// independently. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_dial_burst() { + let (_relay_map, relay_url, _relay_guard) = iroh::test_utils::run_relay_server() + .await + .expect("run test relay"); + let relay = RelaySettings::Custom(vec![RelayEntry::new(relay_url.to_string())]); + + let host = bind_node_with_relay(relay.clone()).await; + host.share(Arc::new(StreamingEchoServer)) + .await + .expect("share"); + assert!(host.wait_online(std::time::Duration::from_secs(15)).await); + + let client = bind_node_with_relay(relay).await; + host.trust().lock().await.accept(&client.endpoint_id()); + assert!(client.wait_online(std::time::Duration::from_secs(15)).await); + + let addr = { + let mut a = iroh::EndpointAddr::new(host.endpoint_id()); + a.addrs.insert(TransportAddr::Relay( + relay_url_of(&host).expect("host has a relay addr"), + )); + a + }; + + let mut tasks = Vec::new(); + for i in 0..8u32 { + let client = client.clone(); + let addr = addr.clone(); + tasks.push(tokio::spawn(async move { + let mut stream = client + .connect_with_addr(addr, Some(format!("burst-{i}"))) + .await + .unwrap_or_else(|e| panic!("parallel dial {i} failed: {e}")); + let msg = format!("burst-payload-{i:04}"); + stream.send.write_all(msg.as_bytes()).await.unwrap(); + let mut buf = vec![0u8; msg.len()]; + tokio::time::timeout( + std::time::Duration::from_secs(10), + stream.recv.read_exact(&mut buf), + ) + .await + .unwrap_or_else(|_| panic!("dial {i}: echo timed out under burst")) + .unwrap(); + assert_eq!(buf, msg.as_bytes(), "dial {i}: reply corrupted under burst"); + stream.send.finish().unwrap(); + })); + } + for t in tasks { + t.await.expect("burst task panicked"); + } + + host.shutdown().await.unwrap(); +} + +/// The relay transport addr the host's endpoint currently advertises. +fn relay_url_of(node: &RoamingNode) -> Option { + node.endpoint() + .addr() + .addrs + .into_iter() + .find_map(|a| match a { + TransportAddr::Relay(url) => Some(url), + _ => None, + }) +} diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index b30e9b52a..e90f053cc 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -234,9 +234,61 @@ struct GooseAcpSession { agent: Arc, } -struct ActivePromptRun { +pub struct ActivePromptRun { run_id: String, cancel_token: CancellationToken, + /// The agent actually running this prompt. Roaming gives each connection + /// its own agent, so a steer arriving on a second connection must be + /// routed here rather than to the caller's connection-local agent. + agent: Arc, +} + +/// Per-session active-run registry, shared by every `GooseAcpAgent` created +/// from one `AcpServer`. Roaming spawns a fresh agent per connection, so two +/// paired clients loading the same session get distinct agents; sharing this +/// map across them is what makes the "session already has an active run" guard +/// fire between connections instead of letting two loops interleave writes on +/// one session. +pub type ActiveRunRegistry = Arc>>; + +/// Releases a registry entry if the owning `on_prompt` future is dropped +/// without reaching its explicit `clear_active_run` — e.g. a roaming +/// connection is revoked or lost mid-prompt and the transport drops the +/// request future. Without this, the shared registry retains the run forever +/// and every later connection gets "session already has active run". +/// +/// The explicit clear still runs on normal paths; this drop is then a no-op +/// because the entry (matched by run id) is already gone. +struct ActiveRunDropGuard { + registry: ActiveRunRegistry, + session_id: String, + run_id: String, + cancel_token: CancellationToken, +} + +impl Drop for ActiveRunDropGuard { + fn drop(&mut self) { + self.cancel_token.cancel(); + let registry = self.registry.clone(); + let session_id = std::mem::take(&mut self.session_id); + let run_id = std::mem::take(&mut self.run_id); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let agent = { + let mut runs = registry.lock().await; + match runs.get(&session_id) { + Some(run) if run.run_id == run_id => { + runs.remove(&session_id).map(|run| run.agent) + } + _ => None, + } + }; + if let Some(agent) = agent { + agent.discard_pending_steers(&session_id).await; + } + }); + } + } } #[derive(Clone, Debug, Default)] @@ -270,6 +322,13 @@ pub struct GooseAcpAgentOptions { pub goose_platform: GoosePlatform, pub additional_source_roots: Vec, pub scheduler: Option>, + /// When set, new sessions use this host-controlled working directory instead + /// of the `cwd` the connecting client sends (see `AcpServerFactoryConfig`). + pub session_cwd: Option, + /// Active-run registry shared across all agents from one `AcpServer`, so the + /// active-run guard holds across roaming connections that each get a fresh + /// agent for the same session. + pub active_prompt_runs: ActiveRunRegistry, } pub struct GooseAcpAgent { @@ -296,6 +355,7 @@ pub struct GooseAcpAgent { disable_session_naming: bool, provider_inventory: ProviderInventoryService, additional_source_roots: Vec, + session_cwd: Option, recipe_path_cache: Arc>>, } @@ -723,6 +783,13 @@ pub(super) fn build_usage_updates( }) } +/// Resolve the cwd an existing session should be activated with: a +/// host-imposed cwd (roaming) wins, otherwise the client-requested cwd is +/// honored as-is, preserving standard ACP semantics. +pub(super) fn effective_session_cwd(host_cwd: Option<&Path>, requested: &Path) -> PathBuf { + host_cwd.unwrap_or(requested).to_path_buf() +} + pub(super) fn validate_absolute_cwd(cwd: &Path) -> Result<(), agent_client_protocol::Error> { if !cwd.is_absolute() { return Err( @@ -738,6 +805,41 @@ pub(super) fn validate_absolute_cwd(cwd: &Path) -> Result<(), agent_client_proto } impl GooseAcpAgent { + #[cfg(test)] + pub(crate) fn active_run_registry(&self) -> &ActiveRunRegistry { + &self.active_prompt_runs + } + + #[cfg(test)] + pub(crate) async fn test_start_active_run( + &self, + session_id: &str, + run_id: String, + agent: Arc, + ) -> Result<(), agent_client_protocol::Error> { + self.start_active_run(session_id, run_id, CancellationToken::new(), agent) + .await + } + + #[cfg(test)] + pub(crate) fn test_drop_active_run_guard(&self, session_id: &str, run_id: &str) { + drop(ActiveRunDropGuard { + registry: self.active_prompt_runs.clone(), + session_id: session_id.to_string(), + run_id: run_id.to_string(), + cancel_token: CancellationToken::new(), + }); + } + + #[cfg(test)] + pub(crate) async fn test_require_active_run( + &self, + session_id: &str, + expected_run_id: &str, + ) -> Result<(String, Arc), agent_client_protocol::Error> { + self.require_active_run(session_id, expected_run_id).await + } + pub fn permission_manager(&self) -> Arc { Arc::clone(&self.permission_manager) } @@ -830,7 +932,7 @@ impl GooseAcpAgent { Ok(Self { sessions: Arc::new(Mutex::new(HashMap::new())), - active_prompt_runs: Arc::new(Mutex::new(HashMap::new())), + active_prompt_runs: options.active_prompt_runs, closed_session_ids: Arc::new(Mutex::new(HashSet::new())), agent_manager, provider_factory: options.provider_factory, @@ -852,6 +954,7 @@ impl GooseAcpAgent { disable_session_naming: options.disable_session_naming, provider_inventory, additional_source_roots: options.additional_source_roots, + session_cwd: options.session_cwd, recipe_path_cache: Arc::new(Mutex::new(HashMap::new())), }) } @@ -876,39 +979,46 @@ impl GooseAcpAgent { .await } - async fn maybe_refresh_provider_inventory_with_agent( - &self, - goose_session: &Session, - agent: &Arc, - ) { - let Some(provider_name) = goose_session.provider_name.as_deref() else { + /// Warm the provider model-list cache after session creation. + /// + /// This is a best-effort cache refresh, never a prerequisite for using the + /// session, so it runs as a detached background task. Keeping it off the + /// `session/new` critical path avoids stalling session creation on slow or + /// blocking work such as a synchronous keychain read while resolving the + /// provider's inventory identity. + fn spawn_provider_inventory_refresh(&self, goose_session: &Session, agent: &Arc) { + let Some(provider_name) = goose_session.provider_name.clone() else { return; }; - let Some(mut inventory) = self - .provider_inventory - .find_entry_for_provider(provider_name) - .await - else { - return; - }; - if !should_refresh_inventory_for_session_init(&inventory) { - return; - } - let provider = match agent.provider().await { - Ok(provider) => provider, - Err(error) => { - warn!( - provider = %provider_name, - session = %goose_session.id, - error = %error, - "agent has no provider available for inventory refresh" - ); + let inventory_service = self.provider_inventory.clone(); + let agent = agent.clone(); + let session_id = goose_session.id.clone(); + tokio::spawn(async move { + let Some(mut inventory) = inventory_service + .find_entry_for_provider(&provider_name) + .await + else { + return; + }; + if !should_refresh_inventory_for_session_init(&inventory) { return; } - }; - self.provider_inventory - .refresh_with_provider(provider_name, &provider, &mut inventory, "session init") - .await; + let provider = match agent.provider().await { + Ok(provider) => provider, + Err(error) => { + warn!( + provider = %provider_name, + session = %session_id, + error = %error, + "agent has no provider available for inventory refresh" + ); + return; + } + }; + inventory_service + .refresh_with_provider(&provider_name, &provider, &mut inventory, "session init") + .await; + }); } async fn get_or_create_session_agent_with_results( @@ -1003,8 +1113,7 @@ impl GooseAcpAgent { let agent = agent_result.agent.clone(); self.apply_acp_extension_overrides(cx, &agent, session) .await; - self.maybe_refresh_provider_inventory_with_agent(session, &agent) - .await; + self.spawn_provider_inventory_refresh(session, &agent); Ok((agent, agent_result.extension_results)) } @@ -1746,6 +1855,7 @@ impl GooseAcpAgent { session_id: &str, run_id: String, cancel_token: CancellationToken, + agent: Arc, ) -> Result<(), agent_client_protocol::Error> { if self.closed_session_ids.lock().await.contains(session_id) { return Err(agent_client_protocol::Error::resource_not_found(Some( @@ -1767,13 +1877,14 @@ impl GooseAcpAgent { ActivePromptRun { run_id, cancel_token, + agent, }, ); Ok(()) } async fn clear_active_run(&self, session_id: &str, run_id: &str) { - { + let agent = { let mut active_prompt_runs = self.active_prompt_runs.lock().await; let Some(active_run) = active_prompt_runs.get(session_id) else { return; @@ -1783,15 +1894,13 @@ impl GooseAcpAgent { return; } - active_prompt_runs.remove(session_id); - } - - let agent = { - let sessions = self.sessions.lock().await; - sessions - .get(session_id) - .map(|session| session.agent.clone()) + active_prompt_runs + .remove(session_id) + .map(|active_run| active_run.agent) }; + + // Discard steers on the agent that owned the run; under roaming it may + // not be this connection's agent. if let Some(agent) = agent { agent.discard_pending_steers(session_id).await; } @@ -1816,7 +1925,7 @@ impl GooseAcpAgent { &self, session_id: &str, expected_run_id: &str, - ) -> Result { + ) -> Result<(String, Arc), agent_client_protocol::Error> { if expected_run_id.is_empty() { return Err(agent_client_protocol::Error::invalid_params() .data("expectedRunId must not be empty")); @@ -1838,7 +1947,7 @@ impl GooseAcpAgent { })), ); } - Ok(active_run.run_id.clone()) + Ok((active_run.run_id.clone(), active_run.agent.clone())) } fn active_run_meta(active_run_id: Option<&str>) -> Meta { @@ -1947,15 +2056,27 @@ impl GooseAcpAgent { let run_id = format!("run_{}", Uuid::new_v4()); let cancel_token = CancellationToken::new(); - self.start_active_run(&session_id, run_id.clone(), cancel_token.clone()) - .await?; - let agent = match self.get_session_agent(&session_id).await { - Ok(agent) => agent, - Err(error) => { - self.clear_active_run(&session_id, &run_id).await; - return Err(error); - } + // Resolve the agent before claiming the run so the registry can record + // which agent owns it; registration stays atomic, so the cross-connection + // guard still admits only one run per session. + let agent = self.get_session_agent(&session_id).await?; + self.start_active_run( + &session_id, + run_id.clone(), + cancel_token.clone(), + agent.clone(), + ) + .await?; + + // Frees the run if this future is dropped mid-prompt (e.g. the roaming + // connection carrying it is revoked or lost); a normal completion's + // explicit clear wins and makes the guard's cleanup a no-op. + let _run_guard = ActiveRunDropGuard { + registry: self.active_prompt_runs.clone(), + session_id: session_id.clone(), + run_id: run_id.clone(), + cancel_token: cancel_token.clone(), }; if cancel_token.is_cancelled() { @@ -2156,10 +2277,10 @@ impl GooseAcpAgent { ); } - self.require_active_run(&req.session_id, &req.expected_run_id) - .await?; - let agent = self.get_session_agent(&req.session_id).await?; - let active_run_id = self + // Route to the agent that owns the run, not this connection's agent: + // under roaming the steering client may be a different connection than + // the one running the prompt. + let (active_run_id, agent) = self .require_active_run(&req.session_id, &req.expected_run_id) .await?; @@ -2509,6 +2630,7 @@ pub async fn run(builtins: Vec, enable_scheduler: bool) -> Result<()> { config_dir: Paths::config_dir(), goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + session_cwd: None, enable_scheduler, }, ); @@ -2591,6 +2713,35 @@ mod tests { } } + #[test] + fn effective_session_cwd_prefers_host_cwd_over_client_path() { + let cwd = effective_session_cwd( + Some(Path::new("/host/share")), + Path::new("/client/only/path"), + ); + + assert_eq!(cwd, PathBuf::from("/host/share")); + } + + #[test] + fn effective_session_cwd_uses_client_path_without_host_override() { + let cwd = effective_session_cwd(None, Path::new("/client/path")); + + assert_eq!(cwd, PathBuf::from("/client/path")); + } + + #[test] + fn effective_session_cwd_is_validated_instead_of_client_path() { + let host = tempfile::tempdir().unwrap(); + let client_path = Path::new("/does/not/exist/on/host"); + + assert!(validate_absolute_cwd(client_path).is_err()); + + let cwd = effective_session_cwd(Some(host.path()), client_path); + + assert!(validate_absolute_cwd(&cwd).is_ok()); + } + #[test] fn agent_creation_auth_error_maps_to_auth_required() { let error = anyhow::Error::new(agent_client_protocol::Error::auth_required()); @@ -3368,6 +3519,8 @@ print(\"hello, world\") goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), scheduler: None, + session_cwd: None, + active_prompt_runs: Default::default(), }) .await .unwrap(), diff --git a/crates/goose/src/acp/server/fork_session.rs b/crates/goose/src/acp/server/fork_session.rs index b8dfcca67..c77f0a888 100644 --- a/crates/goose/src/acp/server/fork_session.rs +++ b/crates/goose/src/acp/server/fork_session.rs @@ -7,7 +7,6 @@ impl GooseAcpAgent { cx: &ConnectionTo, args: ForkSessionRequest, ) -> Result { - validate_absolute_cwd(&args.cwd)?; let conversation_before = conversation_before_from_meta(args.meta.as_ref())?; let source_session_id = &*args.session_id.0; @@ -16,6 +15,12 @@ impl GooseAcpAgent { .get_session(source_session_id, false) .await .internal_err()?; + + // Resolve and validate the effective cwd before copying anything, so a + // bad request cannot leave a stray "(copy)" session in the store. + let cwd = effective_session_cwd(self.session_cwd.as_deref(), &args.cwd); + validate_absolute_cwd(&cwd)?; + let fork_name = if source.name.trim().is_empty() { "(copy)".to_string() } else { @@ -43,12 +48,7 @@ impl GooseAcpAgent { .internal_err()?; let goose_session = self - .prepare_session_for_activation( - new_session.clone(), - args.cwd.clone(), - args.mcp_servers, - true, - ) + .prepare_session_for_activation(new_session.clone(), cwd, args.mcp_servers, true) .await?; let (agent, extension_results) = self.prepare_acp_session_agent(cx, &goose_session).await?; diff --git a/crates/goose/src/acp/server/load_session.rs b/crates/goose/src/acp/server/load_session.rs index 4c4aa8dbe..a11747f28 100644 --- a/crates/goose/src/acp/server/load_session.rs +++ b/crates/goose/src/acp/server/load_session.rs @@ -94,12 +94,35 @@ fn build_replayed_tool_call( tool_call } +/// Where to start replaying so that at most roughly `tail` trailing messages +/// are sent without splitting a turn: walk backwards from `len - tail` to the +/// nearest turn boundary (a visible user message that is not a tool response), +/// so tool request/response pairs are never separated. Returns 0 (full +/// replay) when the history is short enough or no boundary exists. +fn replay_start_index(messages: &[Message], tail: usize) -> usize { + if tail == 0 || messages.len() <= tail { + return 0; + } + let candidate = messages.len() - tail; + messages[..=candidate] + .iter() + .rposition(|message| message.role == Role::User && !message.is_tool_response()) + .unwrap_or(0) +} + +fn replay_tail_from_meta(meta: Option<&Meta>) -> Option { + meta.and_then(|m| m.get("replayTail")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) +} + fn replay_conversation_to_client( cx: &ConnectionTo, session: &Session, supports_goose_custom_notifications: bool, client_requests_tool_call_label_enrichment: bool, -) -> Result<(), agent_client_protocol::Error> { + replay_tail: Option, +) -> Result { let session_id = SessionId::new(session.id.clone()); let tool_call_notifier = ToolCallNotifier::new(cx, &session_id); @@ -108,10 +131,14 @@ fn replay_conversation_to_client( .as_ref() .map(messages_for_acp_replay) .unwrap_or_default(); + let skipped = replay_tail + .map(|tail| replay_start_index(&messages, tail)) + .unwrap_or(0); + let messages = &messages[skipped..]; let mut replay_tool_requests = HashMap::new(); - for message in &messages { + for message in messages { for content_item in &message.content { match content_item { MessageContent::Text(text) => { @@ -198,7 +225,7 @@ fn replay_conversation_to_client( } } - Ok(()) + Ok(skipped) } impl GooseAcpAgent { @@ -271,7 +298,6 @@ impl GooseAcpAgent { args: LoadSessionRequest, ) -> Result { debug!(?args, "load session request"); - validate_absolute_cwd(&args.cwd)?; let session_id_str = args.session_id.0.to_string(); @@ -284,15 +310,19 @@ impl GooseAcpAgent { .data(format!("Session not found: {}", session_id_str)) })?; + let cwd = effective_session_cwd(self.session_cwd.as_deref(), &args.cwd); + validate_absolute_cwd(&cwd)?; + session = self - .prepare_session_for_activation(session, args.cwd.clone(), args.mcp_servers, true) + .prepare_session_for_activation(session, cwd, args.mcp_servers, true) .await?; - replay_conversation_to_client( + let replayed_from = replay_conversation_to_client( cx, &session, self.supports_goose_custom_notifications(), self.requests_tool_call_label_enrichment(), + replay_tail_from_meta(args.meta.as_ref()), )?; let (agent, extension_results) = self.prepare_acp_session_agent(cx, &session).await?; self.apply_session_recipe(&agent, &session).await?; @@ -330,7 +360,14 @@ impl GooseAcpAgent { response = response.config_options(co); } - response = response.meta(session_response_meta(&session, &extension_results)); + let mut meta = session_response_meta(&session, &extension_results); + if replayed_from > 0 { + meta.insert( + "replaySkipped".to_string(), + serde_json::Value::Number(replayed_from.into()), + ); + } + response = response.meta(meta); self.closed_session_ids.lock().await.remove(&session_id_str); Ok(response) @@ -414,6 +451,68 @@ mod tests { assert_eq!(capability.current.as_deref(), Some("high")); } + #[test] + fn replay_start_index_short_history_replays_everything() { + let messages = vec![ + Message::user().with_text("q1"), + Message::assistant().with_text("a1"), + ]; + assert_eq!(replay_start_index(&messages, 10), 0); + assert_eq!(replay_start_index(&messages, 2), 0); + } + + #[test] + fn replay_start_index_zero_tail_replays_everything() { + let messages = vec![ + Message::user().with_text("q1"), + Message::assistant().with_text("a1"), + ]; + assert_eq!(replay_start_index(&messages, 0), 0); + } + + #[test] + fn replay_start_index_starts_at_turn_boundary() { + let messages = vec![ + Message::user().with_text("q1"), + Message::assistant().with_text("a1"), + Message::user().with_text("q2"), + Message::assistant().with_text("a2"), + Message::user().with_text("q3"), + Message::assistant().with_text("a3"), + ]; + // tail=3 → candidate index 3 (a2); nearest user boundary at or before is q2 (index 2) + assert_eq!(replay_start_index(&messages, 3), 2); + // tail=1 → candidate index 5 (a3); boundary is q3 (index 4) + assert_eq!(replay_start_index(&messages, 1), 4); + } + + #[test] + fn replay_start_index_never_splits_tool_call_pairs() { + let tool_request = Message::assistant() + .with_tool_request("tool_1", Ok(CallToolRequestParams::new("developer__shell"))); + let tool_response = Message::user() + .with_tool_response("tool_1", Ok(rmcp::model::CallToolResult::success(vec![]))); + let messages = vec![ + Message::user().with_text("q1"), + tool_request, + tool_response, + Message::assistant().with_text("a1"), + ]; + // tail=2 → candidate is the tool response; it is not a turn boundary, + // so we walk back to q1 (index 0) rather than splitting the pair. + assert_eq!(replay_start_index(&messages, 2), 0); + } + + #[test] + fn replay_start_index_no_boundary_replays_everything() { + let messages = vec![ + Message::assistant().with_text("a1"), + Message::assistant().with_text("a2"), + Message::assistant().with_text("a3"), + ]; + assert_eq!(replay_start_index(&messages, 1), 0); + } + #[test] fn acp_replay_populates_only_empty_marked_assistant_messages() { let visible_message = Message::assistant() diff --git a/crates/goose/src/acp/server/new_session.rs b/crates/goose/src/acp/server/new_session.rs index 6fb81eb96..c56e97364 100644 --- a/crates/goose/src/acp/server/new_session.rs +++ b/crates/goose/src/acp/server/new_session.rs @@ -36,8 +36,14 @@ impl GooseAcpAgent { pub(super) async fn handle_new_session( &self, cx: &ConnectionTo, - args: NewSessionRequest, + mut args: NewSessionRequest, ) -> Result { + // When the host imposes a working directory (e.g. roaming, where the + // connector's absolute path is meaningless on this machine), ignore the + // cwd the client sent and use the host-controlled one instead. + if let Some(host_cwd) = &self.session_cwd { + args.cwd = host_cwd.clone(); + } validate_absolute_cwd(&args.cwd)?; let config = Config::global(); let session_type = session_type_from_meta(args.meta.as_ref())?; diff --git a/crates/goose/src/acp/server/schedule.rs b/crates/goose/src/acp/server/schedule.rs index 676773c36..c73e70ec6 100644 --- a/crates/goose/src/acp/server/schedule.rs +++ b/crates/goose/src/acp/server/schedule.rs @@ -375,6 +375,7 @@ mod tests { config_dir: root.path().join("config"), goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + session_cwd: None, enable_scheduler: false, }); let agent = server.create_agent().await.unwrap(); diff --git a/crates/goose/src/acp/server_factory.rs b/crates/goose/src/acp/server_factory.rs index 08e288ab1..073948bea 100644 --- a/crates/goose/src/acp/server_factory.rs +++ b/crates/goose/src/acp/server_factory.rs @@ -1,5 +1,5 @@ use crate::acp::server::{ - AcpBuiltinSelection, AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions, + AcpBuiltinSelection, AcpProviderFactory, ActiveRunRegistry, GooseAcpAgent, GooseAcpAgentOptions, }; use crate::agents::GoosePlatform; use crate::scheduler_trait::SchedulerTrait; @@ -16,12 +16,17 @@ pub struct AcpServerFactoryConfig { pub config_dir: std::path::PathBuf, pub goose_platform: GoosePlatform, pub additional_source_roots: Vec, + /// When set, new sessions use this host-controlled working directory + /// instead of the `cwd` the connecting client sends. Used by roaming, where + /// the connector's absolute path is meaningless on the host machine. + pub session_cwd: Option, pub enable_scheduler: bool, } pub struct AcpServer { config: AcpServerFactoryConfig, scheduler: OnceCell>, + active_prompt_runs: ActiveRunRegistry, } impl AcpServer { @@ -29,6 +34,7 @@ impl AcpServer { Self { config, scheduler: OnceCell::new(), + active_prompt_runs: ActiveRunRegistry::default(), } } @@ -61,6 +67,21 @@ impl AcpServer { } pub async fn create_agent(&self) -> Result> { + self.create_agent_with_session_cwd(self.config.session_cwd.clone()) + .await + } + + /// Create an agent whose sessions use `session_cwd` instead of this + /// server's configured default. Used by the roaming bridge on `goose + /// serve --roam`: the serve-wide server keeps `session_cwd: None` for + /// local ACP clients whose paths are real on this machine, while each + /// roaming connection gets a host-controlled working directory (the + /// connector's absolute path is meaningless here). The agent still shares + /// this server's active-run registry. + pub async fn create_agent_with_session_cwd( + &self, + session_cwd: Option, + ) -> Result> { let config = crate::config::Config::global(); let disable_session_naming = config.get_goose_disable_session_naming().unwrap_or(false); let scheduler = self.scheduler().await?; @@ -100,7 +121,9 @@ impl AcpServer { disable_session_naming, goose_platform: self.config.goose_platform.clone(), additional_source_roots: self.config.additional_source_roots.clone(), + session_cwd, scheduler, + active_prompt_runs: self.active_prompt_runs.clone(), }) .await?; info!("Created new ACP agent"); @@ -120,6 +143,7 @@ mod tests { data_dir, goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + session_cwd: None, enable_scheduler, }) } @@ -141,6 +165,74 @@ mod tests { assert!(server.scheduler().await.unwrap().is_some()); } + #[tokio::test] + async fn agents_from_one_server_share_the_active_run_registry() { + let root = tempfile::tempdir().unwrap(); + let server = server(root.path().to_path_buf(), false); + + let a = server.create_agent().await.unwrap(); + let b = server.create_agent().await.unwrap(); + + assert!( + Arc::ptr_eq(a.active_run_registry(), b.active_run_registry()), + "each connection's agent must share one per-session run registry so \ + the active-run guard holds across roaming connections" + ); + } + + #[tokio::test] + async fn steer_routes_to_the_agent_that_owns_the_run() { + let root = tempfile::tempdir().unwrap(); + let server = server(root.path().to_path_buf(), false); + + let running = server.create_agent().await.unwrap(); + let steering = server.create_agent().await.unwrap(); + + let owner = Arc::new(crate::agents::Agent::new()); + running + .test_start_active_run("session-1", "run-1".to_string(), owner.clone()) + .await + .unwrap(); + + let (run_id, resolved) = steering + .test_require_active_run("session-1", "run-1") + .await + .unwrap(); + + assert_eq!(run_id, "run-1"); + assert!( + Arc::ptr_eq(&resolved, &owner), + "a steer arriving on a second roaming connection must resolve the \ + agent running the prompt, not the caller's connection-local agent" + ); + } + + #[tokio::test] + async fn dropping_a_prompt_future_releases_the_shared_run() { + let root = tempfile::tempdir().unwrap(); + let server = server(root.path().to_path_buf(), false); + + let running = server.create_agent().await.unwrap(); + let owner = Arc::new(crate::agents::Agent::new()); + running + .test_start_active_run("session-1", "run-1".to_string(), owner) + .await + .unwrap(); + + running.test_drop_active_run_guard("session-1", "run-1"); + tokio::task::yield_now().await; + + let second = server.create_agent().await.unwrap(); + assert!( + second + .test_require_active_run("session-1", "run-1") + .await + .is_err(), + "a dropped prompt future must release its run so later \ + connections are not permanently locked out of the session" + ); + } + #[tokio::test] async fn start_scheduler_initializes_before_any_client_connects() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index 8e48d19b4..6d710c777 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -49,6 +49,18 @@ pub enum ConfigError { LockError(String), #[error("Secret stored using file-based fallback")] FallbackToFileStorage, + #[error("Timed out reading the system keyring")] + KeyringTimeout, +} + +/// Outcome of a bounded keyring read. +/// +/// A timeout is kept separate from a keyring error so callers never confuse +/// "the read did not finish" with "there is no entry". +#[cfg(feature = "system-keyring")] +enum KeyringReadError { + Keyring(keyring::Error), + TimedOut, } impl From for ConfigError { @@ -912,8 +924,23 @@ impl Config { match &self.secrets { #[cfg(feature = "system-keyring")] SecretStorage::Keyring { service } => { - let result = - self.handle_keyring_operation(|entry| entry.get_password(), service, None); + let result = match Self::read_keyring_password_with_timeout(service) { + Ok(content) => Ok(content), + // A timed-out read says nothing about whether secrets + // exist. Surface it instead of falling back, so the + // empty file store is never cached as authoritative and + // a later mutation cannot overwrite the real keyring. + Err(KeyringReadError::TimedOut) => { + tracing::warn!( + "keyring read timed out after 3s; not falling back to file \ + storage (set GOOSE_DISABLE_KEYRING=1 to skip the keyring)" + ); + Err(ConfigError::KeyringTimeout) + } + Err(KeyringReadError::Keyring(keyring_err)) => { + self.handle_keyring_fallback_error(&keyring_err, None) + } + }; match result { Ok(content) => Ok(serde_json::from_str(&content)?), @@ -1128,6 +1155,64 @@ impl Config { Entry::new(service, KEYRING_USERNAME) } + /// Read the keyring password on a dedicated thread with a timeout. + /// + /// A synchronous keychain read can block indefinitely — e.g. an unsigned + /// binary triggers a macOS keychain ACL prompt that can't be answered when + /// running headless or over piped stdio (as with `goose acp`). Because this + /// read sits on the `session/new` critical path, a block there hangs the + /// whole async runtime. Bounding it keeps the runtime responsive. + /// + /// A timeout is reported distinctly from a keyring error. It must never be + /// mistaken for "this user has no secrets": the entry may hold every + /// configured credential and simply be waiting on an ACL prompt, so + /// treating it as absent would cache an empty secret map and let the next + /// mutation overwrite the real keyring contents. + #[cfg(feature = "system-keyring")] + fn read_keyring_password_with_timeout(service: &str) -> Result { + use std::sync::mpsc; + use std::time::Duration; + + // One long-lived worker performs every keyring read through a + // single-slot queue. If a read blocks indefinitely (e.g. a pending + // keychain ACL prompt on a headless host), at most one thread is ever + // stuck and at most one request is ever queued behind it — later + // lookups fail fast with a timeout instead of growing an unbounded + // queue. Replies to abandoned requests land in dropped receivers and + // are discarded. + type ReadRequest = (String, mpsc::Sender>); + static WORKER: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + + let worker = WORKER.get_or_init(|| { + let (tx, rx) = mpsc::sync_channel::(1); + std::thread::Builder::new() + .name("goose-keyring-read".to_string()) + .spawn(move || { + while let Ok((service, reply)) = rx.recv() { + let result = Self::get_keyring_entry(&service) + .and_then(|entry| entry.get_password()); + let _ = reply.send(result); + } + }) + .expect("failed to spawn keyring reader thread"); + std::sync::Mutex::new(tx) + }); + + let (tx, rx) = mpsc::channel(); + worker + .lock() + .unwrap() + .try_send((service.to_string(), tx)) + .map_err(|_| KeyringReadError::TimedOut)?; + + match rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(password)) => Ok(password), + Ok(Err(err)) => Err(KeyringReadError::Keyring(err)), + Err(_) => Err(KeyringReadError::TimedOut), + } + } + /// Handle keyring errors with automatic fallback to file storage #[cfg(feature = "system-keyring")] fn handle_keyring_fallback_error( diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index c80270741..3593e5589 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -1108,6 +1108,11 @@ impl SessionStorage { .execute(&mut *tx) .await?; + // Create the inventory tables inside the same transaction so that a + // second SessionStorage opening the same DB file never observes a + // committed schema_version (and thus takes the migration path) while + // the inventory tables don't yet exist — which raced as + // `no such table: provider_inventory_entries`. crate::providers::inventory::create_tables(&mut tx).await?; tx.commit().await?; diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index 2f42b7147..28a4f1b2e 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -400,7 +400,9 @@ pub async fn spawn_acp_server_in_process( disable_session_naming, goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + session_cwd: None, scheduler: Some(Arc::new(FixtureScheduler::new())), + active_prompt_runs: Default::default(), }) .await .unwrap(); diff --git a/crates/goose/tests/acp_transport_auth_test.rs b/crates/goose/tests/acp_transport_auth_test.rs index 822800199..71020f05f 100644 --- a/crates/goose/tests/acp_transport_auth_test.rs +++ b/crates/goose/tests/acp_transport_auth_test.rs @@ -23,6 +23,7 @@ fn test_acp_router(dir: &tempfile::TempDir) -> Router { config_dir: dir.path().join("config"), goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + session_cwd: None, enable_scheduler: false, })); create_acp_router(server) @@ -35,6 +36,7 @@ fn test_authenticated_acp_router(dir: &tempfile::TempDir) -> Router { config_dir: dir.path().join("config"), goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + session_cwd: None, enable_scheduler: false, })); create_router(server, SECRET.to_string(), true, Vec::new()) @@ -51,6 +53,7 @@ fn test_router_with_origins( config_dir: dir.path().join("config"), goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + session_cwd: None, enable_scheduler: false, })); create_router( diff --git a/documentation/docs/guides/roaming-agents.md b/documentation/docs/guides/roaming-agents.md new file mode 100644 index 000000000..414ff6ac2 --- /dev/null +++ b/documentation/docs/guides/roaming-agents.md @@ -0,0 +1,405 @@ +--- +sidebar_position: 95 +title: Roaming Agents +sidebar_label: Roaming Agents +--- + +Roaming agents let you reach a running goose agent from another machine over a +peer-to-peer connection — no open ports, no VPN, no server to host. It's built +on [iroh](https://iroh.computer) (QUIC), so two machines can connect directly or +via a relay, typically without any firewall changes. + +:::warning Opt-in build required +Roaming is an optional, experimental feature that is **not included in +released goose binaries**. Every command in this guide requires a goose built +from source with the `roaming` feature enabled: + +```bash +cargo build --release -p goose-cli --features roaming +``` + +On a default build, `goose roam` reports an unrecognized subcommand. +::: + +Roaming is designed to be **embedded**: the transport is a standalone Rust crate +(`goose-roaming`) with no dependency on goose's agent internals, the CLI exposes +it as `goose roam` commands, and there are wasm bindings for browser apps. If +you build on goose — or just want an authenticated p2p ACP transport — you can +use the same pieces directly. The web client (covered near the end) is a +**reference client** built entirely on this public surface. + +Use it to drive your laptop's agent from another device, hand a one-shot task to +a remote agent, expose a remote agent to any local ACP client (like an editor), +or wire p2p agent access into your own application. + +## The core idea: roaming is an ACP transport + +Roaming does exactly one thing: it provides an **authenticated, peer-to-peer +[ACP](/docs/guides/acp-clients) transport**. The host runs goose's real ACP +server; the connecting side is an ACP client. That's it. + +Everything that feels "session-shaped" is therefore just plain ACP that happens +to run over a roaming connection — not a bespoke roaming feature: + +| You want to… | It's just ACP… | Command | +|--------------|----------------|---------| +| List the remote's sessions | `session/list` | `roam delegate --list-sessions` | +| Continue a specific session | `session/load` | `roam delegate --session "…"` | +| Run a fresh one-shot task | `session/new` + `session/prompt` | `roam delegate "…"` | +| Drive a remote agent from a real UI | full ACP surface | `roam bridge` → Zed or another ACP editor | +| Quick interactive peek | a built-in REPL | `roam connect` | + +Because the connection carries the full ACP surface, the connecting side can +enumerate, create, and resume the host's sessions with no roaming-specific +protocol. Higher-level behaviours (saved peers) sit *above* the transport and +are described below. + +:::note +Roaming is an optional, experimental feature. It's available when goose is built +with the `roaming` feature (`cargo build -p goose-cli --features roaming`). +::: + +## How it works: cards and mutual acceptance + +Trust is a **mutual, public-key relationship** — like WireGuard or SSH +known-hosts, and deliberately infrastructural. Each node has one long-lived +identity and produces a **connection card**: a shareable string containing its +public key and how to reach it (relay URLs). *Nothing in a card is secret* — +possessing one grants no access. + +To let a peer reach you, you each: + +1. **Swap cards** (`goose roam id` prints yours; send it over any channel). +2. **Accept the other's key** (`goose roam peers accept …`). + +Since a card is just a string, it can travel however is convenient — including +as a QR code: `goose roam id --qr` and `goose roam share --qr` also render the +card as a QR code in the terminal, which you can scan from a phone camera (or +directly from the web client's camera, see below) instead of copy-pasting. + +A connection only succeeds when the **host has accepted the dialer's key**. The +transport (iroh QUIC-TLS) proves each side holds the private key for the identity +in its card, so no one can impersonate a key, and a leaked card lets no one in. +There is no bearer token that grants access by possession. + +``` +┌────────────┐ swap cards ┌────────────┐ +│ Machine A │ ◀───────────────▶ │ Machine B │ +│ │ each accepts the │ │ +│ roam share│ other's key │ roam connect│ +│ (agent) │ ◀═══ ACP over ══▶ │ /delegate/ │ +└────────────┘ iroh + relay │ bridge │ + └────────────┘ +``` + +Each connecting client gets its **own** agent and drives its **own** sessions +over the full ACP surface. (Simultaneous multi-viewer "co-driving" of one live +session is a possible future feature, not part of this ACP-transport model.) + +## Using the CLI + +### Quick start + +Say machine B wants to drive machine A's agent. Both run `goose roam id` and send +each other the card it prints. Then: + +**On machine A (the host):** add B's card and accept its key. + +```bash +goose roam peers add 'goose+roam://…B…' laptop-b +goose roam peers accept laptop-b # grants control by default +goose roam share # serve to accepted peers +``` + +`share` keeps running and prints A's card too. The agent runs in the directory +`share` was started in (override with `--cwd `); the connecting side's own +directory is always ignored. + +**On machine B (the client):** add A's card and connect. + +```bash +goose roam peers add 'goose+roam://…A…' laptop-a +goose roam connect laptop-a +``` + +You get an interactive prompt that drives the agent on machine A. Type a message +and press enter; `/quit` or Ctrl-D to leave. + +`connect` is a minimal built-in chat loop — handy for a quick sanity check. For +real work, prefer `bridge` (drive the remote agent from a full ACP client) or +`delegate` (scriptable one-shot tasks). + +For the common "pair a new device" case there is also a one-step helper: +`goose roam pair` shows this node's card as a QR code, reads the device's card +from stdin, and saves + accepts it in one go (the equivalent of +`peers add` + `peers accept`). + +:::tip +Compare the short **fingerprint** shown by `roam id` / `peers accept` out of band +(e.g. read it aloud) to be sure you accepted the key you meant to. +::: + +### One-shot delegation + +To send a single task and get the answer back — no interactive session: + +```bash +goose roam delegate 'goose+roam://…' "Summarize the last 5 commits in this repo." +``` + +The remote agent runs the task with its own tools and prints its final response. +`delegate` is a thin ACP client, so it can also work with the remote's existing +sessions — all plain ACP under the hood: + +```bash +# List the remote agent's sessions (session/list) +goose roam delegate 'goose+roam://…' --list-sessions + +# Continue a specific session instead of starting fresh (session/load) +goose roam delegate 'goose+roam://…' --session "Now fix the first failure." +``` + +### Bridging to any ACP client + +`connect` and `delegate` embed goose's own ACP client. `bridge` does the +opposite: it exposes a remote agent as a **local ACP endpoint**, so any ACP +client — [Zed](/docs/guides/acp-clients) or another editor — can drive it as if +it were running locally. It runs no UI and no agent +of its own; it transparently proxies ACP bytes between the local client and the +remote agent. + +Bridge over stdio (the default — for a client that launches goose as a +subprocess): + +```bash +goose roam bridge 'goose+roam://…' +``` + +Configure your ACP client to run `goose roam bridge ''` as its agent +command. It will speak ACP on the process's stdin/stdout, and every request is +forwarded to the remote agent. + +Or bridge over a local TCP port, for a client that connects to an address: + +```bash +goose roam bridge laptop --listen 127.0.0.1:8900 +``` + +This accepts a single ACP connection on that address and proxies it to the +remote agent. Saved peer names work here too. + +Because a default `share` serves the full ACP surface, a bridged client gets +everything — it can list, create, and load the host's sessions, not just a +single pre-selected one. + +:::note +A bridge serves one client connection. The remote host still runs the agent, +imposes its own working directory, and authorizes the connection. +::: + +## Embedding roaming in your own app + +Everything above is built on the **`goose-roaming` crate** +(`crates/goose-roaming`), and you can use it directly. The crate deliberately +has **zero dependency on goose core** — it knows nothing about agents or +sessions, only about identity, trust, and authenticated byte streams — so you +can embed it in any Rust application, with or without goose. + +The surface a consumer touches: + +- **`RoamingIdentity`** — a persisted ed25519 node key whose public half *is* + the iroh endpoint id (`RoamingIdentity::generate()` for ephemeral, + `default_key_path` for the on-disk one goose uses). +- **`RoamingConfig`** — a builder for a node: `RoamingConfig::new(identity)` + plus chainers like `.with_relay(RelaySettings::…)` and + `.with_bind_addr(addr)`. Defaults to iroh's public relays and an **empty + allowlist** (accepts no one), so the safe default is built in. +- **`RoamingNode`** — the node itself. `RoamingNode::bind(config)` binds the + endpoint; `node.share(server)` hosts an agent to accepted peers; + `node.connect(&card, label)` / `node.connect_with_addr(addr, label)` dial a + remote and return a `RoamingClientStream` (use `.into_futures_io()` to get + plain async read/write halves); `node.card()` produces the shareable card. +- **`AcpStreamServer`** — the trait your host side implements to plug in "the + agent". It has two methods — `serve_stream` (drive your protocol over an + authorized stream for an accepted peer) and `agent_id` (a display id sent in + the handshake ack) — and that's the entire integration seam. goose-cli's + `FullAcpBridge` implements it by handing the stream to goose's real ACP + `serve`; your app can serve anything. +- **`TrustBook`** — the mutual allowlist of accepted peer keys, with durable + persistence and fail-closed reload. `node.trust()` gives you a handle to + accept or revoke keys at runtime. +- **`ConnectionCard`** — the non-secret identity + reachability string + (`goose+roam://…`), with `encode()` / parsing and a short `fingerprint()` + for out-of-band verification. + +A minimal end-to-end example (condensed from +`crates/goose-roaming/examples/echo_roundtrip.rs`, which runs both ends in one +process — `cargo run -p goose-roaming --example echo_roundtrip`): + +```rust +use std::sync::Arc; +use goose_roaming::{ + AcpStreamServer, EndpointId, RoamingConfig, RoamingIdentity, RoamingNode, +}; + +// Your "agent": anything that can serve an authorized byte stream. +struct EchoServer; +impl AcpStreamServer for EchoServer { + fn serve_stream( + &self, + _client: EndpointId, + recv: Box, + send: Box, + ) -> futures::future::BoxFuture<'static, anyhow::Result<()>> { + Box::pin(async move { /* echo recv back on send … */ Ok(()) }) + } + fn agent_id(&self) -> String { "echo-agent".to_string() } +} + +async fn demo() -> anyhow::Result<()> { + // Host: bind a node and share the agent to accepted peers. + let host = RoamingNode::bind(RoamingConfig::new(RoamingIdentity::generate())).await?; + host.share(Arc::new(EchoServer)).await?; + println!("share this card: {}", host.card().encode()?); + + // Client: a separate node dials the host's card. + let client = RoamingNode::bind(RoamingConfig::new(RoamingIdentity::generate())).await?; + + // Trust step: the HOST must accept the client's key, or the dial is refused. + host.trust().lock().await.accept(&client.endpoint_id()); + + let stream = client.connect(&host.card(), Some("example".into())).await?; + let (send, recv, _conn) = stream.into_futures_io(); + // … speak your protocol (ACP, or anything) over send/recv … + Ok(()) +} +``` + +A few notes for integrators: + +- **To expose a full goose backend**, you don't have to implement + `AcpStreamServer` yourself: `goose serve --roam` runs goose's regular agent + server *and* exposes it over roam in one process. It works headless, writes + its card to `/roam/serve.json`, and prints it on startup. +- **For browser apps**, the same transport compiles to WebAssembly. The wasm + bindings (`@aaif/goose-roam-web`, built from the `goose-roaming-web` crate in + the [goose-mobile repo](https://github.com/aaif-goose/goose-mobile/tree/main/mobile-web)) + expose a `RoamClient` to JavaScript — generate an identity, print your card, + dial a host's card, and drive ACP from inside a browser tab, with no server in + between. The web client below is built on these bindings. +- The crate's [README](https://github.com/aaif-goose/goose/tree/main/crates/goose-roaming) + covers the design decisions (why the host controls the working directory, + why trust is all-or-nothing, etc.) in more depth. + +## The web client: a reference browser client + +The hosted web client at +[aaif-goose.github.io/goose-mobile](https://aaif-goose.github.io/goose-mobile/) +is a **reference client built on the pieces above**: the `@aaif/goose-roam-web` +wasm bindings for transport, and goose's `ui/sdk` `GooseClient` for the ACP +protocol layer. The browser tab is itself a roam peer: iroh compiled to +WebAssembly runs inside the tab and connects through the same relays with the +same mutual key trust — there is no server in between, and no traffic goes +through the site's origin. Anything it does, your own app can do with the same +bindings. + +Pairing works exactly like any other peer. The tab generates its own identity +and shows its card; you accept it once on the host: + +```bash +goose roam peers accept 'goose+roam://…tab…' phone +``` + +To get the host's card into the browser, paste it — or run +`goose roam share --qr` and scan the QR code with the web client's camera. + +Once connected, the tab can list and open the host's sessions, start new ones, +stream responses, steer a running turn, and group sessions by project. You can +connect several hosts at once; their sessions appear in one merged list. + +The source lives in the [goose-mobile repo](https://github.com/aaif-goose/goose-mobile/tree/main/mobile-web) +(`mobile-web/`) — the README there has build details if you want to host it +yourself (it builds to a static site). + +## Saved peers + +Save a peer's card under a nickname so you don't paste cards each time. A saved +card is just an address-book entry — it does **not** let that peer connect to +you (use `peers accept` for that): + +```bash +goose roam peers add 'goose+roam://…' laptop # save to the address book +goose roam connect laptop +goose roam delegate laptop "run the tests and report failures" + +goose roam peers list # show saved peers + which keys you accept +goose roam connections # show observed connections +goose roam id # print this node's connection card +``` + +## Controlling who can connect + +Access is granted **only** by accepting a peer's public key — there is no bearer +token that works by possession. You accept a peer by saved name or inline card: + +```bash +goose roam peers accept laptop # accept a saved peer +goose roam peers accept 'goose+roam://…' # accept an inline card (also saves it) +goose roam peers accept 'goose+roam://…' laptop # accept + save under a nickname in one go + +goose roam peers list # see who is accepted +goose roam peers revoke laptop # stop accepting (name, card, or raw id) +``` + +An accepted peer gets goose's **full ACP surface** — it can drive its own +sessions on this machine (new/list/load/prompt), which is effectively remote +shell access. There are no finer-grained roles: acceptance is all-or-nothing. + +Acceptance is **durable** and **live**: it is stored on disk, and a running +`share` re-reads it on each connection *and* polls the trust file (about every +two seconds) to enforce it against connections that are already open. Revoking +a peer therefore takes effect within seconds even against a live peer — the +share force-closes any of its open connections. No restart on either side. + +Because trust is keyed on the peer's public key and the transport authenticates +that key cryptographically, a card can be shared over any channel — it is not a +secret, and a leaked card lets no one in. + +:::warning +Accepting a peer grants **full control** — the peer can run the agent's tools, +including its shell. Only accept machines and people you trust, and verify the +fingerprint out of band. +::: + +## Letting the agent reach other agents + +With the roaming feature enabled, goose can delegate to other agents itself. Ask +it to, and it can run `goose roam delegate ""` via its shell — for +example, "delegate this to my work laptop and summarize what it finds." It sends +one self-contained task and relays the response. + +Because saved peers are just an address book, the agent can discover what +remotes it has available (`goose roam peers list`) and route work to the right +one — e.g. run a build on the machine that has the toolchain, then bring the +result back. Each delegation is a self-contained task with a bounded response, +so this composes into multi-machine workflows without any shared state. + +## Notes and limits + +- Peers connect directly when NAT hole-punching succeeds and fall back to a + relay otherwise. By default roaming uses a set of goose-managed iroh relays + (one per region — not iroh's shared public relays); override them with the + `GOOSE_ROAM_RELAYS` config key or environment variable to point at your own + deployment. +- `connect`, `delegate`, and `bridge` all accept either a saved peer name or a + raw `goose+roam://…` card. Remember the peer must also have accepted your key. +- A message sent to a session that has a run in flight **in the share process** + becomes a steer of that run. A loop running in a *different* process on the + host (another CLI, or a host that does not have roam enabled) can't be steered + remotely — the web client detects this and warns before sending. +- Revoking a peer force-closes its connections within seconds and drops any + in-flight turn at its next step; no new work can start. One narrow residual: + an OS process a tool had already spawned (say, a long shell command) may run + to completion — revocation stops the agent, not processes it already forked. +- On macOS, if a session still appears to hang on connect, set + `GOOSE_DISABLE_KEYRING=1` to skip the keychain entirely.