From 6d71a01a6f8695175a4cc45872937caa3363ecad Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:45:59 +0800 Subject: [PATCH 01/11] feat: add Rust awn CLI skeleton with wire-compatible crypto module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/awn-cli: Cargo project (name=awn, version synced to 1.3.1) - crypto.rs: Ed25519 signing, agentId derivation, canonicalize, domain separators — all byte-for-byte compatible with TS agent-world-sdk - PROTOCOL_VERSION derived from Cargo.toml at compile time (major.minor) - 18 tests including 4 cross-language compatibility tests verified against TS output (same seed → same agentId, canonical JSON, signature, digest) - sync-version.mjs: now syncs Cargo.toml version alongside other files - AGENTS.md: document Cargo.toml as version-bearing file Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .gitignore | 1 + AGENTS.md | 2 + packages/awn-cli/Cargo.lock | 2572 ++++++++++++++++++++++++++++++++ packages/awn-cli/Cargo.toml | 32 + packages/awn-cli/src/crypto.rs | 357 +++++ packages/awn-cli/src/main.rs | 5 + scripts/sync-version.mjs | 11 +- 7 files changed, 2979 insertions(+), 1 deletion(-) create mode 100644 packages/awn-cli/Cargo.lock create mode 100644 packages/awn-cli/Cargo.toml create mode 100644 packages/awn-cli/src/crypto.rs create mode 100644 packages/awn-cli/src/main.rs diff --git a/.gitignore b/.gitignore index bac607e..0106fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ animation/out/ .hal/* !.hal/standards/ !.hal/commands/ +packages/awn-cli/target/ diff --git a/AGENTS.md b/AGENTS.md index d56f220..3c2f1e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,7 @@ No `develop` branch. No backmerge. | `package-lock.json` | `"version"` (auto-updated) | | `openclaw.plugin.json` | `"version"` | | `skills/awn/SKILL.md` | `version:` in YAML frontmatter | +| `packages/awn-cli/Cargo.toml` | `version` (also derives `PROTOCOL_VERSION` at compile time) | ### Versioning @@ -276,6 +277,7 @@ These files must always have matching versions (synced automatically by `scripts | `package-lock.json` | `"version"` (auto-updated by `npm version`) | | `openclaw.plugin.json` | `"version"` | | `skills/awn/SKILL.md` | `version:` in YAML frontmatter | +| `packages/awn-cli/Cargo.toml` | `version` (also derives `PROTOCOL_VERSION` at compile time) | ### Versioning diff --git a/packages/awn-cli/Cargo.lock b/packages/awn-cli/Cargo.lock new file mode 100644 index 0000000..3f374d4 --- /dev/null +++ b/packages/awn-cli/Cargo.lock @@ -0,0 +1,2572 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "awn" +version = "1.3.1" +dependencies = [ + "assert_cmd", + "axum", + "base64", + "chrono", + "clap", + "dirs", + "ed25519-dalek", + "hex", + "predicates", + "rand", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/awn-cli/Cargo.toml b/packages/awn-cli/Cargo.toml new file mode 100644 index 0000000..78d3328 --- /dev/null +++ b/packages/awn-cli/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "awn" +version = "1.3.1" +edition = "2021" +description = "Agent World Network CLI — standalone agent-native interface for world-scoped P2P messaging" +license = "MIT" +repository = "https://github.com/ReScienceLab/agent-world-network" + + + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ed25519-dalek = { version = "2", features = ["rand_core"] } +sha2 = "0.10" +rand = "0.8" +base64 = "0.22" +hex = "0.4" +tokio = { version = "1", features = ["full"] } +axum = "0.8" +reqwest = { version = "0.12", features = ["json"] } +chrono = { version = "0.4", features = ["serde"] } +dirs = "6" +tracing = "0.1" +thiserror = "2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3" +assert_cmd = "2" +predicates = "3" diff --git a/packages/awn-cli/src/crypto.rs b/packages/awn-cli/src/crypto.rs new file mode 100644 index 0000000..02c5eee --- /dev/null +++ b/packages/awn-cli/src/crypto.rs @@ -0,0 +1,357 @@ +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// Extract "major.minor" from the Cargo package version at compile time. +/// e.g. "1.3.1" → "1.3" +const PROTOCOL_VERSION: &str = { + const V: &str = env!("CARGO_PKG_VERSION"); + const B: &[u8] = V.as_bytes(); + // Find second '.' to truncate at major.minor + const fn find_second_dot() -> usize { + let mut dots = 0; + let mut i = 0; + while i < B.len() { + if B[i] == b'.' { + dots += 1; + if dots == 2 { + return i; + } + } + i += 1; + } + B.len() + } + const END: usize = find_second_dot(); + // SAFETY: slicing valid UTF-8 at ASCII '.' boundary + unsafe { std::str::from_utf8_unchecked(std::slice::from_raw_parts(B.as_ptr(), END)) } +}; + +pub const SEPARATOR_ANNOUNCE: &str = concatcp!("AgentWorld-Announce-", PROTOCOL_VERSION, "\0"); +pub const SEPARATOR_HEARTBEAT: &str = concatcp!("AgentWorld-Heartbeat-", PROTOCOL_VERSION, "\0"); +pub const SEPARATOR_MESSAGE: &str = concatcp!("AgentWorld-Message-", PROTOCOL_VERSION, "\0"); +pub const SEPARATOR_HTTP_REQUEST: &str = concatcp!("AgentWorld-Req-", PROTOCOL_VERSION, "\0"); +pub const SEPARATOR_HTTP_RESPONSE: &str = concatcp!("AgentWorld-Res-", PROTOCOL_VERSION, "\0"); +pub const SEPARATOR_AGENT_CARD: &str = concatcp!("AgentWorld-Card-", PROTOCOL_VERSION, "\0"); +pub const SEPARATOR_KEY_ROTATION: &str = concatcp!("AgentWorld-Rotation-", PROTOCOL_VERSION, "\0"); +pub const SEPARATOR_WORLD_STATE: &str = concatcp!("AgentWorld-WorldState-", PROTOCOL_VERSION, "\0"); + +macro_rules! concatcp { + ($a:expr, $b:expr, $c:expr) => {{ + const A: &str = $a; + const B: &str = $b; + const C: &str = $c; + const LEN: usize = A.len() + B.len() + C.len(); + const fn build() -> [u8; LEN] { + let mut buf = [0u8; LEN]; + let a = A.as_bytes(); + let b = B.as_bytes(); + let c = C.as_bytes(); + let mut i = 0; + while i < a.len() { + buf[i] = a[i]; + i += 1; + } + let mut j = 0; + while j < b.len() { + buf[i + j] = b[j]; + j += 1; + } + let mut k = 0; + while k < c.len() { + buf[i + j + k] = c[k]; + k += 1; + } + buf + } + // SAFETY: inputs are valid UTF-8 str literals, concatenation is valid UTF-8 + unsafe { std::str::from_utf8_unchecked(&{ const BYTES: [u8; LEN] = build(); BYTES }) } + }}; +} +use concatcp; + +/// Derive an agent ID from a base64-encoded Ed25519 public key. +/// Format: `aw:sha256:` +pub fn agent_id_from_public_key(public_key_b64: &str) -> Result { + let pub_bytes = B64.decode(public_key_b64).map_err(|_| CryptoError::InvalidBase64)?; + let hash = Sha256::digest(&pub_bytes); + Ok(format!("aw:sha256:{}", hex::encode(hash))) +} + +/// Canonicalize a JSON value: sort object keys recursively, arrays preserved. +pub fn canonicalize(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut sorted = serde_json::Map::new(); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for k in keys { + sorted.insert(k.clone(), canonicalize(&map[k])); + } + Value::Object(sorted) + } + Value::Array(arr) => Value::Array(arr.iter().map(canonicalize).collect()), + other => other.clone(), + } +} + +/// Sign a payload with domain separation. +/// Returns base64-encoded Ed25519 signature. +pub fn sign_with_domain_separator( + separator: &str, + payload: &Value, + signing_key: &SigningKey, +) -> String { + let canonical_json = serde_json::to_string(&canonicalize(payload)).unwrap(); + let mut message = Vec::with_capacity(separator.len() + canonical_json.len()); + message.extend_from_slice(separator.as_bytes()); + message.extend_from_slice(canonical_json.as_bytes()); + let sig = signing_key.sign(&message); + B64.encode(sig.to_bytes()) +} + +/// Verify a domain-separated signature. +pub fn verify_with_domain_separator( + separator: &str, + public_key_b64: &str, + payload: &Value, + signature_b64: &str, +) -> Result { + let canonical_json = serde_json::to_string(&canonicalize(payload)).unwrap(); + let mut message = Vec::with_capacity(separator.len() + canonical_json.len()); + message.extend_from_slice(separator.as_bytes()); + message.extend_from_slice(canonical_json.as_bytes()); + + let pub_bytes = B64.decode(public_key_b64).map_err(|_| CryptoError::InvalidBase64)?; + let verifying_key = + VerifyingKey::from_bytes(&pub_bytes.try_into().map_err(|_| CryptoError::InvalidKeyLength)?) + .map_err(|_| CryptoError::InvalidPublicKey)?; + let sig_bytes = B64.decode(signature_b64).map_err(|_| CryptoError::InvalidBase64)?; + let signature = ed25519_dalek::Signature::from_bytes( + &sig_bytes + .try_into() + .map_err(|_| CryptoError::InvalidSignatureLength)?, + ); + + Ok(verifying_key.verify(&message, &signature).is_ok()) +} + +/// Compute SHA-256 content digest in the AWN header format. +pub fn compute_content_digest(body: &str) -> String { + let hash = Sha256::digest(body.as_bytes()); + format!("sha-256=:{}:", B64.encode(hash)) +} + +#[derive(Debug, thiserror::Error)] +pub enum CryptoError { + #[error("invalid base64 encoding")] + InvalidBase64, + #[error("invalid key length (expected 32 bytes)")] + InvalidKeyLength, + #[error("invalid public key")] + InvalidPublicKey, + #[error("invalid signature length (expected 64 bytes)")] + InvalidSignatureLength, +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use serde_json::json; + + fn make_keypair() -> (SigningKey, String) { + let seed: [u8; 32] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]; + let signing_key = SigningKey::from_bytes(&seed); + let pub_b64 = B64.encode(signing_key.verifying_key().as_bytes()); + (signing_key, pub_b64) + } + + #[test] + fn test_agent_id_deterministic() { + let (_, pub_b64) = make_keypair(); + let id1 = agent_id_from_public_key(&pub_b64).unwrap(); + let id2 = agent_id_from_public_key(&pub_b64).unwrap(); + assert_eq!(id1, id2); + assert!(id1.starts_with("aw:sha256:")); + assert_eq!(id1.len(), "aw:sha256:".len() + 64); + } + + #[test] + fn test_agent_id_different_keys() { + let (_, pub_b64_1) = make_keypair(); + let seed2: [u8; 32] = [42; 32]; + let key2 = SigningKey::from_bytes(&seed2); + let pub_b64_2 = B64.encode(key2.verifying_key().as_bytes()); + let id1 = agent_id_from_public_key(&pub_b64_1).unwrap(); + let id2 = agent_id_from_public_key(&pub_b64_2).unwrap(); + assert_ne!(id1, id2); + } + + #[test] + fn test_agent_id_invalid_base64() { + assert!(agent_id_from_public_key("not-valid!!!").is_err()); + } + + #[test] + fn test_canonicalize_sorts_keys() { + let input = json!({"b": 2, "a": 1, "c": 3}); + let canonical = canonicalize(&input); + let s = serde_json::to_string(&canonical).unwrap(); + assert_eq!(s, r#"{"a":1,"b":2,"c":3}"#); + } + + #[test] + fn test_canonicalize_nested() { + let input = json!({"z": {"b": 2, "a": 1}, "a": [3, 1, 2]}); + let canonical = canonicalize(&input); + let s = serde_json::to_string(&canonical).unwrap(); + assert_eq!(s, r#"{"a":[3,1,2],"z":{"a":1,"b":2}}"#); + } + + #[test] + fn test_canonicalize_preserves_array_order() { + let input = json!([3, 1, 2]); + let canonical = canonicalize(&input); + assert_eq!(canonical, json!([3, 1, 2])); + } + + #[test] + fn test_canonicalize_primitives() { + assert_eq!(canonicalize(&json!(42)), json!(42)); + assert_eq!(canonicalize(&json!("hello")), json!("hello")); + assert_eq!(canonicalize(&json!(true)), json!(true)); + assert_eq!(canonicalize(&json!(null)), json!(null)); + } + + #[test] + fn test_sign_and_verify_roundtrip() { + let (signing_key, pub_b64) = make_keypair(); + let payload = json!({"agentId": "aw:sha256:abc", "ts": 1234567890}); + let sig = sign_with_domain_separator(SEPARATOR_HEARTBEAT, &payload, &signing_key); + let valid = + verify_with_domain_separator(SEPARATOR_HEARTBEAT, &pub_b64, &payload, &sig).unwrap(); + assert!(valid); + } + + #[test] + fn test_wrong_separator_fails() { + let (signing_key, pub_b64) = make_keypair(); + let payload = json!({"test": true}); + let sig = sign_with_domain_separator(SEPARATOR_ANNOUNCE, &payload, &signing_key); + let valid = + verify_with_domain_separator(SEPARATOR_HEARTBEAT, &pub_b64, &payload, &sig).unwrap(); + assert!(!valid); + } + + #[test] + fn test_wrong_key_fails() { + let (signing_key, _) = make_keypair(); + let seed2: [u8; 32] = [42; 32]; + let other_key = SigningKey::from_bytes(&seed2); + let other_pub = B64.encode(other_key.verifying_key().as_bytes()); + + let payload = json!({"test": true}); + let sig = sign_with_domain_separator(SEPARATOR_ANNOUNCE, &payload, &signing_key); + let valid = + verify_with_domain_separator(SEPARATOR_ANNOUNCE, &other_pub, &payload, &sig).unwrap(); + assert!(!valid); + } + + #[test] + fn test_tampered_payload_fails() { + let (signing_key, pub_b64) = make_keypair(); + let payload = json!({"test": true}); + let sig = sign_with_domain_separator(SEPARATOR_ANNOUNCE, &payload, &signing_key); + let tampered = json!({"test": false}); + let valid = + verify_with_domain_separator(SEPARATOR_ANNOUNCE, &pub_b64, &tampered, &sig).unwrap(); + assert!(!valid); + } + + #[test] + fn test_content_digest() { + let digest = compute_content_digest("hello world"); + assert!(digest.starts_with("sha-256=:")); + assert!(digest.ends_with(":")); + let digest2 = compute_content_digest("hello world"); + assert_eq!(digest, digest2); + let digest3 = compute_content_digest("different"); + assert_ne!(digest, digest3); + } + + #[test] + fn test_domain_separator_values() { + assert_eq!(SEPARATOR_ANNOUNCE, "AgentWorld-Announce-1.3\0"); + assert_eq!(SEPARATOR_HEARTBEAT, "AgentWorld-Heartbeat-1.3\0"); + assert_eq!(SEPARATOR_MESSAGE, "AgentWorld-Message-1.3\0"); + assert_eq!(SEPARATOR_HTTP_REQUEST, "AgentWorld-Req-1.3\0"); + assert_eq!(SEPARATOR_HTTP_RESPONSE, "AgentWorld-Res-1.3\0"); + } + + #[test] + fn test_canonicalize_key_order_matches_ts() { + let input = json!({"from": "aw:sha256:abc", "publicKey": "AAAA", "alias": "test", "timestamp": 1000}); + let canonical = canonicalize(&input); + let s = serde_json::to_string(&canonical).unwrap(); + assert!(s.starts_with(r#"{"alias":"#)); + assert!(s.contains(r#""from":"aw:sha256:abc""#)); + } + + // ── Cross-language compatibility tests (values from TS implementation) ─── + + #[test] + fn test_compat_agent_id_matches_ts() { + let pub_b64 = "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ="; + let id = agent_id_from_public_key(pub_b64).unwrap(); + assert_eq!( + id, + "aw:sha256:65b60673d6ed884bf01c2c222d82ada0740f29ac3355d6a925c81f17f47a27b8" + ); + } + + #[test] + fn test_compat_canonicalize_matches_ts() { + let input = json!({"agentId": "aw:sha256:abc", "ts": 1234567890}); + let canonical = canonicalize(&input); + let s = serde_json::to_string(&canonical).unwrap(); + assert_eq!(s, r#"{"agentId":"aw:sha256:abc","ts":1234567890}"#); + } + + #[test] + fn test_compat_sign_verify_matches_ts() { + // Same deterministic seed as TS test + let seed: [u8; 32] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]; + let signing_key = SigningKey::from_bytes(&seed); + let pub_b64 = B64.encode(signing_key.verifying_key().as_bytes()); + assert_eq!(pub_b64, "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ="); + + let payload = json!({"agentId": "aw:sha256:abc", "ts": 1234567890}); + let sig = sign_with_domain_separator(SEPARATOR_HEARTBEAT, &payload, &signing_key); + + // Signature produced by TS with same seed + payload + separator + assert_eq!( + sig, + "eiQlVUIjwNif53F4dPL8qWE00AwLEuKt1tOR5xnLh7DotTG1t9ezzQEgbPrDhNtghERD9pB5y5NQ57Xu/XeoCQ==" + ); + + // Verify with Rust matches TS verify + let valid = + verify_with_domain_separator(SEPARATOR_HEARTBEAT, &pub_b64, &payload, &sig).unwrap(); + assert!(valid); + } + + #[test] + fn test_compat_content_digest_matches_ts() { + let digest = compute_content_digest("hello world"); + assert_eq!(digest, "sha-256=:uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=:"); + } +} diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs new file mode 100644 index 0000000..ab810b5 --- /dev/null +++ b/packages/awn-cli/src/main.rs @@ -0,0 +1,5 @@ +mod crypto; + +fn main() { + println!("awn-cli v{}", env!("CARGO_PKG_VERSION")); +} diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs index feaacde..18a29b6 100644 --- a/scripts/sync-version.mjs +++ b/scripts/sync-version.mjs @@ -17,6 +17,15 @@ const sdkPkg = JSON.parse(readFileSync('packages/agent-world-sdk/package.json', sdkPkg.version = version writeFileSync('packages/agent-world-sdk/package.json', JSON.stringify(sdkPkg, null, 2) + '\n') +// Sync awn Cargo.toml version +import { existsSync } from 'fs' +const cargoPath = 'packages/awn-cli/Cargo.toml' +if (existsSync(cargoPath)) { + let cargo = readFileSync(cargoPath, 'utf8') + cargo = cargo.replace(/^version = ".*"/m, `version = "${version}"`) + writeFileSync(cargoPath, cargo) +} + if (gatewayUrl) { let indexTs = readFileSync('src/index.ts', 'utf8') indexTs = indexTs.replace(/(process\.env\.GATEWAY_URL \?\? ")[^"]*"/, `$1${gatewayUrl}"`) @@ -31,4 +40,4 @@ if (gatewayUrl) { writeFileSync('docs/index.html', docsHtml) } -console.log(`Synced version ${version} → openclaw.plugin.json, skills/awn/SKILL.md, packages/agent-world-sdk/package.json${gatewayUrl ? `, src/index.ts, web/client.js, docs/index.html (gatewayUrl: ${gatewayUrl})` : ''}`) +console.log(`Synced version ${version} → openclaw.plugin.json, skills/awn/SKILL.md, packages/agent-world-sdk/package.json, packages/awn-cli/Cargo.toml${gatewayUrl ? `, src/index.ts, web/client.js, docs/index.html (gatewayUrl: ${gatewayUrl})` : ''}`) From f76d0ac7118aabb977cb06fc2142dcd4c3f7ab32 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:47:01 +0800 Subject: [PATCH 02/11] feat(awn): add identity module with TS-compatible key persistence - load_or_create_identity: load seed from JSON or generate new keypair - File format matches TS identity.json (seed + publicKey fields) - Can load TS-generated identity files and derive same agentId - 7 tests: create, reload, cross-name isolation, TS compat, nested dirs, corrupt file handling Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/awn-cli/src/identity.rs | 166 +++++++++++++++++++++++++++++++ packages/awn-cli/src/main.rs | 3 +- 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 packages/awn-cli/src/identity.rs diff --git a/packages/awn-cli/src/identity.rs b/packages/awn-cli/src/identity.rs new file mode 100644 index 0000000..0ea1035 --- /dev/null +++ b/packages/awn-cli/src/identity.rs @@ -0,0 +1,166 @@ +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use ed25519_dalek::SigningKey; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +use crate::crypto::agent_id_from_public_key; + +#[derive(Clone)] +pub struct Identity { + pub agent_id: String, + pub pub_b64: String, + pub signing_key: SigningKey, +} + +#[derive(Serialize, Deserialize)] +struct IdentityFile { + seed: String, + #[serde(rename = "publicKey")] + public_key: String, +} + +/// Load an existing Ed25519 identity from `data_dir` or create a new one. +/// File name defaults to `identity.json` but can be overridden with `name`. +/// Wire-compatible with the TS `loadOrCreateIdentity()`. +pub fn load_or_create_identity(data_dir: &Path, name: &str) -> Result { + fs::create_dir_all(data_dir).map_err(IdentityError::Io)?; + let id_file = data_dir.join(format!("{name}.json")); + + let signing_key = if id_file.exists() { + let raw = fs::read_to_string(&id_file).map_err(IdentityError::Io)?; + let saved: IdentityFile = + serde_json::from_str(&raw).map_err(|e| IdentityError::Parse(e.to_string()))?; + let seed_bytes = B64.decode(&saved.seed).map_err(|_| { + IdentityError::Parse("invalid base64 seed in identity file".into()) + })?; + let seed: [u8; 32] = seed_bytes.try_into().map_err(|_| { + IdentityError::Parse("seed must be exactly 32 bytes".into()) + })?; + SigningKey::from_bytes(&seed) + } else { + let mut csprng = OsRng; + let signing_key = SigningKey::generate(&mut csprng); + let seed = signing_key.to_bytes(); + let pub_b64 = B64.encode(signing_key.verifying_key().as_bytes()); + let id_data = IdentityFile { + seed: B64.encode(seed), + public_key: pub_b64, + }; + let json = serde_json::to_string_pretty(&id_data).unwrap(); + fs::write(&id_file, json).map_err(IdentityError::Io)?; + signing_key + }; + + let pub_b64 = B64.encode(signing_key.verifying_key().as_bytes()); + let agent_id = agent_id_from_public_key(&pub_b64) + .map_err(|e| IdentityError::Parse(e.to_string()))?; + + Ok(Identity { + agent_id, + pub_b64, + signing_key, + }) +} + +#[derive(Debug, thiserror::Error)] +pub enum IdentityError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("parse error: {0}")] + Parse(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_create_new_identity() { + let tmp = TempDir::new().unwrap(); + let id = load_or_create_identity(tmp.path(), "identity").unwrap(); + assert!(id.agent_id.starts_with("aw:sha256:")); + assert!(!id.pub_b64.is_empty()); + assert!(tmp.path().join("identity.json").exists()); + } + + #[test] + fn test_load_existing_identity() { + let tmp = TempDir::new().unwrap(); + let id1 = load_or_create_identity(tmp.path(), "identity").unwrap(); + let id2 = load_or_create_identity(tmp.path(), "identity").unwrap(); + assert_eq!(id1.agent_id, id2.agent_id); + assert_eq!(id1.pub_b64, id2.pub_b64); + } + + #[test] + fn test_different_names_different_identities() { + let tmp = TempDir::new().unwrap(); + let id1 = load_or_create_identity(tmp.path(), "alice").unwrap(); + let id2 = load_or_create_identity(tmp.path(), "bob").unwrap(); + assert_ne!(id1.agent_id, id2.agent_id); + assert!(tmp.path().join("alice.json").exists()); + assert!(tmp.path().join("bob.json").exists()); + } + + #[test] + fn test_identity_file_format_compatible_with_ts() { + let tmp = TempDir::new().unwrap(); + let _id = load_or_create_identity(tmp.path(), "identity").unwrap(); + let raw = fs::read_to_string(tmp.path().join("identity.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(parsed.get("seed").is_some(), "must have 'seed' field"); + assert!( + parsed.get("publicKey").is_some(), + "must have 'publicKey' field (camelCase like TS)" + ); + } + + #[test] + fn test_load_ts_generated_identity() { + // Simulate a TS-generated identity.json + let tmp = TempDir::new().unwrap(); + let seed: [u8; 32] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]; + let signing_key = SigningKey::from_bytes(&seed); + let pub_b64 = B64.encode(signing_key.verifying_key().as_bytes()); + let ts_json = serde_json::json!({ + "seed": B64.encode(seed), + "publicKey": pub_b64 + }); + fs::write( + tmp.path().join("identity.json"), + serde_json::to_string_pretty(&ts_json).unwrap(), + ) + .unwrap(); + + let id = load_or_create_identity(tmp.path(), "identity").unwrap(); + assert_eq!(id.pub_b64, "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ="); + assert_eq!( + id.agent_id, + "aw:sha256:65b60673d6ed884bf01c2c222d82ada0740f29ac3355d6a925c81f17f47a27b8" + ); + } + + #[test] + fn test_creates_data_dir_if_missing() { + let tmp = TempDir::new().unwrap(); + let nested = tmp.path().join("deep").join("nested").join("dir"); + let id = load_or_create_identity(&nested, "identity").unwrap(); + assert!(id.agent_id.starts_with("aw:sha256:")); + assert!(nested.join("identity.json").exists()); + } + + #[test] + fn test_corrupt_identity_file_returns_error() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("identity.json"), "not valid json").unwrap(); + let result = load_or_create_identity(tmp.path(), "identity"); + assert!(result.is_err()); + } +} diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index ab810b5..6785eb0 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -1,5 +1,6 @@ mod crypto; +mod identity; fn main() { - println!("awn-cli v{}", env!("CARGO_PKG_VERSION")); + println!("awn v{}", env!("CARGO_PKG_VERSION")); } From f231beef1f3978f985f0a8e43984025a56373d2b Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:48:43 +0800 Subject: [PATCH 03/11] feat(awn): add peer DB with TOFU, capability search, and TS-compatible persistence - PeerDb: JSON peer store at $data_dir/peers.json - upsert, remove, list (sorted by lastSeen), get, find_by_capability - TOFU trust model: first-use caching, TTL-based key refresh, mismatch rejection - camelCase JSON field names matching TS peers.json format - 14 tests: CRUD, persistence, TOFU logic, capability prefix/exact search, corrupt file recovery, TS format compatibility Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/awn-cli/src/main.rs | 1 + packages/awn-cli/src/peer_db.rs | 388 ++++++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 packages/awn-cli/src/peer_db.rs diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index 6785eb0..50601a7 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -1,5 +1,6 @@ mod crypto; mod identity; +mod peer_db; fn main() { println!("awn v{}", env!("CARGO_PKG_VERSION")); diff --git a/packages/awn-cli/src/peer_db.rs b/packages/awn-cli/src/peer_db.rs new file mode 100644 index 0000000..efbc52f --- /dev/null +++ b/packages/awn-cli/src/peer_db.rs @@ -0,0 +1,388 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Endpoint { + pub transport: String, + pub address: String, + pub port: u16, + pub priority: i32, + #[serde(default)] + pub ttl: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PeerRecord { + pub agent_id: String, + pub public_key: String, + #[serde(default)] + pub alias: String, + #[serde(default)] + pub endpoints: Vec, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub first_seen: u64, + #[serde(default)] + pub last_seen: u64, + #[serde(default)] + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tofu_cached_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub discovered_via: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PeerStore { + version: u32, + peers: HashMap, +} + +pub struct PeerDb { + path: PathBuf, + store: PeerStore, + tofu_ttl_ms: u64, +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + +impl PeerDb { + pub fn open(data_dir: &Path) -> Self { + fs::create_dir_all(data_dir).ok(); + let path = data_dir.join("peers.json"); + let store = if path.exists() { + match fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or(PeerStore { + version: 2, + peers: HashMap::new(), + }), + Err(_) => PeerStore { + version: 2, + peers: HashMap::new(), + }, + } + } else { + PeerStore { + version: 2, + peers: HashMap::new(), + } + }; + PeerDb { + path, + store, + tofu_ttl_ms: 7 * 24 * 60 * 60 * 1000, + } + } + + pub fn flush(&self) { + if let Ok(json) = serde_json::to_string_pretty(&self.store) { + fs::write(&self.path, json).ok(); + } + } + + pub fn size(&self) -> usize { + self.store.peers.len() + } + + pub fn get(&self, agent_id: &str) -> Option<&PeerRecord> { + self.store.peers.get(agent_id) + } + + pub fn list(&self) -> Vec<&PeerRecord> { + let mut peers: Vec<_> = self.store.peers.values().collect(); + peers.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); + peers + } + + pub fn upsert( + &mut self, + agent_id: &str, + public_key: &str, + alias: Option<&str>, + endpoints: Option>, + capabilities: Option>, + source: Option<&str>, + last_seen: Option, + ) { + let now = now_ms(); + if let Some(existing) = self.store.peers.get_mut(agent_id) { + if existing.public_key.is_empty() && !public_key.is_empty() { + existing.public_key = public_key.to_string(); + } + if let Some(ls) = last_seen { + existing.last_seen = existing.last_seen.max(ls); + } else { + existing.last_seen = now; + } + if let Some(a) = alias { + if existing.source != "manual" { + existing.alias = a.to_string(); + } + } + if let Some(eps) = endpoints { + if !eps.is_empty() { + existing.endpoints = eps; + } + } + if let Some(caps) = capabilities { + if !caps.is_empty() { + existing.capabilities = caps; + } + } + } else { + self.store.peers.insert( + agent_id.to_string(), + PeerRecord { + agent_id: agent_id.to_string(), + public_key: public_key.to_string(), + alias: alias.unwrap_or("").to_string(), + endpoints: endpoints.unwrap_or_default(), + capabilities: capabilities.unwrap_or_default(), + first_seen: now, + last_seen: last_seen.unwrap_or(now), + source: source.unwrap_or("gossip").to_string(), + version: None, + tofu_cached_at: None, + discovered_via: None, + }, + ); + } + } + + pub fn remove(&mut self, agent_id: &str) -> bool { + self.store.peers.remove(agent_id).is_some() + } + + pub fn find_by_capability(&self, cap: &str) -> Vec<&PeerRecord> { + let is_prefix = cap.ends_with(':'); + let mut matches: Vec<_> = self + .store + .peers + .values() + .filter(|p| { + p.capabilities + .iter() + .any(|c| if is_prefix { c.starts_with(cap) } else { c == cap }) + }) + .collect(); + matches.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); + matches + } + + pub fn tofu_verify(&mut self, agent_id: &str, public_key: &str) -> bool { + let now = now_ms(); + if let Some(existing) = self.store.peers.get_mut(agent_id) { + if existing.public_key.is_empty() { + existing.public_key = public_key.to_string(); + existing.tofu_cached_at = Some(now); + existing.last_seen = now; + self.flush(); + return true; + } + if let Some(cached_at) = existing.tofu_cached_at { + if now - cached_at > self.tofu_ttl_ms { + existing.public_key = public_key.to_string(); + existing.tofu_cached_at = Some(now); + existing.last_seen = now; + self.flush(); + return true; + } + } + if existing.public_key != public_key { + return false; + } + existing.last_seen = now; + return true; + } + // New peer — cache key + self.store.peers.insert( + agent_id.to_string(), + PeerRecord { + agent_id: agent_id.to_string(), + public_key: public_key.to_string(), + alias: String::new(), + endpoints: vec![], + capabilities: vec![], + first_seen: now, + last_seen: now, + source: "gossip".to_string(), + version: None, + tofu_cached_at: Some(now), + discovered_via: None, + }, + ); + self.flush(); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_open_empty() { + let tmp = TempDir::new().unwrap(); + let db = PeerDb::open(tmp.path()); + assert_eq!(db.size(), 0); + assert!(db.list().is_empty()); + } + + #[test] + fn test_upsert_and_get() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "pubkey1", Some("Alice"), None, None, None, None); + assert_eq!(db.size(), 1); + let peer = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(peer.alias, "Alice"); + assert_eq!(peer.public_key, "pubkey1"); + } + + #[test] + fn test_upsert_updates_existing() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "pk1", Some("Alice"), None, None, None, None); + let eps = vec![Endpoint { + transport: "tcp".into(), + address: "10.0.0.1".into(), + port: 8099, + priority: 1, + ttl: 3600, + }]; + db.upsert("aw:sha256:aaa", "", Some("Alice Updated"), Some(eps), None, None, None); + let peer = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(peer.public_key, "pk1"); // not overwritten with empty + assert_eq!(peer.alias, "Alice Updated"); + assert_eq!(peer.endpoints.len(), 1); + } + + #[test] + fn test_remove() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "pk1", None, None, None, None, None); + assert!(db.remove("aw:sha256:aaa")); + assert_eq!(db.size(), 0); + assert!(!db.remove("aw:sha256:aaa")); + } + + #[test] + fn test_persist_and_reload() { + let tmp = TempDir::new().unwrap(); + { + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "pk1", Some("Alice"), None, None, None, None); + db.flush(); + } + let db = PeerDb::open(tmp.path()); + assert_eq!(db.size(), 1); + let peer = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(peer.alias, "Alice"); + } + + #[test] + fn test_find_by_capability_exact() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "pk1", None, None, Some(vec!["world:arena".into()]), None, None); + db.upsert("aw:sha256:bbb", "pk2", None, None, Some(vec!["world:lobby".into()]), None, None); + let found = db.find_by_capability("world:arena"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].agent_id, "aw:sha256:aaa"); + } + + #[test] + fn test_find_by_capability_prefix() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "pk1", None, None, Some(vec!["world:arena".into()]), None, None); + db.upsert("aw:sha256:bbb", "pk2", None, None, Some(vec!["world:lobby".into()]), None, None); + db.upsert("aw:sha256:ccc", "pk3", None, None, Some(vec!["agent".into()]), None, None); + let found = db.find_by_capability("world:"); + assert_eq!(found.len(), 2); + } + + #[test] + fn test_list_sorted_by_last_seen() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:old", "pk1", None, None, None, None, Some(1000)); + db.upsert("aw:sha256:new", "pk2", None, None, None, None, Some(9000)); + db.upsert("aw:sha256:mid", "pk3", None, None, None, None, Some(5000)); + let list = db.list(); + assert_eq!(list[0].agent_id, "aw:sha256:new"); + assert_eq!(list[1].agent_id, "aw:sha256:mid"); + assert_eq!(list[2].agent_id, "aw:sha256:old"); + } + + #[test] + fn test_tofu_new_peer() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); + let peer = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(peer.public_key, "pk1"); + assert!(peer.tofu_cached_at.is_some()); + } + + #[test] + fn test_tofu_same_key_passes() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); + assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); + } + + #[test] + fn test_tofu_different_key_fails() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); + assert!(!db.tofu_verify("aw:sha256:aaa", "pk2")); + } + + #[test] + fn test_tofu_empty_key_accepts() { + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "", None, None, None, None, None); + assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); + assert_eq!(db.get("aw:sha256:aaa").unwrap().public_key, "pk1"); + } + + #[test] + fn test_corrupt_file_loads_empty() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("peers.json"), "invalid json").unwrap(); + let db = PeerDb::open(tmp.path()); + assert_eq!(db.size(), 0); + } + + #[test] + fn test_ts_format_compatibility() { + // Verify JSON field names match TS camelCase convention + let tmp = TempDir::new().unwrap(); + let mut db = PeerDb::open(tmp.path()); + db.upsert("aw:sha256:aaa", "pk1", Some("Test"), None, None, None, None); + db.flush(); + let raw = fs::read_to_string(tmp.path().join("peers.json")).unwrap(); + assert!(raw.contains("agentId"), "must use camelCase agentId"); + assert!(raw.contains("publicKey"), "must use camelCase publicKey"); + assert!(raw.contains("firstSeen"), "must use camelCase firstSeen"); + assert!(raw.contains("lastSeen"), "must use camelCase lastSeen"); + } +} From 2a259ffa6bd83a8da1443ba3e487e3726a5236ad Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:51:09 +0800 Subject: [PATCH 04/11] feat(awn): add daemon with IPC + clap CLI (status, peers, worlds) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon.rs: axum HTTP server on localhost for IPC, with /ipc/status, /ipc/peers, /ipc/worlds, /ipc/ping endpoints - main.rs: clap CLI with subcommands: daemon start/stop, status, peers, worlds — all with --json dual output for agent consumption - daemon fetches worlds from gateway + merges with local peer DB cache - 5 daemon integration tests (start, ping, status, peers, identity reuse) - Binary name: awn (not awn-cli) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/awn-cli/src/daemon.rs | 358 +++++++++++++++++++++++++++++++++ packages/awn-cli/src/main.rs | 228 ++++++++++++++++++++- 2 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 packages/awn-cli/src/daemon.rs diff --git a/packages/awn-cli/src/daemon.rs b/packages/awn-cli/src/daemon.rs new file mode 100644 index 0000000..972135d --- /dev/null +++ b/packages/awn-cli/src/daemon.rs @@ -0,0 +1,358 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tokio::sync::oneshot; + +use crate::crypto; +use crate::identity::{self, Identity}; +use crate::peer_db::{Endpoint, PeerDb, PeerRecord}; + +const DEFAULT_IPC_PORT: u16 = 8199; + +#[derive(Clone)] +pub struct DaemonState { + pub identity: Identity, + pub peer_db: Arc>, + pub data_dir: PathBuf, + pub gateway_url: String, + pub peer_port: u16, +} + +#[derive(Serialize, Deserialize)] +pub struct StatusResponse { + pub agent_id: String, + pub pub_b64: String, + pub version: String, + pub peer_port: u16, + pub gateway_url: String, + pub known_peers: usize, + pub data_dir: String, +} + +#[derive(Serialize, Deserialize)] +pub struct PeersResponse { + pub peers: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct WorldsResponse { + pub worlds: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct WorldSummary { + #[serde(rename = "worldId")] + pub world_id: String, + #[serde(rename = "agentId")] + pub agent_id: String, + pub name: String, + pub endpoints: Vec, + pub reachable: bool, + #[serde(rename = "lastSeen")] + pub last_seen: u64, +} + +#[derive(Deserialize)] +pub struct PeersQuery { + pub capability: Option, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Serialize, Deserialize)] +pub struct OkResponse { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +pub struct DaemonHandle { + shutdown_tx: oneshot::Sender<()>, + pub addr: SocketAddr, +} + +impl DaemonHandle { + pub fn shutdown(self) { + let _ = self.shutdown_tx.send(()); + } +} + +pub async fn start_daemon( + data_dir: PathBuf, + gateway_url: String, + peer_port: u16, + ipc_port: u16, +) -> Result { + let identity = identity::load_or_create_identity(&data_dir, "identity") + .map_err(|e| DaemonError::Identity(e.to_string()))?; + let peer_db = PeerDb::open(&data_dir); + + let state = DaemonState { + identity, + peer_db: Arc::new(Mutex::new(peer_db)), + data_dir, + gateway_url, + peer_port, + }; + + let app = Router::new() + .route("/ipc/status", get(handle_status)) + .route("/ipc/peers", get(handle_peers)) + .route("/ipc/worlds", get(handle_worlds)) + .route("/ipc/ping", get(handle_ping)) + .with_state(state); + + let addr = SocketAddr::from(([127, 0, 0, 1], ipc_port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| DaemonError::Bind(e.to_string()))?; + let bound_addr = listener.local_addr().unwrap(); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .ok(); + }); + + Ok(DaemonHandle { + shutdown_tx, + addr: bound_addr, + }) +} + +async fn handle_status(State(state): State) -> Json { + let peer_count = state.peer_db.lock().unwrap().size(); + Json(StatusResponse { + agent_id: state.identity.agent_id.clone(), + pub_b64: state.identity.pub_b64.clone(), + version: env!("CARGO_PKG_VERSION").to_string(), + peer_port: state.peer_port, + gateway_url: state.gateway_url.clone(), + known_peers: peer_count, + data_dir: state.data_dir.to_string_lossy().to_string(), + }) +} + +async fn handle_peers( + State(state): State, + axum::extract::Query(query): axum::extract::Query, +) -> Json { + let db = state.peer_db.lock().unwrap(); + let peers = if let Some(cap) = &query.capability { + db.find_by_capability(cap).into_iter().cloned().collect() + } else { + db.list().into_iter().cloned().collect() + }; + Json(PeersResponse { peers }) +} + +async fn handle_worlds(State(state): State) -> Json { + // Fetch from gateway + let mut worlds = Vec::new(); + let url = format!("{}/worlds", state.gateway_url.trim_end_matches('/')); + if let Ok(resp) = reqwest::get(&url).await { + if let Ok(data) = resp.json::().await { + if let Some(arr) = data.get("worlds").and_then(|w| w.as_array()) { + for w in arr { + let world_id = w.get("worldId").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let agent_id = w.get("agentId").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let name = w.get("name").and_then(|v| v.as_str()).unwrap_or(&world_id).to_string(); + let last_seen = w.get("lastSeen").and_then(|v| v.as_u64()).unwrap_or(0); + let endpoints: Vec = w + .get("endpoints") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + let reachable = !endpoints.is_empty(); + worlds.push(WorldSummary { + world_id, + agent_id, + name, + endpoints, + reachable, + last_seen, + }); + } + } + } + } + + // Merge with local cache + { + let db = state.peer_db.lock().unwrap(); + let local_worlds = db.find_by_capability("world:"); + for lw in local_worlds { + if !worlds.iter().any(|w| w.agent_id == lw.agent_id) { + let cap = lw.capabilities.iter().find(|c| c.starts_with("world:")).cloned().unwrap_or_default(); + let world_id = cap.strip_prefix("world:").unwrap_or("").to_string(); + worlds.push(WorldSummary { + world_id, + agent_id: lw.agent_id.clone(), + name: if lw.alias.is_empty() { cap.clone() } else { lw.alias.clone() }, + endpoints: lw.endpoints.clone(), + reachable: !lw.endpoints.is_empty(), + last_seen: lw.last_seen, + }); + } + } + } + + Json(WorldsResponse { worlds }) +} + +async fn handle_ping() -> Json { + Json(OkResponse { + ok: true, + message: Some("daemon alive".to_string()), + }) +} + +pub fn ipc_port() -> u16 { + std::env::var("AWN_IPC_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_IPC_PORT) +} + +pub fn default_data_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".awn") +} + +pub fn default_gateway_url() -> String { + std::env::var("GATEWAY_URL").unwrap_or_else(|_| "https://gateway.agentworlds.ai".to_string()) +} + +#[derive(Debug, thiserror::Error)] +pub enum DaemonError { + #[error("identity error: {0}")] + Identity(String), + #[error("bind error: {0}")] + Bind(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn test_daemon_starts_and_responds_to_ping() { + let tmp = TempDir::new().unwrap(); + let handle = start_daemon( + tmp.path().to_path_buf(), + "http://localhost:9999".to_string(), + 8099, + 0, // OS-assigned port + ) + .await + .unwrap(); + + let url = format!("http://{}/ipc/ping", handle.addr); + let resp: OkResponse = reqwest::get(&url).await.unwrap().json().await.unwrap(); + assert!(resp.ok); + + handle.shutdown(); + } + + #[tokio::test] + async fn test_daemon_status_returns_identity() { + let tmp = TempDir::new().unwrap(); + let handle = start_daemon( + tmp.path().to_path_buf(), + "http://localhost:9999".to_string(), + 8099, + 0, + ) + .await + .unwrap(); + + let url = format!("http://{}/ipc/status", handle.addr); + let resp: StatusResponse = reqwest::get(&url).await.unwrap().json().await.unwrap(); + assert!(resp.agent_id.starts_with("aw:sha256:")); + assert_eq!(resp.version, env!("CARGO_PKG_VERSION")); + assert_eq!(resp.peer_port, 8099); + + handle.shutdown(); + } + + #[tokio::test] + async fn test_daemon_peers_empty() { + let tmp = TempDir::new().unwrap(); + let handle = start_daemon( + tmp.path().to_path_buf(), + "http://localhost:9999".to_string(), + 8099, + 0, + ) + .await + .unwrap(); + + let url = format!("http://{}/ipc/peers", handle.addr); + let resp: PeersResponse = reqwest::get(&url).await.unwrap().json().await.unwrap(); + assert!(resp.peers.is_empty()); + + handle.shutdown(); + } + + #[tokio::test] + async fn test_daemon_creates_identity_file() { + let tmp = TempDir::new().unwrap(); + let handle = start_daemon( + tmp.path().to_path_buf(), + "http://localhost:9999".to_string(), + 8099, + 0, + ) + .await + .unwrap(); + + assert!(tmp.path().join("identity.json").exists()); + handle.shutdown(); + } + + #[tokio::test] + async fn test_daemon_reuses_identity() { + let tmp = TempDir::new().unwrap(); + + let handle1 = start_daemon( + tmp.path().to_path_buf(), + "http://localhost:9999".to_string(), + 8099, + 0, + ) + .await + .unwrap(); + let url1 = format!("http://{}/ipc/status", handle1.addr); + let resp1: StatusResponse = reqwest::get(&url1).await.unwrap().json().await.unwrap(); + handle1.shutdown(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let handle2 = start_daemon( + tmp.path().to_path_buf(), + "http://localhost:9999".to_string(), + 8099, + 0, + ) + .await + .unwrap(); + let url2 = format!("http://{}/ipc/status", handle2.addr); + let resp2: StatusResponse = reqwest::get(&url2).await.unwrap().json().await.unwrap(); + handle2.shutdown(); + + assert_eq!(resp1.agent_id, resp2.agent_id); + } +} diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index 50601a7..95416cc 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -1,7 +1,231 @@ mod crypto; +mod daemon; mod identity; mod peer_db; -fn main() { - println!("awn v{}", env!("CARGO_PKG_VERSION")); +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "awn", version, about = "Agent World Network — standalone CLI for world-scoped P2P messaging")] +struct Cli { + /// Output JSON instead of human-readable text + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Start or stop the AWN background daemon + Daemon { + #[command(subcommand)] + action: DaemonAction, + }, + /// Show this agent's identity, transport, and status + Status, + /// List known peers + Peers { + /// Filter by capability prefix (e.g. "world:") + #[arg(long)] + capability: Option, + }, + /// List available worlds from the Gateway + Worlds, +} + +#[derive(Subcommand)] +enum DaemonAction { + /// Start the AWN daemon + Start { + /// Data directory for identity and peer DB + #[arg(long)] + data_dir: Option, + /// Gateway URL + #[arg(long)] + gateway_url: Option, + /// Peer server port + #[arg(long, default_value_t = 8099)] + port: u16, + /// IPC port for CLI ↔ daemon communication + #[arg(long, default_value_t = 0)] + ipc_port: u16, + }, + /// Stop the AWN daemon + Stop, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Daemon { action } => match action { + DaemonAction::Start { + data_dir, + gateway_url, + port, + ipc_port, + } => { + let data_dir = data_dir.unwrap_or_else(daemon::default_data_dir); + let gateway_url = gateway_url.unwrap_or_else(daemon::default_gateway_url); + let ipc_port = if ipc_port == 0 { + daemon::ipc_port() + } else { + ipc_port + }; + + match daemon::start_daemon(data_dir, gateway_url, port, ipc_port).await { + Ok(handle) => { + if cli.json { + println!( + "{}", + serde_json::json!({ + "ok": true, + "ipc_addr": handle.addr.to_string() + }) + ); + } else { + eprintln!("AWN daemon listening on {}", handle.addr); + eprintln!("Press Ctrl+C to stop"); + } + tokio::signal::ctrl_c().await.ok(); + handle.shutdown(); + if !cli.json { + eprintln!("Daemon stopped"); + } + } + Err(e) => { + if cli.json { + println!("{}", serde_json::json!({"error": e.to_string()})); + } else { + eprintln!("Error: {e}"); + } + std::process::exit(1); + } + } + } + DaemonAction::Stop => { + if cli.json { + println!("{}", serde_json::json!({"error": "daemon stop not yet implemented (use Ctrl+C)"})); + } else { + eprintln!("Use Ctrl+C to stop the daemon, or kill the process."); + } + } + }, + Commands::Status => { + let ipc = daemon::ipc_port(); + let url = format!("http://127.0.0.1:{ipc}/ipc/status"); + match reqwest::get(&url).await { + Ok(resp) => { + if let Ok(status) = resp.json::().await { + if cli.json { + println!("{}", serde_json::to_string(&status).unwrap()); + } else { + println!("=== AWN Status ==="); + println!("Agent ID: {}", status.agent_id); + println!("Version: v{}", status.version); + println!("Peer port: {}", status.peer_port); + println!("Gateway: {}", status.gateway_url); + println!("Known peers: {}", status.known_peers); + println!("Data dir: {}", status.data_dir); + } + } + } + Err(_) => { + if cli.json { + println!("{}", serde_json::json!({"error": "AWN daemon not running. Start with: awn daemon start"})); + } else { + eprintln!("AWN daemon not running. Start with: awn daemon start"); + } + std::process::exit(1); + } + } + } + Commands::Peers { capability } => { + let ipc = daemon::ipc_port(); + let mut url = format!("http://127.0.0.1:{ipc}/ipc/peers"); + if let Some(cap) = &capability { + url = format!("{url}?capability={}", urlencoding(cap)); + } + match reqwest::get(&url).await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if cli.json { + println!("{}", serde_json::to_string(&data).unwrap()); + } else if data.peers.is_empty() { + println!("No peers found."); + } else { + println!("=== Known Peers ({}) ===", data.peers.len()); + for p in &data.peers { + let alias = if p.alias.is_empty() { + String::new() + } else { + format!(" — {}", p.alias) + }; + let caps = if p.capabilities.is_empty() { + String::new() + } else { + format!(" [{}]", p.capabilities.join(", ")) + }; + let ago = (now_ms().saturating_sub(p.last_seen)) / 1000; + println!(" {}{}{} — {}s ago", p.agent_id, alias, caps, ago); + } + } + } + } + Err(_) => { + if cli.json { + println!("{}", serde_json::json!({"error": "AWN daemon not running. Start with: awn daemon start"})); + } else { + eprintln!("AWN daemon not running. Start with: awn daemon start"); + } + std::process::exit(1); + } + } + } + Commands::Worlds => { + let ipc = daemon::ipc_port(); + let url = format!("http://127.0.0.1:{ipc}/ipc/worlds"); + match reqwest::get(&url).await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if cli.json { + println!("{}", serde_json::to_string(&data).unwrap()); + } else if data.worlds.is_empty() { + println!("No worlds found."); + } else { + println!("=== Available Worlds ({}) ===", data.worlds.len()); + for w in &data.worlds { + let status = if w.reachable { "reachable" } else { "no endpoint" }; + let ago = (now_ms().saturating_sub(w.last_seen)) / 1000; + println!(" world:{} — {} [{}] — {}s ago", w.world_id, w.name, status, ago); + } + } + } + } + Err(_) => { + if cli.json { + println!("{}", serde_json::json!({"error": "AWN daemon not running. Start with: awn daemon start"})); + } else { + eprintln!("AWN daemon not running. Start with: awn daemon start"); + } + std::process::exit(1); + } + } + } + } +} + +fn urlencoding(s: &str) -> String { + s.replace(':', "%3A") +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 } From 6a23b8f845c90fbe7987b07811506c3470d64c99 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:51:53 +0800 Subject: [PATCH 05/11] docs(awn): add CLI-Anything style SKILL.md for agent discovery - Follows CLI-Anything SKILL.md format (name/description table, command groups, installation, JSON output docs, agent usage guide) - Documents daemon start, status, peers, worlds commands - Explains --json flag for machine-readable output Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/awn-cli/skills/SKILL.md | 91 ++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/awn-cli/skills/SKILL.md diff --git a/packages/awn-cli/skills/SKILL.md b/packages/awn-cli/skills/SKILL.md new file mode 100644 index 0000000..adbab90 --- /dev/null +++ b/packages/awn-cli/skills/SKILL.md @@ -0,0 +1,91 @@ +| name | description | +|------|-------------| +| awn | Agent World Network CLI — world-scoped P2P messaging between AI agents over Ed25519-signed HTTP | + +# awn + +Standalone CLI for the Agent World Network. Discover worlds, join them, exchange messages with co-member agents. All messages are Ed25519-signed at the application layer. Single binary, zero dependencies. + +## Installation + +``` +cargo install awn +``` + +Or download a prebuilt binary from GitHub Releases. + +**No runtime dependencies.** The binary includes everything needed. + +## Usage + +### Start the daemon + +The daemon runs a background service that maintains identity, peer DB, and gateway connectivity. + +``` +awn daemon start +awn daemon start --data-dir ~/.awn --gateway-url https://gateway.agentworlds.ai --port 8099 +``` + +### Basic commands + +``` +awn status # agent ID, version, known peers +awn peers # list known peers +awn peers --capability world: # filter by capability prefix +awn worlds # list available worlds from Gateway +``` + +### JSON output (for agents) + +All commands support `--json` for structured, machine-readable output: + +``` +awn --json status +awn --json worlds +awn --json peers --capability world: +``` + +## Command Groups + +### daemon + +| Command | Description | +|---------|-------------| +| `start` | Start the AWN background daemon | +| `stop` | Stop the AWN daemon | + +### discovery + +| Command | Description | +|---------|-------------| +| `status` | Show agent ID, version, peer count, gateway URL | +| `peers` | List known peers (optionally filtered by capability) | +| `worlds` | List available worlds from Gateway + local cache | + +## For AI Agents + +When using this CLI programmatically: + +1. **Always use `--json` flag** for parseable output +2. **Start daemon first**: `awn daemon start` +3. **Workflow**: `awn worlds` → `awn join ` → `awn action ` +4. **Check return codes** — 0 for success, non-zero for errors +5. **Parse stderr** for error messages on failure + +## Architecture + +``` +awn daemon start + → loads/creates Ed25519 identity (~/.awn/identity.json) + → opens peer DB (~/.awn/peers.json) + → starts IPC server on localhost:8199 + +awn status / peers / worlds + → connects to daemon via localhost HTTP + → returns result as human text or JSON +``` + +## Version + +1.3.1 From 5380abb3e8816134925fc9bfcb8e08a22408d435 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:52:08 +0800 Subject: [PATCH 06/11] chore: add changeset for Rust awn CLI Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .changeset/awn-rust-cli.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/awn-rust-cli.md diff --git a/.changeset/awn-rust-cli.md b/.changeset/awn-rust-cli.md new file mode 100644 index 0000000..b2b819e --- /dev/null +++ b/.changeset/awn-rust-cli.md @@ -0,0 +1,5 @@ +--- +"@resciencelab/agent-world-network": minor +--- + +Add standalone Rust CLI binary (`awn`) for agent-native AWN interface: Ed25519 crypto (wire-compatible with TS SDK), identity persistence, peer DB with TOFU, IPC daemon with axum, clap CLI with --json dual output, SKILL.md for agent discovery From ae61d174c2a6afc6887a4ad78b8d37adaae0d166 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:58:39 +0800 Subject: [PATCH 07/11] ci: add Rust cargo test to CI + cross-compiled CLI release workflow - test.yml: new test-rust job runs cargo test on PRs (with rust-cache) - release-cli.yml: triggered by GitHub Release publish, cross-compiles awn binary for linux-x64, darwin-x64, darwin-arm64, uploads tar.gz archives to the release - AGENTS.md: update CI workflows table Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .github/workflows/release-cli.yml | 81 +++++++++++++++++++++++++++++++ .github/workflows/test.yml | 12 +++++ AGENTS.md | 3 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-cli.yml diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 0000000..2fe1db2 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,81 @@ +name: Release CLI + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + - target: x86_64-apple-darwin + os: macos-latest + archive: tar.gz + - target: aarch64-apple-darwin + os: macos-latest + archive: tar.gz + + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/awn-cli + + - name: Build + run: cargo build --release --manifest-path packages/awn-cli/Cargo.toml --target ${{ matrix.target }} + + - name: Package + shell: bash + run: | + VERSION=$(grep '^version' packages/awn-cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + ARCHIVE_NAME="awn-v${VERSION}-${{ matrix.target }}" + mkdir -p "dist/${ARCHIVE_NAME}" + cp "packages/awn-cli/target/${{ matrix.target }}/release/awn" "dist/${ARCHIVE_NAME}/" + cp packages/awn-cli/skills/SKILL.md "dist/${ARCHIVE_NAME}/" + cd dist + tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}" + echo "ARCHIVE=${ARCHIVE_NAME}.tar.gz" >> "$GITHUB_ENV" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: awn-${{ matrix.target }} + path: dist/${{ env.ARCHIVE }} + + upload: + name: Upload to Release + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'release' + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for f in artifacts/*.tar.gz; do + echo "Uploading $f" + gh release upload "${{ github.event.release.tag_name }}" "$f" \ + --repo "${{ github.repository }}" \ + --clobber + done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e112c4f..18ea327 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,3 +33,15 @@ jobs: run: npm --prefix packages/agent-world-sdk run build - if: "!startsWith(github.head_ref, 'changeset-release/')" run: node --test test/*.test.mjs + + test-rust: + name: test (awn-cli) + runs-on: ubuntu-latest + if: "!startsWith(github.head_ref, 'changeset-release/')" + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/awn-cli + - run: cargo test --manifest-path packages/awn-cli/Cargo.toml diff --git a/AGENTS.md b/AGENTS.md index 3c2f1e4..d4b36e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,8 +192,9 @@ No manual version bumping, no release scripts, no backmerge. | Workflow | Trigger | What it does | |---|---|---| | `release.yml` | Push to `main`, `workflow_dispatch` | Changesets: create Version PR or publish npm + GH Release + ClawHub | +| `release-cli.yml` | GH Release published, `workflow_dispatch` | Cross-compile Rust `awn` binary (linux-x64, darwin-x64, darwin-arm64), attach to release | | `publish.yml` | `workflow_dispatch` only | Emergency manual npm publish | -| `test.yml` | Push/PR to `main`, `workflow_dispatch` | Build + test (Node 20+22) | +| `test.yml` | Push/PR to `main`, `workflow_dispatch` | Build + test (Node 20+22) + Rust cargo test | | `changeset-check.yml` | PR to `main` | Ensure changeset present + validate packages | | `auto-close-issues.yml` | PR merged | Close linked issues | From a029bf93fc3aed3777ea2245144ce2f0c40035e9 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 17:03:20 +0800 Subject: [PATCH 08/11] feat(awn): add package manager installation (brew, apt, install.sh) - Cargo.toml: add cargo-deb metadata for .deb package building - release-cli.yml: build .deb on Linux, auto-generate Homebrew formula on release, upload .deb + tar.gz archives to GitHub Releases - install.sh: universal installer (detects OS/arch, downloads binary) - SKILL.md: document all installation methods (brew, apt, curl, cargo) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .github/workflows/release-cli.yml | 92 ++++++++++++++++++++++++++++++- packages/awn-cli/Cargo.toml | 10 ++++ packages/awn-cli/install.sh | 74 +++++++++++++++++++++++++ packages/awn-cli/skills/SKILL.md | 30 +++++++++- 4 files changed, 202 insertions(+), 4 deletions(-) create mode 100755 packages/awn-cli/install.sh diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 2fe1db2..2f677ad 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -40,6 +40,13 @@ jobs: - name: Build run: cargo build --release --manifest-path packages/awn-cli/Cargo.toml --target ${{ matrix.target }} + - name: Build .deb package (Linux only) + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: | + cargo install cargo-deb + cargo deb --manifest-path packages/awn-cli/Cargo.toml --target ${{ matrix.target }} --no-build + cp packages/awn-cli/target/${{ matrix.target }}/debian/*.deb dist/ 2>/dev/null || true + - name: Package shell: bash run: | @@ -56,7 +63,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: awn-${{ matrix.target }} - path: dist/${{ env.ARCHIVE }} + path: | + dist/${{ env.ARCHIVE }} + dist/*.deb upload: name: Upload to Release @@ -73,9 +82,88 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - for f in artifacts/*.tar.gz; do + for f in artifacts/*.tar.gz artifacts/*.deb; do + [ -f "$f" ] || continue echo "Uploading $f" gh release upload "${{ github.event.release.tag_name }}" "$f" \ --repo "${{ github.repository }}" \ --clobber done + + homebrew: + name: Update Homebrew formula + needs: upload + runs-on: ubuntu-latest + if: github.event_name == 'release' + steps: + - uses: actions/checkout@v6 + + - name: Generate Homebrew formula + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + REPO="${{ github.repository }}" + BASE_URL="https://github.com/${REPO}/releases/download/${TAG}" + + URL_DARWIN_ARM64="${BASE_URL}/awn-v${VERSION}-aarch64-apple-darwin.tar.gz" + URL_DARWIN_X64="${BASE_URL}/awn-v${VERSION}-x86_64-apple-darwin.tar.gz" + URL_LINUX_X64="${BASE_URL}/awn-v${VERSION}-x86_64-unknown-linux-gnu.tar.gz" + + # Download archives and compute SHA256 + for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu; do + url="${BASE_URL}/awn-v${VERSION}-${target}.tar.gz" + curl -sL "$url" -o "/tmp/awn-${target}.tar.gz" + done + + SHA_DARWIN_ARM64=$(sha256sum /tmp/awn-aarch64-apple-darwin.tar.gz | cut -d' ' -f1) + SHA_DARWIN_X64=$(sha256sum /tmp/awn-x86_64-apple-darwin.tar.gz | cut -d' ' -f1) + SHA_LINUX_X64=$(sha256sum /tmp/awn-x86_64-unknown-linux-gnu.tar.gz | cut -d' ' -f1) + + mkdir -p Formula + cat > Formula/awn.rb <&2; exit 1; } + +detect_target() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux*) os="unknown-linux-gnu" ;; + Darwin*) os="apple-darwin" ;; + *) error "Unsupported OS: $os" ;; + esac + + case "$arch" in + x86_64|amd64) arch="x86_64" ;; + arm64|aarch64) arch="aarch64" ;; + *) error "Unsupported architecture: $arch" ;; + esac + + echo "${arch}-${os}" +} + +get_latest_version() { + curl -sL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | head -1 \ + | sed 's/.*"tag_name": *"v\?\([^"]*\)".*/\1/' +} + +main() { + local version="${VERSION:-}" + local target + target="$(detect_target)" + + if [ -z "$version" ]; then + info "Fetching latest release..." + version="$(get_latest_version)" + fi + + [ -z "$version" ] && error "Could not determine version. Set VERSION=x.y.z manually." + + local url="https://github.com/${REPO}/releases/download/v${version}/${BINARY}-v${version}-${target}.tar.gz" + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + info "Downloading awn v${version} for ${target}..." + curl -sL "$url" -o "${tmp}/awn.tar.gz" || error "Download failed. Check that v${version} has a binary for ${target}." + + info "Extracting..." + tar xzf "${tmp}/awn.tar.gz" -C "$tmp" + + info "Installing to ${INSTALL_DIR}..." + if [ -w "$INSTALL_DIR" ]; then + cp "${tmp}/${BINARY}-v${version}-${target}/${BINARY}" "${INSTALL_DIR}/${BINARY}" + chmod +x "${INSTALL_DIR}/${BINARY}" + else + sudo cp "${tmp}/${BINARY}-v${version}-${target}/${BINARY}" "${INSTALL_DIR}/${BINARY}" + sudo chmod +x "${INSTALL_DIR}/${BINARY}" + fi + + info "Done! awn v${version} installed to ${INSTALL_DIR}/${BINARY}" + "${INSTALL_DIR}/${BINARY}" --version +} + +main "$@" diff --git a/packages/awn-cli/skills/SKILL.md b/packages/awn-cli/skills/SKILL.md index adbab90..860e74f 100644 --- a/packages/awn-cli/skills/SKILL.md +++ b/packages/awn-cli/skills/SKILL.md @@ -8,11 +8,37 @@ Standalone CLI for the Agent World Network. Discover worlds, join them, exchange ## Installation +### Quick install (recommended) + +```bash +curl -fsSL https://raw.githubusercontent.com/ReScienceLab/agent-world-network/main/packages/awn-cli/install.sh | bash ``` -cargo install awn + +### Homebrew (macOS / Linux) + +```bash +brew tap ReScienceLab/tap +brew install awn ``` -Or download a prebuilt binary from GitHub Releases. +### apt (Debian / Ubuntu) + +Download the `.deb` package from [GitHub Releases](https://github.com/ReScienceLab/agent-world-network/releases): + +```bash +curl -LO https://github.com/ReScienceLab/agent-world-network/releases/latest/download/awn_VERSION_amd64.deb +sudo dpkg -i awn_*_amd64.deb +``` + +### Cargo (build from source) + +```bash +cargo install --git https://github.com/ReScienceLab/agent-world-network --path packages/awn-cli +``` + +### Manual download + +Download a prebuilt binary from [GitHub Releases](https://github.com/ReScienceLab/agent-world-network/releases) for your platform. **No runtime dependencies.** The binary includes everything needed. From 7a50ed764afce81b0fd13d4dc486068f3d47646d Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 17:05:51 +0800 Subject: [PATCH 09/11] fix(awn): resolve IPC port via port file + exit 1 on unimplemented stop Fixes two issues from Codex review: 1. CLI commands now discover daemon IPC port via: --ipc-port flag > AWN_IPC_PORT env > ~/.awn/daemon.port file > default The daemon writes its bound port to daemon.port on start and removes it on shutdown, so 'awn status' works after 'awn daemon start --ipc-port N' without needing out-of-band env setup. 2. 'awn daemon stop' now exits with code 1 (was 0) since it is not yet implemented. Agents relying on exit codes will correctly detect failure. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/awn-cli/src/daemon.rs | 17 ++++++++++++++ packages/awn-cli/src/main.rs | 41 +++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/awn-cli/src/daemon.rs b/packages/awn-cli/src/daemon.rs index 972135d..b52087b 100644 --- a/packages/awn-cli/src/daemon.rs +++ b/packages/awn-cli/src/daemon.rs @@ -236,6 +236,23 @@ pub fn default_gateway_url() -> String { std::env::var("GATEWAY_URL").unwrap_or_else(|_| "https://gateway.agentworlds.ai".to_string()) } +const PORT_FILE: &str = "daemon.port"; + +pub fn write_port_file(data_dir: &std::path::Path, port: u16) { + let _ = std::fs::create_dir_all(data_dir); + let _ = std::fs::write(data_dir.join(PORT_FILE), port.to_string()); +} + +pub fn read_port_file(data_dir: &std::path::Path) -> Option { + std::fs::read_to_string(data_dir.join(PORT_FILE)) + .ok() + .and_then(|s| s.trim().parse().ok()) +} + +pub fn remove_port_file(data_dir: &std::path::Path) { + let _ = std::fs::remove_file(data_dir.join(PORT_FILE)); +} + #[derive(Debug, thiserror::Error)] pub enum DaemonError { #[error("identity error: {0}")] diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index 95416cc..1863aa2 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -13,6 +13,10 @@ struct Cli { #[arg(long, global = true)] json: bool, + /// IPC port for CLI ↔ daemon communication (overrides AWN_IPC_PORT and saved port file) + #[arg(long, global = true)] + ipc_port: Option, + #[command(subcommand)] command: Commands, } @@ -49,9 +53,6 @@ enum DaemonAction { /// Peer server port #[arg(long, default_value_t = 8099)] port: u16, - /// IPC port for CLI ↔ daemon communication - #[arg(long, default_value_t = 0)] - ipc_port: u16, }, /// Stop the AWN daemon Stop, @@ -67,18 +68,14 @@ async fn main() { data_dir, gateway_url, port, - ipc_port, } => { let data_dir = data_dir.unwrap_or_else(daemon::default_data_dir); let gateway_url = gateway_url.unwrap_or_else(daemon::default_gateway_url); - let ipc_port = if ipc_port == 0 { - daemon::ipc_port() - } else { - ipc_port - }; + let ipc_port = cli.ipc_port.unwrap_or_else(|| daemon::ipc_port()); - match daemon::start_daemon(data_dir, gateway_url, port, ipc_port).await { + match daemon::start_daemon(data_dir.clone(), gateway_url, port, ipc_port).await { Ok(handle) => { + daemon::write_port_file(&data_dir, handle.addr.port()); if cli.json { println!( "{}", @@ -92,6 +89,7 @@ async fn main() { eprintln!("Press Ctrl+C to stop"); } tokio::signal::ctrl_c().await.ok(); + daemon::remove_port_file(&data_dir); handle.shutdown(); if !cli.json { eprintln!("Daemon stopped"); @@ -111,12 +109,13 @@ async fn main() { if cli.json { println!("{}", serde_json::json!({"error": "daemon stop not yet implemented (use Ctrl+C)"})); } else { - eprintln!("Use Ctrl+C to stop the daemon, or kill the process."); + eprintln!("Error: daemon stop not yet implemented. Use Ctrl+C to stop the daemon, or kill the process."); } + std::process::exit(1); } }, Commands::Status => { - let ipc = daemon::ipc_port(); + let ipc = resolve_ipc_port(&cli); let url = format!("http://127.0.0.1:{ipc}/ipc/status"); match reqwest::get(&url).await { Ok(resp) => { @@ -144,10 +143,10 @@ async fn main() { } } } - Commands::Peers { capability } => { - let ipc = daemon::ipc_port(); + Commands::Peers { ref capability } => { + let ipc = resolve_ipc_port(&cli); let mut url = format!("http://127.0.0.1:{ipc}/ipc/peers"); - if let Some(cap) = &capability { + if let Some(cap) = capability { url = format!("{url}?capability={}", urlencoding(cap)); } match reqwest::get(&url).await { @@ -187,7 +186,7 @@ async fn main() { } } Commands::Worlds => { - let ipc = daemon::ipc_port(); + let ipc = resolve_ipc_port(&cli); let url = format!("http://127.0.0.1:{ipc}/ipc/worlds"); match reqwest::get(&url).await { Ok(resp) => { @@ -219,6 +218,16 @@ async fn main() { } } +fn resolve_ipc_port(cli: &Cli) -> u16 { + if let Some(port) = cli.ipc_port { + return port; + } + if let Ok(port) = std::env::var("AWN_IPC_PORT").and_then(|s| s.parse().map_err(|_| std::env::VarError::NotPresent)) { + return port; + } + daemon::read_port_file(&daemon::default_data_dir()).unwrap_or_else(|| daemon::ipc_port()) +} + fn urlencoding(s: &str) -> String { s.replace(':', "%3A") } From ab3d8a07b866bb77bb21f42ffa733d4c73813126 Mon Sep 17 00:00:00 2001 From: Yilin <69336584+Jing-yilin@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:33:25 +0800 Subject: [PATCH 10/11] refactor: separate world records from agent registries (#159) ## Summary - rename peer-facing TypeScript modules and APIs to agent/world terminology - add dedicated world db/registry storage in the plugin, SDK, and gateway - update gateway world discovery, heartbeats, and tests for worldId-as-identity plus slug support ## Validation - npm run build - npm --prefix packages/agent-world-sdk run build - node --test test/*.test.mjs --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .changeset/beige-nights-jam.md | 5 + gateway/schemas.mjs | 17 +- gateway/server.mjs | 288 ++++++++------ .../src/{peer-db.ts => agent-db.ts} | 44 +-- .../{peer-protocol.ts => agent-protocol.ts} | 28 +- .../agent-world-sdk/src/gateway-announce.ts | 31 +- packages/agent-world-sdk/src/index.ts | 8 +- packages/agent-world-sdk/src/types.ts | 10 +- packages/agent-world-sdk/src/world-db.ts | 39 ++ packages/agent-world-sdk/src/world-server.ts | 30 +- src/{peer-client.ts => agent-client.ts} | 6 +- src/{peer-db.ts => agent-db.ts} | 90 ++--- src/{peer-server.ts => agent-server.ts} | 32 +- src/channel.ts | 14 +- src/index.ts | 355 ++++++++++-------- src/types.ts | 25 +- src/world-db.ts | 103 +++++ ...-db.test.mjs => agentid-agent-db.test.mjs} | 86 +++-- test/base58.test.mjs | 2 +- test/canonicalize.test.mjs | 18 +- test/gateway-announce-default.test.mjs | 4 +- test/gateway-heartbeat.test.mjs | 40 +- test/gateway-world-record.test.mjs | 34 +- test/gateway-worlds.test.mjs | 22 +- test/index-lifecycle.test.mjs | 240 +++++++----- test/key-rotation-sdk.test.mjs | 20 +- test/key-rotation.test.mjs | 12 +- test/request-signing.test.mjs | 10 +- test/response-signing.test.mjs | 8 +- test/transport-enforcement.test.mjs | 10 +- test/transport-types.test.mjs | 8 +- test/world-db.test.mjs | 58 +++ test/world-members.test.mjs | 2 +- test/world-state-broadcast.test.mjs | 8 +- 34 files changed, 1056 insertions(+), 651 deletions(-) create mode 100644 .changeset/beige-nights-jam.md rename packages/agent-world-sdk/src/{peer-db.ts => agent-db.ts} (60%) rename packages/agent-world-sdk/src/{peer-protocol.ts => agent-protocol.ts} (94%) create mode 100644 packages/agent-world-sdk/src/world-db.ts rename src/{peer-client.ts => agent-client.ts} (97%) rename src/{peer-db.ts => agent-db.ts} (70%) rename src/{peer-server.ts => agent-server.ts} (92%) create mode 100644 src/world-db.ts rename test/{agentid-peer-db.test.mjs => agentid-agent-db.test.mjs} (65%) create mode 100644 test/world-db.test.mjs diff --git a/.changeset/beige-nights-jam.md b/.changeset/beige-nights-jam.md new file mode 100644 index 0000000..5e981f2 --- /dev/null +++ b/.changeset/beige-nights-jam.md @@ -0,0 +1,5 @@ +--- +"@resciencelab/agent-world-network": minor +--- + +Rename peer terminology to agent/world across codebase; split world records into dedicated WorldDb and gateway world registry with worldId-as-protocol-identity model and human-readable slug field diff --git a/gateway/schemas.mjs b/gateway/schemas.mjs index c6b66f5..f38010b 100644 --- a/gateway/schemas.mjs +++ b/gateway/schemas.mjs @@ -23,8 +23,8 @@ export const EndpointSchema = { }, } -export const PeerRecordSchema = { - $id: "PeerRecord", +export const AgentRecordSchema = { + $id: "AgentRecord", type: "object", required: ["agentId", "publicKey", "alias", "endpoints", "capabilities", "lastSeen"], properties: { @@ -40,11 +40,10 @@ export const PeerRecordSchema = { export const WorldSummarySchema = { $id: "WorldSummary", type: "object", - required: ["worldId", "agentId", "name", "endpoints", "reachable", "lastSeen"], + required: ["worldId", "endpoints", "reachable", "lastSeen"], properties: { worldId: { type: "string" }, - agentId: { type: "string" }, - name: { type: "string" }, + slug: { type: "string" }, endpoints: { type: "array", items: { $ref: "Endpoint#" } }, reachable: { type: "boolean" }, lastSeen: { type: "integer" }, @@ -54,12 +53,11 @@ export const WorldSummarySchema = { export const WorldDetailSchema = { $id: "WorldDetail", type: "object", - required: ["worldId", "agentId", "publicKey", "name", "endpoints", "reachable", "subscribers", "lastSeen"], + required: ["worldId", "publicKey", "endpoints", "reachable", "subscribers", "lastSeen"], properties: { worldId: { type: "string" }, - agentId: { type: "string" }, + slug: { type: "string" }, publicKey: { type: "string" }, - name: { type: "string" }, endpoints: { type: "array", items: { $ref: "Endpoint#" } }, reachable: { type: "boolean" }, subscribers: { type: "integer", description: "Number of active WebSocket subscribers" }, @@ -75,6 +73,7 @@ export const AnnounceRequestSchema = { from: { type: "string", description: "aw:sha256:{hex} agent identifier" }, publicKey: { type: "string", description: "Base64-encoded Ed25519 public key" }, alias: { type: "string" }, + slug: { type: "string", description: "Optional human-friendly world label for world servers" }, version: { type: "string", default: "1.0.0" }, endpoints: { type: "array", items: { $ref: "Endpoint#" } }, capabilities: { type: "array", items: { type: "string" } }, @@ -110,7 +109,7 @@ export const SignedMessageSchema = { export const allSchemas = [ ErrorSchema, EndpointSchema, - PeerRecordSchema, + AgentRecordSchema, WorldSummarySchema, WorldDetailSchema, AnnounceRequestSchema, diff --git a/gateway/server.mjs b/gateway/server.mjs index df2adbb..ac744c9 100644 --- a/gateway/server.mjs +++ b/gateway/server.mjs @@ -83,8 +83,10 @@ export async function createGatewayApp(opts = {}) { webhookUrl = WEBHOOK_URL, } = opts - const REGISTRY_PATH = path.join(dataDir, "registry.json") - const REGISTRY_TMP_PATH = `${REGISTRY_PATH}.tmp` + const AGENT_REGISTRY_PATH = path.join(dataDir, "agents-registry.json") + const AGENT_REGISTRY_TMP_PATH = `${AGENT_REGISTRY_PATH}.tmp` + const WORLD_REGISTRY_PATH = path.join(dataDir, "worlds-registry.json") + const WORLD_REGISTRY_TMP_PATH = `${WORLD_REGISTRY_PATH}.tmp` // --------------------------------------------------------------------------- // Identity @@ -98,63 +100,81 @@ export async function createGatewayApp(opts = {}) { // Registry // --------------------------------------------------------------------------- - const registry = new Map() // agentId -> PeerRecord + const agentRegistry = new Map() // agentId -> AgentRecord + const worldRegistry = new Map() // worldId -> WorldRecord let _saveTimer = null let _tickTimer = null let _shutdownPromise = null let _registryModifiedAt = null - function writeRegistry() { + function writeRegistries() { fs.mkdirSync(dataDir, { recursive: true }) - const payload = { + const agentPayload = { version: REGISTRY_VERSION, savedAt: Date.now(), - agents: Object.fromEntries([...registry.entries()]), + agents: Object.fromEntries([...agentRegistry.entries()]), } - fs.writeFileSync(REGISTRY_TMP_PATH, JSON.stringify(payload, null, 2)) - fs.renameSync(REGISTRY_TMP_PATH, REGISTRY_PATH) + fs.writeFileSync(AGENT_REGISTRY_TMP_PATH, JSON.stringify(agentPayload, null, 2)) + fs.renameSync(AGENT_REGISTRY_TMP_PATH, AGENT_REGISTRY_PATH) + + const worldPayload = { + version: REGISTRY_VERSION, + savedAt: Date.now(), + worlds: Object.fromEntries([...worldRegistry.entries()]), + } + fs.writeFileSync(WORLD_REGISTRY_TMP_PATH, JSON.stringify(worldPayload, null, 2)) + fs.renameSync(WORLD_REGISTRY_TMP_PATH, WORLD_REGISTRY_PATH) } - function loadRegistry() { - if (!fs.existsSync(REGISTRY_PATH)) { - console.warn(`[gateway] Registry file missing at ${REGISTRY_PATH}; starting with empty registry`) - registry.clear() - _registryModifiedAt = null - return + function loadRegistryFile(filePath, key, targetRegistry) { + if (!fs.existsSync(filePath)) { + targetRegistry.clear() + return { loaded: 0, discarded: 0, savedAt: null } } - try { - const raw = JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")) - if (raw?.version !== REGISTRY_VERSION || !raw?.agents || typeof raw.agents !== "object") { - throw new Error("invalid registry schema") - } + const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) + if (raw?.version !== REGISTRY_VERSION || !raw?.[key] || typeof raw[key] !== "object") { + throw new Error(`invalid ${key} registry schema`) + } - registry.clear() - const cutoff = Date.now() - staleTtlMs - let loaded = 0 - let discarded = 0 + targetRegistry.clear() + const cutoff = Date.now() - staleTtlMs + let loaded = 0 + let discarded = 0 - for (const [agentId, record] of Object.entries(raw.agents)) { - if (!record || typeof record !== "object") { - discarded++ - continue - } - const lastSeen = typeof record.lastSeen === "number" ? record.lastSeen : 0 - if (lastSeen < cutoff) { - discarded++ - continue - } - registry.set(agentId, record) - loaded++ + for (const [id, record] of Object.entries(raw[key])) { + if (!record || typeof record !== "object") { + discarded++ + continue } + const lastSeen = typeof record.lastSeen === "number" ? record.lastSeen : 0 + if (lastSeen < cutoff) { + discarded++ + continue + } + targetRegistry.set(id, record) + loaded++ + } + + return { + loaded, + discarded, + savedAt: typeof raw.savedAt === "number" ? raw.savedAt : null, + } + } - _registryModifiedAt = loaded > 0 - ? (typeof raw.savedAt === "number" ? raw.savedAt : Date.now()) - : null - console.log(`[gateway] Loaded ${loaded} agents from registry (discarded ${discarded} stale)`) + function loadRegistries() { + try { + const agents = loadRegistryFile(AGENT_REGISTRY_PATH, "agents", agentRegistry) + const worlds = loadRegistryFile(WORLD_REGISTRY_PATH, "worlds", worldRegistry) + const timestamps = [agents.savedAt, worlds.savedAt].filter((value) => typeof value === "number") + _registryModifiedAt = timestamps.length > 0 ? Math.max(...timestamps) : null + console.log(`[gateway] Loaded ${agents.loaded} agents from registry (discarded ${agents.discarded} stale)`) + console.log(`[gateway] Loaded ${worlds.loaded} worlds from registry (discarded ${worlds.discarded} stale)`) } catch (error) { - console.warn(`[gateway] Failed to load registry from ${REGISTRY_PATH}; starting with empty registry`, error) - registry.clear() + console.warn("[gateway] Failed to load registry files; starting with empty registries", error) + agentRegistry.clear() + worldRegistry.clear() _registryModifiedAt = null } } @@ -164,9 +184,9 @@ export async function createGatewayApp(opts = {}) { _saveTimer = setTimeout(() => { _saveTimer = null try { - writeRegistry() + writeRegistries() } catch (error) { - console.warn(`[gateway] Failed to save registry to ${REGISTRY_PATH}`, error) + console.warn("[gateway] Failed to save registry files", error) } }, SAVE_DEBOUNCE_MS) } @@ -178,16 +198,16 @@ export async function createGatewayApp(opts = {}) { } try { - writeRegistry() + writeRegistries() } catch (error) { - console.warn(`[gateway] Failed to flush registry to ${REGISTRY_PATH}`, error) + console.warn("[gateway] Failed to flush registry files", error) } } function upsertAgent(agentId, publicKey, opts = {}) { const persist = opts.persist === true const now = Date.now() - const existing = registry.get(agentId) + const existing = agentRegistry.get(agentId) const firstSeen = existing === undefined const lastSeen = opts.lastSeen ? Math.max(existing?.lastSeen ?? 0, opts.lastSeen) @@ -201,11 +221,11 @@ export async function createGatewayApp(opts = {}) { lastSeen, } const changed = JSON.stringify(existing ?? null) !== JSON.stringify(nextRecord) - registry.set(agentId, nextRecord) + agentRegistry.set(agentId, nextRecord) let trimmed = false - if (registry.size > MAX_AGENTS) { - const oldest = [...registry.values()].sort((a, b) => a.lastSeen - b.lastSeen)[0] - registry.delete(oldest.agentId) + if (agentRegistry.size > MAX_AGENTS) { + const oldest = [...agentRegistry.values()].sort((a, b) => a.lastSeen - b.lastSeen)[0] + agentRegistry.delete(oldest.agentId) trimmed = true } if (changed || trimmed) { @@ -215,13 +235,39 @@ export async function createGatewayApp(opts = {}) { saveRegistry() } if (firstSeen && webhookUrl) { - const caps = nextRecord.capabilities ?? [] - const worldCap = caps.find((c) => c.startsWith("world:")) - const worldId = worldCap ? worldCap.slice("world:".length) : undefined fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ event: "world.announced", agentId, worldId, ts: Date.now() }), + body: JSON.stringify({ event: "agent.announced", agentId, ts: Date.now() }), + signal: AbortSignal.timeout(5_000), + }).catch(() => {}) + } + } + + function upsertWorld(worldId, publicKey, opts = {}) { + const persist = opts.persist === true + const now = Date.now() + const existing = worldRegistry.get(worldId) + const firstSeen = existing === undefined + const lastSeen = opts.lastSeen + ? Math.max(existing?.lastSeen ?? 0, opts.lastSeen) + : now + const nextRecord = { + worldId, + slug: opts.slug ?? existing?.slug ?? worldId, + publicKey: publicKey || existing?.publicKey || "", + endpoints: opts.endpoints ?? existing?.endpoints ?? [], + lastSeen, + } + const changed = JSON.stringify(existing ?? null) !== JSON.stringify(nextRecord) + worldRegistry.set(worldId, nextRecord) + if (changed) _registryModifiedAt = now + if (persist && changed) saveRegistry() + if (firstSeen && webhookUrl) { + fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event: "world.announced", worldId, slug: nextRecord.slug, ts: Date.now() }), signal: AbortSignal.timeout(5_000), }).catch(() => {}) } @@ -230,8 +276,8 @@ export async function createGatewayApp(opts = {}) { function pruneStaleAgents(ttl = staleTtlMs) { const cutoff = Date.now() - ttl let pruned = 0 - for (const [id, p] of registry) { - if (p.lastSeen < cutoff) { registry.delete(id); pruned++ } + for (const [id, p] of agentRegistry) { + if (p.lastSeen < cutoff) { agentRegistry.delete(id); pruned++ } } if (pruned > 0) { console.log(`[gateway] Pruned ${pruned} stale agent(s) (TTL ${ttl / 1000}s)`) @@ -239,8 +285,20 @@ export async function createGatewayApp(opts = {}) { } } + function pruneStaleWorlds(ttl = staleTtlMs) { + const cutoff = Date.now() - ttl + let pruned = 0 + for (const [id, world] of worldRegistry) { + if (world.lastSeen < cutoff) { worldRegistry.delete(id); pruned++ } + } + if (pruned > 0) { + console.log(`[gateway] Pruned ${pruned} stale world(s) (TTL ${ttl / 1000}s)`) + flushRegistry() + } + } + function getAgentsForExchange(limit = 50) { - return [...registry.values()] + return [...agentRegistry.values()] .sort((a, b) => b.lastSeen - a.lastSeen) .slice(0, limit) .map(({ agentId, publicKey, alias, endpoints, capabilities, lastSeen }) => ({ @@ -248,11 +306,13 @@ export async function createGatewayApp(opts = {}) { })) } - function findByCapability(cap) { - const isPrefix = cap.endsWith(":"); - return [...registry.values()].filter((p) => - p.capabilities?.some((c) => isPrefix ? c.startsWith(cap) : c === cap) - ).sort((a, b) => b.lastSeen - a.lastSeen); + function listWorlds() { + return [...worldRegistry.values()] + .sort((a, b) => b.lastSeen - a.lastSeen) + } + + function getWorld(worldId) { + return worldRegistry.get(worldId) } // --------------------------------------------------------------------------- @@ -286,7 +346,7 @@ export async function createGatewayApp(opts = {}) { // --------------------------------------------------------------------------- async function sendToWorld(worldId, event, content) { - const world = findByCapability(`world:${worldId}`)[0]; + const world = getWorld(worldId); if (!world?.endpoints?.length) { console.warn(`[gateway] No reachable endpoints for world:${worldId}`); return { ok: false, error: "World agent not reachable" }; @@ -379,8 +439,8 @@ export async function createGatewayApp(opts = {}) { }, }, async () => { const ts = Date.now() - const worlds = findByCapability("world:").length - const agents = registry.size + const worlds = worldRegistry.size + const agents = agentRegistry.size const registryAge = agents > 0 && _registryModifiedAt !== null ? Math.max(0, ts - _registryModifiedAt) : null @@ -449,7 +509,7 @@ export async function createGatewayApp(opts = {}) { 200: { type: "object", required: ["agents"], - properties: { agents: { type: "array", items: { $ref: "PeerRecord#" } } }, + properties: { agents: { type: "array", items: { $ref: "AgentRecord#" } } }, }, }, }, @@ -471,20 +531,14 @@ export async function createGatewayApp(opts = {}) { }, }, }, async () => { - const worlds = findByCapability("world:"); return { - worlds: worlds.map((w) => { - const cap = w.capabilities.find((c) => c.startsWith("world:")) ?? ""; - const worldId = cap.slice("world:".length); - return { - worldId, - agentId: w.agentId, - name: w.alias || worldId, - endpoints: w.endpoints ?? [], - reachable: w.endpoints?.length > 0, - lastSeen: w.lastSeen, - }; - }), + worlds: listWorlds().map((world) => ({ + worldId: world.worldId, + slug: world.slug, + endpoints: world.endpoints ?? [], + reachable: world.endpoints?.length > 0, + lastSeen: world.lastSeen, + })), }; }); @@ -505,18 +559,16 @@ export async function createGatewayApp(opts = {}) { }, }, async (req, reply) => { const { worldId } = req.params; - const worlds = findByCapability(`world:${worldId}`); - if (!worlds.length) return reply.code(404).send({ error: "World not found" }); - const w = worlds[0]; + const world = getWorld(worldId); + if (!world) return reply.code(404).send({ error: "World not found" }); return { worldId, - agentId: w.agentId, - publicKey: w.publicKey, - name: w.alias || worldId, - endpoints: w.endpoints, - reachable: w.endpoints?.length > 0, + slug: world.slug, + publicKey: world.publicKey, + endpoints: world.endpoints, + reachable: world.endpoints?.length > 0, subscribers: worldSubs.get(worldId)?.size ?? 0, - lastSeen: w.lastSeen, + lastSeen: world.lastSeen, }; }); @@ -549,17 +601,12 @@ export async function createGatewayApp(opts = {}) { } } const { worldId } = req.params; - const worlds = findByCapability(`world:${worldId}`); - if (!worlds.length) return reply.code(404).send({ error: "World not found" }); - let removed = 0; - for (const w of worlds) { - registry.delete(w.agentId); - removed++; - } + if (!worldRegistry.has(worldId)) return reply.code(404).send({ error: "World not found" }); + worldRegistry.delete(worldId); _registryModifiedAt = Date.now(); flushRegistry(); - console.log(`[gateway] Deregistered world:${worldId} (${removed} agent(s) removed)`); - return { ok: true, removed }; + console.log(`[gateway] Deregistered world:${worldId}`); + return { ok: true, removed: 1 }; }); app.get("/agents/:agentId", { @@ -573,13 +620,13 @@ export async function createGatewayApp(opts = {}) { properties: { agentId: { type: "string" } }, }, response: { - 200: { $ref: "PeerRecord#" }, + 200: { $ref: "AgentRecord#" }, 404: { $ref: "Error#" }, }, }, }, async (req, reply) => { const { agentId } = req.params; - const agent = registry.get(agentId); + const agent = agentRegistry.get(agentId); if (!agent) return reply.code(404).send({ error: "Agent not found" }); return agent; }); @@ -613,8 +660,8 @@ export async function createGatewayApp(opts = {}) { } } const { agentId } = req.params; - if (!registry.has(agentId)) return reply.code(404).send({ error: "Agent not found" }); - registry.delete(agentId); + if (!agentRegistry.has(agentId)) return reply.code(404).send({ error: "Agent not found" }); + agentRegistry.delete(agentId); _registryModifiedAt = Date.now(); flushRegistry(); console.log(`[gateway] Deregistered agent:${agentId}`); @@ -713,10 +760,10 @@ export async function createGatewayApp(opts = {}) { response: { 200: { type: "object", - required: ["ok", "peers"], + required: ["ok", "agents"], properties: { ok: { type: "boolean" }, - peers: { type: "array", items: { $ref: "PeerRecord#" } }, + agents: { type: "array", items: { $ref: "AgentRecord#" } }, }, }, 400: { $ref: "Error#" }, @@ -743,10 +790,25 @@ export async function createGatewayApp(opts = {}) { if (agentIdFromPublicKey(ann.publicKey) !== ann.from) { return reply.code(400).send({ error: "agentId mismatch" }); } - upsertAgent(ann.from, ann.publicKey, { - alias: ann.alias, endpoints: ann.endpoints, capabilities: ann.capabilities, persist: true, - }); - return { ok: true, peers: getAgentsForExchange(20) }; + const worldCap = Array.isArray(ann.capabilities) + ? ann.capabilities.find((cap) => typeof cap === "string" && cap.startsWith("world:")) + : undefined + if (worldCap) { + const protocolWorldId = agentIdFromPublicKey(ann.publicKey) + upsertWorld(protocolWorldId, ann.publicKey, { + slug: typeof ann.slug === "string" && ann.slug.length > 0 + ? ann.slug + : worldCap.slice("world:".length) || ann.alias || protocolWorldId, + endpoints: ann.endpoints, + lastSeen: ann.timestamp, + persist: true, + }) + } else { + upsertAgent(ann.from, ann.publicKey, { + alias: ann.alias, endpoints: ann.endpoints, capabilities: ann.capabilities, persist: true, + }); + } + return { ok: true, agents: getAgentsForExchange(20) }; }); peer.post("/agents/:agentId/heartbeat", { @@ -776,7 +838,7 @@ export async function createGatewayApp(opts = {}) { const skew = Math.abs(Date.now() - ts); if (skew > 5 * 60 * 1000) return reply.code(400).send({ error: "Timestamp out of range" }); - const existing = registry.get(agentId); + const existing = agentRegistry.get(agentId); if (!existing) return reply.code(404).send({ error: "Unknown agent" }); const ok = verifyWithDomainSeparator( @@ -819,9 +881,8 @@ export async function createGatewayApp(opts = {}) { const skew = Math.abs(Date.now() - ts); if (skew > 5 * 60 * 1000) return reply.code(400).send({ error: "Timestamp out of range" }); - const worlds = findByCapability(`world:${worldId}`); - if (!worlds.length) return reply.code(404).send({ error: "World not found" }); - const existing = worlds[0]; + const existing = worldRegistry.get(worldId); + if (!existing) return reply.code(404).send({ error: "World not found" }); const ok = verifyWithDomainSeparator( DOMAIN_SEPARATORS.HEARTBEAT, @@ -900,14 +961,15 @@ export async function createGatewayApp(opts = {}) { } async function start() { - loadRegistry() + loadRegistries() await app.listen({ port: httpPort, host: "::" }) console.log(`[gateway] agentId=${selfAgentId}`) console.log(`[gateway] HTTP on [::]:${httpPort}`) _tickTimer = setInterval(() => { pruneStaleAgents() + pruneStaleWorlds() if (_registryModifiedAt !== null) { - try { writeRegistry() } catch (error) { + try { writeRegistries() } catch (error) { console.warn("[gateway] Periodic snapshot failed", error) } } diff --git a/packages/agent-world-sdk/src/peer-db.ts b/packages/agent-world-sdk/src/agent-db.ts similarity index 60% rename from packages/agent-world-sdk/src/peer-db.ts rename to packages/agent-world-sdk/src/agent-db.ts index 79a8ce2..3e995c9 100644 --- a/packages/agent-world-sdk/src/peer-db.ts +++ b/packages/agent-world-sdk/src/agent-db.ts @@ -1,10 +1,10 @@ -import type { PeerRecord } from "./types.js" +import type { AgentRecord } from "./types.js" const DEFAULT_MAX_PEERS = 200 const DEFAULT_STALE_TTL_MS = 30 * 60 * 1000 -export class PeerDb { - private peers = new Map() +export class AgentDb { + private agents = new Map() private maxPeers: number private staleTtlMs: number @@ -16,15 +16,15 @@ export class PeerDb { upsert( agentId: string, publicKey: string, - opts: Partial> & { lastSeen?: number } = {} + opts: Partial> & { lastSeen?: number } = {} ): void { const now = Date.now() - const existing = this.peers.get(agentId) + const existing = this.agents.get(agentId) const lastSeen = opts.lastSeen != null ? Math.max(existing?.lastSeen ?? 0, opts.lastSeen) : now - this.peers.set(agentId, { + this.agents.set(agentId, { agentId, publicKey: publicKey || existing?.publicKey || "", alias: opts.alias ?? existing?.alias ?? "", @@ -33,31 +33,31 @@ export class PeerDb { lastSeen, }) - if (this.peers.size > this.maxPeers) { - const oldest = [...this.peers.values()].sort((a, b) => a.lastSeen - b.lastSeen)[0] - this.peers.delete(oldest.agentId) + if (this.agents.size > this.maxPeers) { + const oldest = [...this.agents.values()].sort((a, b) => a.lastSeen - b.lastSeen)[0] + this.agents.delete(oldest.agentId) } } - get(agentId: string): PeerRecord | undefined { - return this.peers.get(agentId) + get(agentId: string): AgentRecord | undefined { + return this.agents.get(agentId) } has(agentId: string): boolean { - return this.peers.has(agentId) + return this.agents.has(agentId) } prune(ttl = this.staleTtlMs): number { const cutoff = Date.now() - ttl let count = 0 - for (const [id, p] of this.peers) { - if (p.lastSeen < cutoff) { this.peers.delete(id); count++ } + for (const [id, p] of this.agents) { + if (p.lastSeen < cutoff) { this.agents.delete(id); count++ } } return count } - getPeersForExchange(limit = 50): PeerRecord[] { - return [...this.peers.values()] + getAgentsForExchange(limit = 50): AgentRecord[] { + return [...this.agents.values()] .sort((a, b) => b.lastSeen - a.lastSeen) .slice(0, limit) .map(({ agentId, publicKey, alias, endpoints, capabilities, lastSeen }) => ({ @@ -68,22 +68,22 @@ export class PeerDb { })) } - findByCapability(cap: string): PeerRecord[] { + findByCapability(cap: string): AgentRecord[] { const isPrefix = cap.endsWith(":") - return [...this.peers.values()] + return [...this.agents.values()] .filter((p) => p.capabilities?.some((c) => isPrefix ? c.startsWith(cap) : c === cap)) .sort((a, b) => b.lastSeen - a.lastSeen) } get size(): number { - return this.peers.size + return this.agents.size } - values(): IterableIterator { - return this.peers.values() + values(): IterableIterator { + return this.agents.values() } delete(agentId: string): void { - this.peers.delete(agentId) + this.agents.delete(agentId) } } diff --git a/packages/agent-world-sdk/src/peer-protocol.ts b/packages/agent-world-sdk/src/agent-protocol.ts similarity index 94% rename from packages/agent-world-sdk/src/peer-protocol.ts rename to packages/agent-world-sdk/src/agent-protocol.ts index 9b79bd0..8853ea0 100644 --- a/packages/agent-world-sdk/src/peer-protocol.ts +++ b/packages/agent-world-sdk/src/agent-protocol.ts @@ -13,13 +13,13 @@ import { PROTOCOL_VERSION } from "./version.js"; import { buildSignedAgentCard } from "./card.js"; import type { AgentCardOpts } from "./card.js"; import type { Identity, KeyRotationRequest } from "./types.js"; -import type { PeerDb as PeerDbType } from "./peer-db.js"; +import type { AgentDb as AgentDbType } from "./agent-db.js"; export type { AgentCardOpts }; -export interface PeerProtocolOpts { +export interface AgentProtocolOpts { identity: Identity; - peerDb: PeerDbType; + agentDb: AgentDbType; /** Extra fields to include in /peer/ping response (evaluated on every request) */ pingExtra?: Record | (() => Record); /** Called when a non-peer-protocol message arrives. Return reply body or null to skip. */ @@ -36,15 +36,15 @@ export interface PeerProtocolOpts { /** * Register AWN peer protocol routes on a Fastify instance: * GET /peer/ping - * GET /peer/peers + * GET /peer/agents * POST /peer/announce * POST /peer/message */ -export function registerPeerRoutes( +export function registerAgentRoutes( fastify: FastifyInstance, - opts: PeerProtocolOpts + opts: AgentProtocolOpts ): void { - const { identity, peerDb, pingExtra, onMessage, card } = opts; + const { identity, agentDb, pingExtra, onMessage, card } = opts; // Custom JSON parser that preserves the raw body string for digest verification. // The raw bytes are stored on req.rawBody so verifyHttpRequestHeaders can check @@ -103,7 +103,7 @@ export function registerPeerRoutes( })); fastify.get("/peer/peers", async () => ({ - peers: peerDb.getPeersForExchange(), + agents: agentDb.getAgentsForExchange(), })); fastify.post("/peer/announce", async (req, reply) => { @@ -148,12 +148,12 @@ export function registerPeerRoutes( .code(400) .send({ error: "agentId does not match publicKey" }); } - peerDb.upsert(ann.from as string, ann.publicKey as string, { + agentDb.upsert(ann.from as string, ann.publicKey as string, { alias: ann.alias as string, endpoints: ann.endpoints as [], capabilities: ann.capabilities as [], }); - return { peers: peerDb.getPeersForExchange() }; + return { agents: agentDb.getAgentsForExchange() }; }); fastify.post("/peer/message", async (req, reply) => { @@ -195,7 +195,7 @@ export function registerPeerRoutes( const agentId = msg.from as string; // TOFU: verify agentId ↔ publicKey binding - const known = peerDb.get(agentId); + const known = agentDb.get(agentId); if (known?.publicKey) { if (known.publicKey !== msg.publicKey) { return reply.code(403).send({ @@ -210,7 +210,7 @@ export function registerPeerRoutes( } } - peerDb.upsert(agentId, msg.publicKey as string, {}); + agentDb.upsert(agentId, msg.publicKey as string, {}); let content: unknown; try { @@ -322,7 +322,7 @@ export function registerPeerRoutes( return reply.code(403).send({ error: "Invalid signatureByNewKey" }); } - const known = peerDb.get(agentId); + const known = agentDb.get(agentId); if (known?.publicKey && known.publicKey !== oldPublicKeyB64) { return reply.code(403).send({ error: @@ -330,7 +330,7 @@ export function registerPeerRoutes( }); } - peerDb.upsert(agentId, newPublicKeyB64, {}); + agentDb.upsert(agentId, newPublicKeyB64, {}); return { ok: true }; }); } diff --git a/packages/agent-world-sdk/src/gateway-announce.ts b/packages/agent-world-sdk/src/gateway-announce.ts index e1a921a..f5d9850 100644 --- a/packages/agent-world-sdk/src/gateway-announce.ts +++ b/packages/agent-world-sdk/src/gateway-announce.ts @@ -5,18 +5,19 @@ import { signWithDomainSeparator, } from "./crypto.js"; import type { Identity } from "./types.js"; -import type { PeerDb } from "./peer-db.js"; +import type { AgentDb } from "./agent-db.js"; const DEFAULT_GATEWAY_URL = "http://localhost:8100"; export interface AnnounceOpts { identity: Identity; alias: string; + slug?: string; version?: string; publicAddr: string | null; publicPort: number; capabilities: string[]; - peerDb: PeerDb; + agentDb: AgentDb; } export async function announceToGateway( @@ -26,11 +27,12 @@ export async function announceToGateway( const { identity, alias, + slug, version, publicAddr, publicPort, capabilities, - peerDb, + agentDb, } = opts; const url = `${gatewayUrl.replace(/\/+$/, "")}/agents`; @@ -51,6 +53,7 @@ export async function announceToGateway( from: identity.agentId, publicKey: identity.pubB64, alias, + ...(slug ? { slug } : {}), version: version ?? "1.0.0", endpoints, capabilities, @@ -80,7 +83,7 @@ export async function announceToGateway( }); if (!resp.ok) return; const data = (await resp.json()) as { - peers?: Array<{ + agents?: Array<{ agentId: string; publicKey: string; alias: string; @@ -89,13 +92,13 @@ export async function announceToGateway( lastSeen: number; }>; }; - for (const peer of data.peers ?? []) { - if (peer.agentId && peer.agentId !== identity.agentId) { - peerDb.upsert(peer.agentId, peer.publicKey, { - alias: peer.alias, - endpoints: peer.endpoints, - capabilities: peer.capabilities, - lastSeen: peer.lastSeen, + for (const agent of data.agents ?? []) { + if (agent.agentId && agent.agentId !== identity.agentId) { + agentDb.upsert(agent.agentId, agent.publicKey, { + alias: agent.alias, + endpoints: agent.endpoints, + capabilities: agent.capabilities, + lastSeen: agent.lastSeen, }); } } @@ -177,11 +180,11 @@ export async function startGatewayAnnounce(opts: GatewayAnnounceOpts): Promise<( await Promise.allSettled( urls.map((u) => announceToGateway(u, opts)) ); - onDiscovery?.(opts.peerDb.size); + onDiscovery?.(opts.agentDb.size); } - const worldCap = opts.capabilities.find((c) => c.startsWith("world:")); - const worldId = worldCap ? worldCap.slice("world:".length) : undefined; + const isWorldServer = opts.capabilities.some((c) => c.startsWith("world:")); + const worldId = isWorldServer ? opts.identity.agentId : undefined; async function runHeartbeat() { const results = await Promise.allSettled( diff --git a/packages/agent-world-sdk/src/index.ts b/packages/agent-world-sdk/src/index.ts index cffd357..48639a8 100644 --- a/packages/agent-world-sdk/src/index.ts +++ b/packages/agent-world-sdk/src/index.ts @@ -22,14 +22,16 @@ export { } from "./identity.js"; export { buildSignedAgentCard, verifyAgentCard } from "./card.js"; export type { AgentCardOpts } from "./card.js"; -export { PeerDb } from "./peer-db.js"; +export { AgentDb } from "./agent-db.js"; +export { WorldDb } from "./world-db.js"; export { announceToGateway, startGatewayAnnounce, sendHeartbeat } from "./gateway-announce.js"; -export { registerPeerRoutes, multibaseToBase64, base58Decode } from "./peer-protocol.js"; +export { registerAgentRoutes, multibaseToBase64, base58Decode } from "./agent-protocol.js"; export { createWorldServer } from "./world-server.js"; export { WorldLedger } from "./world-ledger.js"; export type { Endpoint, - PeerRecord, + AgentRecord, + WorldRecord, Identity, ActionParamSchema, ActionSchema, diff --git a/packages/agent-world-sdk/src/types.ts b/packages/agent-world-sdk/src/types.ts index 6726300..bd8c76c 100644 --- a/packages/agent-world-sdk/src/types.ts +++ b/packages/agent-world-sdk/src/types.ts @@ -6,7 +6,7 @@ export interface Endpoint { ttl?: number } -export interface PeerRecord { +export interface AgentRecord { agentId: string publicKey: string alias: string @@ -15,6 +15,14 @@ export interface PeerRecord { lastSeen: number } +export interface WorldRecord { + worldId: string + slug: string + publicKey: string + endpoints: Endpoint[] + lastSeen: number +} + export interface Identity { agentId: string pubB64: string diff --git a/packages/agent-world-sdk/src/world-db.ts b/packages/agent-world-sdk/src/world-db.ts new file mode 100644 index 0000000..77a98b5 --- /dev/null +++ b/packages/agent-world-sdk/src/world-db.ts @@ -0,0 +1,39 @@ +import type { WorldRecord } from "./types.js" + +export class WorldDb { + private worlds = new Map() + + upsert( + worldId: string, + opts: Partial> = {} + ): void { + const existing = this.worlds.get(worldId) + this.worlds.set(worldId, { + worldId, + slug: opts.slug ?? existing?.slug ?? worldId, + publicKey: opts.publicKey ?? existing?.publicKey ?? "", + endpoints: opts.endpoints ?? existing?.endpoints ?? [], + lastSeen: opts.lastSeen ?? existing?.lastSeen ?? Date.now(), + }) + } + + get(worldId: string): WorldRecord | undefined { + return this.worlds.get(worldId) + } + + getBySlug(slug: string): WorldRecord | undefined { + return [...this.worlds.values()].find((world) => world.slug === slug) + } + + list(): WorldRecord[] { + return [...this.worlds.values()].sort((a, b) => b.lastSeen - a.lastSeen) + } + + delete(worldId: string): void { + this.worlds.delete(worldId) + } + + get size(): number { + return this.worlds.size + } +} diff --git a/packages/agent-world-sdk/src/world-server.ts b/packages/agent-world-sdk/src/world-server.ts index 7523bc1..25087fe 100644 --- a/packages/agent-world-sdk/src/world-server.ts +++ b/packages/agent-world-sdk/src/world-server.ts @@ -1,7 +1,7 @@ import Fastify from "fastify"; import { loadOrCreateIdentity } from "./identity.js"; -import { PeerDb } from "./peer-db.js"; -import { registerPeerRoutes } from "./peer-protocol.js"; +import { AgentDb } from "./agent-db.js"; +import { registerAgentRoutes } from "./agent-protocol.js"; import { startGatewayAnnounce } from "./gateway-announce.js"; import { canonicalize, @@ -85,11 +85,12 @@ export async function createWorldServer( const resolvedPublicPort = publicPort ?? port; const identity = loadOrCreateIdentity(dataDir, "world-identity"); + const protocolWorldId = identity.agentId console.log( `[world] agentId=${identity.agentId} world=${worldId} name="${worldName}"` ); - const peerDb = new PeerDb({ staleTtlMs }); + const agentDb = new AgentDb({ staleTtlMs }); // Track agents currently in world for idle eviction const agentLastSeen = new Map(); @@ -125,14 +126,15 @@ export async function createWorldServer( const fastify = Fastify({ logger: false }); // Register peer protocol routes - registerPeerRoutes(fastify, { + registerAgentRoutes(fastify, { identity, - peerDb, + agentDb, card: cardUrl ? { name: cardName ?? worldName, description: cardDescription, cardUrl } : undefined, pingExtra: () => ({ - worldId, + worldId: protocolWorldId, + slug: worldId, worldName, agents: agentLastSeen.size, ...(maxAgents ? { maxAgents } : {}), @@ -163,7 +165,8 @@ export async function createWorldServer( ledger.append("world.join", agentId, alias || undefined); sendReply({ ok: true, - worldId, + worldId: protocolWorldId, + slug: worldId, manifest: buildManifest(result.manifest), state: result.state, members: getMembers(agentId), @@ -239,8 +242,8 @@ export async function createWorldServer( fastify.get("/world/members", async (req, reply) => { const from = req.headers["x-agentworld-from"] as string | undefined; - const peer = from ? peerDb.get(from) : undefined; - if (!peer?.publicKey) { + const agent = from ? agentDb.get(from) : undefined; + if (!agent?.publicKey) { return reply.code(403).send({ error: "Unknown member public key" }); } const authority = (req.headers["host"] as string) ?? "localhost"; @@ -250,7 +253,7 @@ export async function createWorldServer( req.url, authority, "", - peer.publicKey + agent.publicKey ); if (!result.ok) { return reply.code(403).send({ error: result.error }); @@ -348,8 +351,8 @@ export async function createWorldServer( // Stale peer pruning const pruneTimer = setInterval(() => { - const pruned = peerDb.prune(); - if (pruned > 0) console.log(`[world] Pruned ${pruned} stale peer(s)`); + const pruned = agentDb.prune(); + if (pruned > 0) console.log(`[world] Pruned ${pruned} stale agent(s)`); }, 5 * 60 * 1000); // Gateway announce @@ -358,10 +361,11 @@ export async function createWorldServer( stopAnnounce = await startGatewayAnnounce({ identity, alias: worldName, + slug: worldId, publicAddr, publicPort: resolvedPublicPort, capabilities: [`world:${worldId}`], - peerDb, + agentDb, gatewayUrls, intervalMs: announceIntervalMs, onDiscovery: (n) => diff --git a/src/peer-client.ts b/src/agent-client.ts similarity index 97% rename from src/peer-client.ts rename to src/agent-client.ts index 588e7e7..1fcc39c 100644 --- a/src/peer-client.ts +++ b/src/agent-client.ts @@ -95,7 +95,7 @@ export interface SendOptions { expectedPublicKey?: string } -export async function getPeerPingInfo( +export async function getAgentPingInfo( targetAddr: string, port: number = 8099, timeoutMs: number = 5_000, @@ -204,12 +204,12 @@ export async function broadcastLeave( console.log(`[p2p] Leave broadcast sent to ${reachable.length} peer(s)`) } -export async function pingPeer( +export async function pingAgent( targetAddr: string, port: number = 8099, timeoutMs: number = 5_000, endpoints?: Endpoint[], ): Promise { - const result = await getPeerPingInfo(targetAddr, port, timeoutMs, endpoints) + const result = await getAgentPingInfo(targetAddr, port, timeoutMs, endpoints) return result.ok } diff --git a/src/peer-db.ts b/src/agent-db.ts similarity index 70% rename from src/peer-db.ts rename to src/agent-db.ts index ea23368..0226535 100644 --- a/src/peer-db.ts +++ b/src/agent-db.ts @@ -1,19 +1,19 @@ /** - * Local peer store with TOFU (Trust On First Use) logic. + * Local agent store with TOFU (Trust On First Use) logic. * Keyed by agentId. */ import * as fs from "fs" import * as path from "path" -import { DiscoveredPeerRecord, Endpoint } from "./types" +import { DiscoveredAgentRecord, Endpoint } from "./types" import { agentIdFromPublicKey } from "./identity" -interface PeerStore { +interface AgentStore { version: number - peers: Record + agents: Record } let dbPath: string -let store: PeerStore = { version: 2, peers: {} } +let store: AgentStore = { version: 3, agents: {} } let _saveTimer: ReturnType | null = null const SAVE_DEBOUNCE_MS = 1000 @@ -21,9 +21,9 @@ function load(): void { if (fs.existsSync(dbPath)) { try { const raw = JSON.parse(fs.readFileSync(dbPath, "utf-8")) - const migrated: Record = {} - for (const [storedId, record] of Object.entries(raw.peers ?? {})) { - const r = record as DiscoveredPeerRecord + const migrated: Record = {} + for (const [storedId, record] of Object.entries(raw.agents ?? raw.peers ?? {})) { + const r = record as DiscoveredAgentRecord // Migrate legacy 32-char truncated agentIds → aw:sha256:<64hex> if (/^[0-9a-f]{32}$/.test(storedId) && r.publicKey) { const newId = agentIdFromPublicKey(r.publicKey) @@ -32,12 +32,12 @@ function load(): void { migrated[storedId] = r } } - store = { version: 2, peers: migrated } + store = { version: 3, agents: migrated } } catch { - store = { version: 2, peers: {} } + store = { version: 3, agents: {} } } } else { - store = { version: 2, peers: {} } + store = { version: 3, agents: {} } } } @@ -62,22 +62,22 @@ export function flushDb(): void { } export function initDb(dataDir: string): void { - dbPath = path.join(dataDir, "peers.json") + dbPath = path.join(dataDir, "agents.json") load() } -export function listPeers(): DiscoveredPeerRecord[] { - return Object.values(store.peers).sort((a, b) => b.lastSeen - a.lastSeen) +export function listAgents(): DiscoveredAgentRecord[] { + return Object.values(store.agents).sort((a, b) => b.lastSeen - a.lastSeen) } -export function upsertPeer(agentId: string, alias: string = ""): void { +export function upsertAgent(agentId: string, alias: string = ""): void { const now = Date.now() - const existing = store.peers[agentId] + const existing = store.agents[agentId] if (existing) { existing.alias = alias || existing.alias existing.lastSeen = now } else { - store.peers[agentId] = { + store.agents[agentId] = { agentId, publicKey: "", alias, @@ -91,7 +91,7 @@ export function upsertPeer(agentId: string, alias: string = ""): void { saveImmediate() } -export function upsertDiscoveredPeer( +export function upsertDiscoveredAgent( agentId: string, publicKey: string, opts: { @@ -104,8 +104,12 @@ export function upsertDiscoveredPeer( capabilities?: string[] } = {} ): void { + if (opts.capabilities?.some((cap) => cap.startsWith("world:"))) { + return + } + const now = Date.now() - const existing = store.peers[agentId] + const existing = store.agents[agentId] if (existing) { if (!existing.publicKey) existing.publicKey = publicKey if (opts.lastSeen !== undefined) { @@ -119,7 +123,7 @@ export function upsertDiscoveredPeer( if (opts.capabilities?.length) existing.capabilities = opts.capabilities if (opts.alias && existing.source !== "manual") existing.alias = opts.alias } else { - store.peers[agentId] = { + store.agents[agentId] = { agentId, publicKey, alias: opts.alias ?? "", @@ -135,39 +139,39 @@ export function upsertDiscoveredPeer( save() } -export function getPeersForExchange(max: number = 20): DiscoveredPeerRecord[] { - return Object.values(store.peers) +export function getAgentsForExchange(max: number = 20): DiscoveredAgentRecord[] { + return Object.values(store.agents) .filter((p) => p.publicKey) .sort((a, b) => b.lastSeen - a.lastSeen) .slice(0, max) } -export function removePeer(agentId: string): void { - delete store.peers[agentId] +export function removeAgent(agentId: string): void { + delete store.agents[agentId] saveImmediate() } -export function getPeer(agentId: string): DiscoveredPeerRecord | null { - return store.peers[agentId] ?? null +export function getAgent(agentId: string): DiscoveredAgentRecord | null { + return store.agents[agentId] ?? null } -export function getPeerIds(): string[] { - return Object.keys(store.peers) +export function getAgentIds(): string[] { + return Object.keys(store.agents) } export function pruneStale(maxAgeMs: number, protectedIds: string[] = []): number { const cutoff = Date.now() - maxAgeMs let pruned = 0 - for (const [id, record] of Object.entries(store.peers)) { + for (const [id, record] of Object.entries(store.agents)) { if (record.source === "manual") continue if (protectedIds.includes(id)) continue if (record.lastSeen < cutoff) { - delete store.peers[id] + delete store.agents[id] pruned++ } } if (pruned > 0) { - console.log(`[p2p:db] Pruned ${pruned} stale peer(s)`) + console.log(`[awn:db] Pruned ${pruned} stale agent(s)`) saveImmediate() } return pruned @@ -183,10 +187,10 @@ export function setTofuTtl(days: number): void { export function tofuVerifyAndCache(agentId: string, publicKey: string): boolean { const now = Date.now() - const existing = store.peers[agentId] + const existing = store.agents[agentId] if (!existing) { - store.peers[agentId] = { + store.agents[agentId] = { agentId, publicKey, alias: "", @@ -211,7 +215,7 @@ export function tofuVerifyAndCache(agentId: string, publicKey: string): boolean // TTL check: if binding has expired, accept new key as fresh TOFU if (existing.tofuCachedAt && now - existing.tofuCachedAt > _tofuTtlMs) { - console.log(`[p2p:db] TOFU TTL expired for ${agentId} — accepting new key`) + console.log(`[awn:db] TOFU TTL expired for ${agentId} — accepting new key`) existing.publicKey = publicKey existing.tofuCachedAt = now existing.lastSeen = now @@ -231,13 +235,13 @@ export function tofuVerifyAndCache(agentId: string, publicKey: string): boolean export function tofuReplaceKey(agentId: string, newPublicKey: string): void { const now = Date.now() - const existing = store.peers[agentId] + const existing = store.agents[agentId] if (existing) { existing.publicKey = newPublicKey existing.tofuCachedAt = now existing.lastSeen = now } else { - store.peers[agentId] = { + store.agents[agentId] = { agentId, publicKey: newPublicKey, alias: "", @@ -252,23 +256,23 @@ export function tofuReplaceKey(agentId: string, newPublicKey: string): void { saveImmediate() } -/** Extract a reachable address from a peer's endpoints for a given transport. */ -export function getEndpointAddress(peer: DiscoveredPeerRecord, transport: string): string | null { - const ep = peer.endpoints +/** Extract a reachable address from an agent's endpoints for a given transport. */ +export function getEndpointAddress(agent: DiscoveredAgentRecord, transport: string): string | null { + const ep = agent.endpoints ?.filter((e) => e.transport === transport) .sort((a, b) => a.priority - b.priority)[0] return ep?.address ?? null } /** - * Find peers that have a matching capability. + * Find agents that have a matching capability. * - Prefix match (cap ends with ":"): "world:" matches "world:pixel-city", "world:dungeon", etc. * - Exact match (cap has no trailing ":"): "world:pixel-city" matches only "world:pixel-city". - * Returns peers sorted by lastSeen descending. + * Returns agents sorted by lastSeen descending. */ -export function findPeersByCapability(cap: string): DiscoveredPeerRecord[] { +export function findAgentsByCapability(cap: string): DiscoveredAgentRecord[] { const isPrefix = cap.endsWith(":") - return Object.values(store.peers) + return Object.values(store.agents) .filter((p) => p.capabilities?.some((c) => isPrefix ? c.startsWith(cap) : c === cap)) .sort((a, b) => b.lastSeen - a.lastSeen) } diff --git a/src/peer-server.ts b/src/agent-server.ts similarity index 92% rename from src/peer-server.ts rename to src/agent-server.ts index eba6510..446eae5 100644 --- a/src/peer-server.ts +++ b/src/agent-server.ts @@ -15,7 +15,7 @@ import { agentIdFromPublicKey, verifyHttpRequestHeaders, signHttpResponse as sig // eslint-disable-next-line @typescript-eslint/no-var-requires const pkgVersion: string = require("../package.json").version const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") -import { tofuVerifyAndCache, tofuReplaceKey, getPeersForExchange, upsertDiscoveredPeer, removePeer, getPeer } from "./peer-db" +import { tofuVerifyAndCache, tofuReplaceKey, getAgentsForExchange, upsertDiscoveredAgent, removeAgent, getAgent } from "./agent-db" const MAX_MESSAGE_AGE_MS = 5 * 60 * 1000 // 5 minutes @@ -67,7 +67,7 @@ interface SelfMeta { } let _selfMeta: SelfMeta = {} -export interface PeerServerOptions { +export interface AgentServerOptions { /** If true, disables startup delays for tests */ testMode?: boolean /** Identity for response signing (optional) */ @@ -95,7 +95,7 @@ function canonical(msg: P2PMessage): Record { } } -export async function startPeerServer(port: number = 8099, opts?: PeerServerOptions): Promise { +export async function startAgentServer(port: number = 8099, opts?: AgentServerOptions): Promise { if (opts?.identity) { _identity = opts.identity } @@ -159,8 +159,8 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti const agentId: string = raw.from - const knownPeer = getPeer(agentId) - if (!knownPeer?.publicKey && agentIdFromPublicKey(raw.publicKey) !== agentId) { + const knownAgent = getAgent(agentId) + if (!knownAgent?.publicKey && agentIdFromPublicKey(raw.publicKey) !== agentId) { return reply.code(400).send({ error: "agentId does not match publicKey" }) } @@ -189,8 +189,8 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti } if (msg.event === "leave") { - removePeer(agentId) - console.log(`[p2p] <- leave from=${agentId} — removed from peer table`) + removeAgent(agentId) + console.log(`[p2p] <- leave from=${agentId} — removed from agent table`) return { ok: true } } @@ -218,8 +218,8 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti const agentId: string = rot.oldAgentId - // Only accept key rotation from known peers or co-members - if (!getPeer(agentId) && !isCoMember(agentId)) { + // Only accept key rotation from known agents or co-members + if (!getAgent(agentId) && !isCoMember(agentId)) { return reply.code(403).send({ error: "Unknown agent — key rotation requires existing relationship" }) } @@ -260,8 +260,8 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti } // TOFU: clean rotation only — key-loss recovery requires manual re-pairing - const knownPeer = getPeer(agentId) - if (knownPeer?.publicKey && knownPeer.publicKey !== oldPublicKeyB64) { + const knownAgent = getAgent(agentId) + if (knownAgent?.publicKey && knownAgent.publicKey !== oldPublicKeyB64) { return reply.code(403).send({ error: "TOFU binding mismatch — key-loss recovery requires manual re-pairing" }) } @@ -272,10 +272,10 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti }) await server.listen({ port, host: "::" }) - console.log(`[p2p] Peer server listening on [::]:${port}`) + console.log(`[p2p] Agent server listening on [::]:${port}`) } -export async function stopPeerServer(): Promise { +export async function stopAgentServer(): Promise { if (server) { await server.close() server = null @@ -299,8 +299,8 @@ export function handleUdpMessage(data: Buffer, from: string): boolean { return false } - const knownPeer = getPeer(raw.from) - if (!knownPeer?.publicKey && agentIdFromPublicKey(raw.publicKey) !== raw.from) { + const knownAgent = getAgent(raw.from) + if (!knownAgent?.publicKey && agentIdFromPublicKey(raw.publicKey) !== raw.from) { return false } @@ -332,7 +332,7 @@ export function handleUdpMessage(data: Buffer, from: string): boolean { } if (msg.event === "leave") { - removePeer(raw.from) + removeAgent(raw.from) console.log(`[p2p] <- leave (UDP) from=${raw.from}`) return true } diff --git a/src/channel.ts b/src/channel.ts index 6815ed7..32053f2 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -3,9 +3,9 @@ * Account IDs are agentIds. */ import { Identity } from "./types" -import { sendP2PMessage, SendOptions } from "./peer-client" -import { listPeers, getPeerIds, getPeer } from "./peer-db" -import { onMessage } from "./peer-server" +import { sendP2PMessage, SendOptions } from "./agent-client" +import { listAgents, getAgentIds, getAgent } from "./agent-db" +import { onMessage } from "./agent-server" export const CHANNEL_CONFIG_SCHEMA = { schema: { @@ -51,14 +51,14 @@ export function buildChannel(identity: Identity, port: number, getSendOpts?: (id capabilities: { chatTypes: ["direct"] }, configSchema: CHANNEL_CONFIG_SCHEMA, config: { - listAccountIds: (_cfg: unknown) => getPeerIds(), + listAccountIds: (_cfg: unknown) => getAgentIds(), resolveAccount: (_cfg: unknown, accountId: string | undefined) => { const id = accountId ?? "" - const peer = getPeer(id) + const agent = getAgent(id) return { accountId: id, - agentId: peer?.agentId ?? id, - alias: peer?.alias ?? id, + agentId: agent?.agentId ?? id, + alias: agent?.alias ?? id, } }, }, diff --git a/src/index.ts b/src/index.ts index e8f59df..dc7985b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,26 @@ /** * AWN — Agent World Network — OpenClaw plugin entry point. * - * Agent ID (sha256(publicKey)[:16]) is the primary peer identifier. + * Agent ID (sha256(publicKey)[:16]) is the primary agent identifier. * Transport is plain HTTP over TCP; QUIC is available as a fast optional transport. */ import * as os from "os" import * as path from "path" import { execSync } from "child_process" -import { loadOrCreateIdentity, deriveDidKey, verifyHttpResponseHeaders } from "./identity" -import { initDb, listPeers, getPeer, flushDb, getPeerIds, getEndpointAddress, setTofuTtl, findPeersByCapability, removePeer } from "./peer-db" -import { startPeerServer, stopPeerServer, setSelfMeta, handleUdpMessage, addWorldMembers, setWorldMembers, removeWorld, clearWorldMembers } from "./peer-server" -import { sendP2PMessage, pingPeer, broadcastLeave, SendOptions, getPeerPingInfo } from "./peer-client" -import { upsertDiscoveredPeer } from "./peer-db" +import { loadOrCreateIdentity, deriveDidKey, verifyHttpResponseHeaders, agentIdFromPublicKey } from "./identity" +import { initDb, listAgents, getAgent, flushDb, getAgentIds, setTofuTtl, removeAgent, findAgentsByCapability } from "./agent-db" +import { initWorldDb, listWorlds, getWorld, getWorldBySlug, upsertWorld, flushWorldDb } from "./world-db" +import { startAgentServer, stopAgentServer, setSelfMeta, handleUdpMessage, addWorldMembers, setWorldMembers, removeWorld, clearWorldMembers } from "./agent-server" +import { sendP2PMessage, pingAgent, broadcastLeave, SendOptions, getAgentPingInfo } from "./agent-client" +import { upsertDiscoveredAgent } from "./agent-db" import { buildChannel, wireInboundToGateway, CHANNEL_CONFIG_SCHEMA } from "./channel" -import { Identity, PluginConfig, Endpoint } from "./types" +import { Identity, PluginConfig, Endpoint, DiscoveredWorldRecord } from "./types" import { TransportManager } from "./transport" import { UDPTransport } from "./transport-quic" import { parseDirectPeerAddress } from "./address" const AWN_TOOLS = [ - "awn_list_peers", + "awn_list_agents", "awn_send_message", "awn_status", "list_worlds", "join_world", "world_action", "world_info", ] @@ -89,6 +90,7 @@ interface ActionSchema { // Track joined worlds for periodic member refresh interface JoinedWorldInfo { agentId: string + slug?: string address: string port: number publicKey: string @@ -163,8 +165,8 @@ function untrackWorldScopedPeer(agentId: string, worldId: string): void { if (worldIds.size > 0) return _worldScopedPeerWorlds.delete(agentId) - if (getPeer(agentId)?.source !== "manual") { - removePeer(agentId) + if (getAgent(agentId)?.source !== "manual") { + removeAgent(agentId) } } @@ -180,9 +182,9 @@ function syncWorldMembers( nextMemberIds.add(member.agentId) - const existingPeer = getPeer(member.agentId) - if (!existingPeer || existingPeer.source !== "manual") { - upsertDiscoveredPeer(member.agentId, "", { + const existingAgent = getAgent(member.agentId) + if (!existingAgent || existingAgent.source !== "manual") { + upsertDiscoveredAgent(member.agentId, "", { alias: member.alias, endpoints: member.endpoints, source: "gossip", @@ -304,7 +306,7 @@ async function leaveJoinedWorlds(): Promise { } function buildSendOpts(peerIdOrAddr?: string): SendOptions { - const peer = peerIdOrAddr ? getPeer(peerIdOrAddr) : null + const peer = peerIdOrAddr ? getAgent(peerIdOrAddr) : null return { endpoints: peer?.endpoints, quicTransport: _quicTransport?.isActive() ? _quicTransport : undefined, @@ -317,8 +319,8 @@ function getGatewayUrl(): string { } async function fetchGatewayWorldRecord(worldId: string): Promise<{ - agentId?: string - alias?: string + worldId?: string + slug?: string endpoints?: Endpoint[] publicKey?: string } | null> { @@ -346,27 +348,66 @@ async function fetchGatewayWorldRecord(worldId: string): Promise<{ : typeof host?.publicKey === "string" ? host.publicKey : undefined - const agentId = typeof detail.agentId === "string" - ? detail.agentId - : typeof host?.agentId === "string" - ? host.agentId - : undefined - const alias = typeof detail.name === "string" - ? detail.name + const protocolWorldId = typeof detail.worldId === "string" + ? detail.worldId + : undefined + const slug = typeof detail.slug === "string" + ? detail.slug : typeof detail.alias === "string" ? detail.alias - : typeof host?.name === "string" - ? host.name - : typeof host?.alias === "string" - ? host.alias - : undefined + : typeof detail.name === "string" + ? detail.name + : undefined - return { agentId, alias, endpoints, publicKey } + return { worldId: protocolWorldId, slug, endpoints, publicKey } } catch { return null } } +async function syncWorldsFromGateway(): Promise { + const gatewayWorlds: DiscoveredWorldRecord[] = [] + try { + const resp = await fetch(`${getGatewayUrl()}/worlds`, { signal: AbortSignal.timeout(10_000) }) + if (!resp.ok) return gatewayWorlds + + const data = await resp.json() as { + worlds?: Array<{ worldId: string; slug?: string; endpoints?: Endpoint[]; publicKey?: string; lastSeen?: number }> + } + + for (const world of data.worlds ?? []) { + if (!world.worldId || gatewayWorlds.some((item) => item.worldId === world.worldId)) continue + const nextWorld: DiscoveredWorldRecord = { + worldId: world.worldId, + slug: world.slug ?? world.worldId, + publicKey: world.publicKey ?? "", + endpoints: world.endpoints ?? [], + lastSeen: world.lastSeen ?? Date.now(), + source: "gateway", + } + gatewayWorlds.push(nextWorld) + upsertWorld(nextWorld.worldId, nextWorld) + } + } catch { /* gateway unreachable */ } + return gatewayWorlds +} + +function resolveKnownWorld(identifier: string | undefined): DiscoveredWorldRecord | null { + if (!identifier) return null + return getWorld(identifier) ?? getWorldBySlug(identifier) +} + +function resolveJoinedWorld(identifier: string | undefined): [string, JoinedWorldInfo] | null { + if (!identifier) return null + const direct = _joinedWorlds.get(identifier) + if (direct) return [identifier, direct] + + for (const [worldId, info] of _joinedWorlds) { + if (info.slug === identifier) return [worldId, info] + } + return null +} + export default function register(api: any) { api.registerService({ id: "awn-node", @@ -385,6 +426,7 @@ export default function register(api: any) { const isFirstRun = !require("fs").existsSync(path.join(dataDir, "identity.json")) identity = loadOrCreateIdentity(dataDir) initDb(dataDir) + initWorldDb(dataDir) if (cfg.tofu_ttl_days !== undefined) setTofuTtl(cfg.tofu_ttl_days) console.log(`[awn] Agent ID: ${identity.agentId}`) @@ -429,7 +471,7 @@ export default function register(api: any) { } _agentMeta.endpoints = advertisedEndpoints - await startPeerServer(peerPort, { identity }) + await startAgentServer(peerPort, { identity }) setSelfMeta({ agentId: identity.agentId, @@ -450,7 +492,7 @@ export default function register(api: any) { "", "Quick start:", " openclaw awn status — show your agent ID", - " openclaw join_world — join a world to discover peers", + " openclaw join_world — join a world to discover agents", ] _welcomeTimer = setTimeout(() => { _welcomeTimer = null @@ -465,7 +507,7 @@ export default function register(api: any) { }, 2000) } - console.log(`[awn] Ready — join a world to discover peers`) + console.log(`[awn] Ready — join a world to discover agents`) }, stop: async () => { @@ -485,10 +527,11 @@ export default function register(api: any) { clearWorldMembers() _worldRefreshFailures.clear() if (identity) { - await broadcastLeave(identity, listPeers(), peerPort, buildSendOpts()) + await broadcastLeave(identity, listAgents(), peerPort, buildSendOpts()) } flushDb() - await stopPeerServer() + flushWorldDb() + await stopAgentServer() if (_transportManager) { await _transportManager.stop() _transportManager = null @@ -514,9 +557,9 @@ export default function register(api: any) { capabilities: { chatTypes: ["direct"] }, configSchema: CHANNEL_CONFIG_SCHEMA, config: { - listAccountIds: () => (identity ? getPeerIds() : []), + listAccountIds: () => (identity ? getAgentIds() : []), resolveAccount: (_: unknown, accountId: string | undefined) => { - const peer = accountId ? getPeer(accountId) : null + const peer = accountId ? getAgent(accountId) : null return { accountId: accountId ?? "", agentId: peer?.agentId ?? accountId ?? "", @@ -558,43 +601,43 @@ export default function register(api: any) { if (_quicTransport?.isActive()) { console.log(`QUIC endpoint: ${_quicTransport.address}`) } - console.log(`Peer port: ${peerPort}`) - console.log(`Known peers: ${listPeers().length}`) + console.log(`Listen port: ${peerPort}`) + console.log(`Known agents: ${listAgents().length}`) console.log(`Worlds joined: ${_joinedWorlds.size}`) }) awn - .command("peers") - .description("List known peers") + .command("agents") + .description("List known agents") .action(() => { - const peers = listPeers() - if (peers.length === 0) { - console.log("No peers yet. Use 'openclaw awn add ' to add one.") + const agents = listAgents() + if (agents.length === 0) { + console.log("No agents yet. Use 'openclaw awn add ' to add one.") return } - console.log("=== Known Peers ===") - for (const peer of peers) { - const ago = Math.round((Date.now() - peer.lastSeen) / 1000) - const label = peer.alias ? ` — ${peer.alias}` : "" - const ver = peer.version ? ` [v${peer.version}]` : "" - const transports = peer.endpoints?.map((e) => e.transport).join(",") || "none" - console.log(` ${peer.agentId}${label}${ver} [${transports}] last seen ${ago}s ago`) + console.log("=== Known Agents ===") + for (const agent of agents) { + const ago = Math.round((Date.now() - agent.lastSeen) / 1000) + const label = agent.alias ? ` — ${agent.alias}` : "" + const ver = agent.version ? ` [v${agent.version}]` : "" + const transports = agent.endpoints?.map((e) => e.transport).join(",") || "none" + console.log(` ${agent.agentId}${label}${ver} [${transports}] last seen ${ago}s ago`) } }) awn .command("ping ") - .description("Check if a peer is reachable") + .description("Check if an agent is reachable") .action(async (agentId: string) => { console.log(`Pinging ${agentId}...`) - const peer = getPeer(agentId) - const ok = await pingPeer(agentId, peerPort, 5_000, peer?.endpoints) + const peer = getAgent(agentId) + const ok = await pingAgent(agentId, peerPort, 5_000, peer?.endpoints) console.log(ok ? `Reachable` : `Unreachable`) }) awn .command("send ") - .description("Send a direct message to a peer") + .description("Send a direct message to an agent") .action(async (agentId: string, message: string) => { if (!identity) { console.error("Plugin not started. Restart the gateway first.") @@ -618,8 +661,9 @@ export default function register(api: any) { } console.log("=== Joined Worlds ===") for (const [id, info] of _joinedWorlds) { - const name = info.manifest?.name ?? id - console.log(` ${id} — ${name} (${info.address}:${info.port})`) + const label = info.slug ?? id + const name = info.manifest?.name ?? label + console.log(` ${label} — ${name} (${info.address}:${info.port}) [id ${id}]`) const actions = info.manifest?.actions if (actions && Object.keys(actions).length > 0) { const actionList = Object.entries(actions).map(([k, v]) => `${k} (${v.desc})`).join(", ") @@ -637,7 +681,7 @@ export default function register(api: any) { description: "Show AWN node status", handler: () => { if (!identity) return { text: "AWN: not started yet." } - const peers = listPeers() + const agents = listAgents() const activeTransport = _transportManager?.active return { text: [ @@ -646,7 +690,7 @@ export default function register(api: any) { `DID Key: \`${deriveDidKey(identity.publicKey)}\``, `Transport: ${activeTransport?.id ?? "http-only"}`, ...(_quicTransport?.isActive() ? [`QUIC: \`${_quicTransport.address}\``] : []), - `Peers: ${peers.length} known`, + `Known agents: ${agents.length}`, `Worlds: ${_joinedWorlds.size} joined`, ].join("\n"), } @@ -654,24 +698,26 @@ export default function register(api: any) { }) api.registerCommand({ - name: "awn-peers", - description: "List known AWN peers", + name: "awn-agents", + description: "List known AWN agents", handler: () => { - const peers = listPeers() - if (peers.length === 0) return { text: "No peers yet. Use `openclaw awn add `." } - const lines = peers.map((p) => { + const agents = listAgents() + if (agents.length === 0) return { text: "No agents yet. Use `openclaw awn add `." } + const lines = agents.map((p) => { + const ago = Math.round((Date.now() - p.lastSeen) / 1000) const label = p.alias ? ` — ${p.alias}` : "" const ver = p.version ? ` [v${p.version}]` : "" - return `\`${p.agentId}\`${label}${ver}` + const caps = p.capabilities?.length ? ` [${p.capabilities.join(", ")}]` : "" + return `${p.agentId}${label}${ver}${caps} — last seen ${ago}s ago` }) - return { text: `**Known Peers**\n${lines.join("\n")}` } + return { text: `**Known Agents**\n${lines.join("\n")}` } }, }) // ── Agent tools ──────────────────────────────────────────────────────────── api.registerTool({ name: "awn_send_message", - description: "Send a signed AWN message to a peer agent by their agent ID. The peer must share a joined world with this agent.", + description: "Send a signed AWN message to an agent by their agent ID. The agent must share a joined world with this agent.", parameters: { type: "object", properties: { @@ -696,23 +742,23 @@ export default function register(api: any) { }) api.registerTool({ - name: "awn_list_peers", - description: "List AWN peers from the local discovery cache. Optionally filter by capability prefix (e.g. 'world:' or 'world:pixel-city'). Entries may appear before you join a shared world, but direct messaging still requires world co-membership.", + name: "awn_list_agents", + description: "List AWN agents from the local discovery cache. Optionally filter by capability prefix (e.g. 'world:' or 'world:pixel-city'). Entries may appear before you join a shared world, but direct messaging still requires world co-membership.", parameters: { type: "object", properties: { - capability: { type: "string", description: "Filter peers by capability prefix (e.g. 'world:')" }, + capability: { type: "string", description: "Filter agents by capability prefix (e.g. 'world:')" }, }, required: [], }, async execute(_id: string, params: { capability?: string }) { - const peers = params.capability - ? findPeersByCapability(params.capability) - : listPeers() - if (peers.length === 0) { - return { content: [{ type: "text", text: "No peers found." }] } + const agents = params.capability + ? findAgentsByCapability(params.capability) + : listAgents() + if (agents.length === 0) { + return { content: [{ type: "text", text: "No agents found." }] } } - const lines = peers.map((p) => { + const lines = agents.map((p) => { const ago = Math.round((Date.now() - p.lastSeen) / 1000) const label = p.alias ? ` — ${p.alias}` : "" const ver = p.version ? ` [v${p.version}]` : "" @@ -725,13 +771,13 @@ export default function register(api: any) { api.registerTool({ name: "awn_status", - description: "Get this agent's AWN identity, transport mode, known peers, and joined worlds.", + description: "Get this agent's AWN identity, transport mode, known agents, and joined worlds.", parameters: { type: "object", properties: {}, required: [] }, async execute(_id: string, _params: Record) { if (!identity) { return { content: [{ type: "text", text: "AWN service not started." }] } } - const peers = listPeers() + const agents = listAgents() const activeTransport = _transportManager?.active const lines = [ ...((_agentMeta.name) ? [`Agent name: ${_agentMeta.name}`] : []), @@ -740,12 +786,13 @@ export default function register(api: any) { `Active transport: ${activeTransport?.id ?? "http-only"}`, ...(_quicTransport?.isActive() ? [`QUIC endpoint: ${_quicTransport.address}`] : []), `Plugin version: v${_agentMeta.version}`, - `Known peers: ${peers.length}`, + `Known agents: ${agents.length}`, `Worlds joined: ${_joinedWorlds.size}`, ] for (const [id, info] of _joinedWorlds) { - const name = info.manifest?.name ?? id - lines.push(` ${id} — ${name}`) + const label = info.slug ?? id + const name = info.manifest?.name ?? label + lines.push(` ${label} — ${name} [id ${id}]`) const actions = info.manifest?.actions if (actions && Object.keys(actions).length > 0) { lines.push(" Actions:") @@ -761,50 +808,21 @@ export default function register(api: any) { description: "List available Agent worlds from the World Registry and local cache.", parameters: { type: "object", properties: {}, required: [] }, async execute(_id: string, _params: Record) { - // Fetch from Gateway - let registryWorlds: Array<{ agentId: string; alias?: string; endpoints?: Endpoint[]; capabilities?: string[]; lastSeen: number }> = [] - try { - const resp = await fetch(`${getGatewayUrl()}/worlds`, { signal: AbortSignal.timeout(10_000) }) - if (resp.ok) { - const data = await resp.json() as { worlds?: Array<{ worldId: string; agentId: string; name?: string; endpoints?: Endpoint[]; lastSeen?: number }> } - for (const w of data.worlds ?? []) { - if (w.agentId && !registryWorlds.some(rw => rw.agentId === w.agentId)) { - registryWorlds.push({ - agentId: w.agentId, - alias: w.name, - endpoints: w.endpoints ?? [], - capabilities: [`world:${w.worldId}`], - lastSeen: w.lastSeen ?? Date.now(), - }) - upsertDiscoveredPeer(w.agentId, "", { - alias: w.name, - endpoints: w.endpoints ?? [], - capabilities: [`world:${w.worldId}`], - source: "gateway", - }) - } - } - } - } catch { /* gateway unreachable */ } - - // Merge with local cache - const localWorlds = findPeersByCapability("world:") - const allWorlds = [...localWorlds] - for (const rw of registryWorlds) { - if (!allWorlds.some(w => w.agentId === rw.agentId)) { - allWorlds.push(rw as any) + const registryWorlds = await syncWorldsFromGateway() + const allWorlds = [...listWorlds()] + for (const world of registryWorlds) { + if (!allWorlds.some((item) => item.worldId === world.worldId)) { + allWorlds.push(world) } } if (!allWorlds.length) { return { content: [{ type: "text", text: "No worlds found. Use join_world with a world address to connect directly." }] } } - const lines = allWorlds.map((p) => { - const cap = p.capabilities?.find((c: string) => c.startsWith("world:")) ?? "" - const worldId = cap.slice("world:".length) - const ago = Math.round((Date.now() - (p.lastSeen ?? 0)) / 1000) - const reachable = p.endpoints?.length ? "reachable" : "no endpoint" - return `world:${worldId} — ${p.alias || worldId} [${reachable}] — last seen ${ago}s ago` + const lines = allWorlds.map((world) => { + const ago = Math.round((Date.now() - world.lastSeen) / 1000) + const reachable = world.endpoints.length ? "reachable" : "no endpoint" + return `${world.slug} [${reachable}] — id ${world.worldId} — last seen ${ago}s ago` }) return { content: [{ type: "text", text: `Found ${allWorlds.length} world(s):\n${lines.join("\n")}` }] } }, @@ -816,7 +834,7 @@ export default function register(api: any) { parameters: { type: "object", properties: { - world_id: { type: "string", description: "The world ID (e.g. 'pixel-city') — looks up from known worlds" }, + world_id: { type: "string", description: "The protocol world ID or slug — looks up from known worlds" }, address: { type: "string", description: "Direct address of the world server (e.g. 'example.com:8099' or '1.2.3.4:8099')" }, alias: { type: "string", description: "Optional display name inside the world" }, }, @@ -834,13 +852,14 @@ export default function register(api: any) { let targetPort: number = peerPort let worldAgentId: string | undefined let worldPublicKey: string | undefined + let worldSlug: string | undefined if (params.address) { const parsedAddress = parseDirectPeerAddress(params.address, peerPort) targetAddr = parsedAddress.address targetPort = parsedAddress.port - const ping = await getPeerPingInfo(targetAddr, targetPort, 5_000) + const ping = await getAgentPingInfo(targetAddr, targetPort, 5_000) if (!ping.ok) { return { content: [{ type: "text", text: `World at ${params.address} is unreachable.` }], isError: true } } @@ -852,38 +871,40 @@ export default function register(api: any) { } worldAgentId = ping.data.agentId worldPublicKey = ping.data.publicKey + worldSlug = typeof ping.data?.slug === "string" + ? ping.data.slug + : typeof ping.data?.worldId === "string" && !ping.data.worldId.startsWith("aw:sha256:") + ? ping.data.worldId + : undefined } else { - const worlds = findPeersByCapability(`world:${params.world_id}`) - if (!worlds.length) { + let world = resolveKnownWorld(params.world_id) + if (!world) { + await syncWorldsFromGateway() + world = resolveKnownWorld(params.world_id) + } + if (!world) { return { content: [{ type: "text", text: `World '${params.world_id}' not found. Use address parameter to connect directly.` }] } } - let world = worlds[0] - if ((!world.endpoints?.length || !world.publicKey) && params.world_id) { - const gatewayWorld = await fetchGatewayWorldRecord(params.world_id) - if (gatewayWorld?.agentId) { - upsertDiscoveredPeer(gatewayWorld.agentId, gatewayWorld.publicKey ?? "", { - alias: gatewayWorld.alias ?? world.alias, - capabilities: world.capabilities, + if ((!world.endpoints.length || !world.publicKey) && world.worldId) { + const gatewayWorld = await fetchGatewayWorldRecord(world.worldId) + if (gatewayWorld?.worldId) { + upsertWorld(gatewayWorld.worldId, { + slug: gatewayWorld.slug ?? world.slug, + publicKey: gatewayWorld.publicKey ?? world.publicKey, endpoints: gatewayWorld.endpoints ?? world.endpoints, source: "gateway", }) - - world = getPeer(gatewayWorld.agentId) ?? { - ...world, - agentId: gatewayWorld.agentId, - alias: gatewayWorld.alias ?? world.alias, - endpoints: gatewayWorld.endpoints ?? world.endpoints, - publicKey: gatewayWorld.publicKey ?? world.publicKey, - } + world = getWorld(gatewayWorld.worldId) ?? world } } - if (!world.endpoints?.length) { + if (!world.endpoints.length) { return { content: [{ type: "text", text: `World '${params.world_id}' has no reachable endpoints.` }] } } targetAddr = world.endpoints[0].address targetPort = world.endpoints[0].port ?? peerPort - worldAgentId = world.agentId - worldPublicKey = getPeer(worldAgentId)?.publicKey ?? world.publicKey ?? "" + worldAgentId = world.worldId + worldPublicKey = world.publicKey + worldSlug = world.slug } if (!worldPublicKey) { @@ -912,16 +933,21 @@ export default function register(api: any) { return { content: [{ type: "text", text: `Failed to join world: ${result.error}` }], isError: true } } - const worldId = (result.data?.worldId ?? params.world_id ?? params.address) as string + const worldId = worldAgentId! const members = result.data?.members as unknown[] | undefined const memberCount = members?.length ?? 0 + const joinedSlug = typeof result.data?.slug === "string" + ? result.data.slug + : typeof result.data?.worldId === "string" && !result.data.worldId.startsWith("aw:sha256:") + ? result.data.worldId + : worldSlug ?? worldId const worldName = typeof result.data?.manifest === "object" && result.data?.manifest && typeof (result.data.manifest as { name?: unknown }).name === "string" ? (result.data.manifest as { name: string }).name - : worldId + : joinedSlug - upsertDiscoveredPeer(worldAgentId!, worldPublicKey, { - alias: worldName, - capabilities: [`world:${worldId}`], + upsertWorld(worldId, { + slug: joinedSlug, + publicKey: worldPublicKey, endpoints: [{ transport: "tcp", address: targetAddr, port: targetPort, priority: 1, ttl: 3600 }], source: "gossip", }) @@ -934,13 +960,13 @@ export default function register(api: any) { const manifest = typeof result.data?.manifest === "object" && result.data?.manifest ? result.data.manifest as JoinedWorldInfo["manifest"] : undefined - _joinedWorlds.set(worldId, { agentId: worldAgentId!, address: targetAddr, port: targetPort, publicKey: worldPublicKey, manifest }) + _joinedWorlds.set(worldId, { agentId: worldId, slug: joinedSlug, address: targetAddr, port: targetPort, publicKey: worldPublicKey, manifest }) _worldRefreshFailures.delete(worldId) if (!_memberRefreshTimer) { _memberRefreshTimer = setInterval(refreshWorldMembers, MEMBER_REFRESH_INTERVAL_MS) } - const lines = [`Joined world '${worldId}' (${worldName}) — ${memberCount} other member(s)`] + const lines = [`Joined world '${joinedSlug}' (${worldName}) — ${memberCount} other member(s)`] if (manifest?.actions && Object.keys(manifest.actions).length > 0) { lines.push("") lines.push("Available actions:") @@ -971,17 +997,22 @@ export default function register(api: any) { } let worldId = params.world_id + let info: JoinedWorldInfo | undefined if (!worldId) { if (_joinedWorlds.size === 1) { - worldId = [..._joinedWorlds.keys()][0] + ;[worldId, info] = [..._joinedWorlds.entries()][0] } else { - const ids = [..._joinedWorlds.keys()].join(", ") + const ids = [..._joinedWorlds.entries()].map(([id, joined]) => joined.slug ?? id).join(", ") return { content: [{ type: "text", text: `Multiple worlds joined (${ids}). Specify world_id.` }], isError: true } } } - - const info = _joinedWorlds.get(worldId) - if (!info) { + if (!info && worldId) { + const resolved = resolveJoinedWorld(worldId) + if (resolved) { + ;[worldId, info] = resolved + } + } + if (!info || !worldId) { return { content: [{ type: "text", text: `Not joined world '${worldId}'.` }], isError: true } } @@ -997,7 +1028,7 @@ export default function register(api: any) { const stateText = result.data?.state !== undefined ? `\nState: ${JSON.stringify(result.data.state)}` : "" - return { content: [{ type: "text", text: `Action '${params.action}' executed in world '${worldId}'.${stateText}` }] } + return { content: [{ type: "text", text: `Action '${params.action}' executed in world '${info.slug ?? worldId}'.${stateText}` }] } }, }) @@ -1020,23 +1051,29 @@ export default function register(api: any) { } let worldId = params.world_id + let info: JoinedWorldInfo | undefined if (!worldId) { if (_joinedWorlds.size === 1) { - worldId = [..._joinedWorlds.keys()][0] + ;[worldId, info] = [..._joinedWorlds.entries()][0] } else { - const ids = [..._joinedWorlds.keys()].join(", ") + const ids = [..._joinedWorlds.entries()].map(([id, joined]) => joined.slug ?? id).join(", ") return { content: [{ type: "text", text: `Multiple worlds joined (${ids}). Specify world_id.` }], isError: true } } } - - const info = _joinedWorlds.get(worldId) - if (!info) { + if (!info && worldId) { + const resolved = resolveJoinedWorld(worldId) + if (resolved) { + ;[worldId, info] = resolved + } + } + if (!info || !worldId) { return { content: [{ type: "text", text: `Not joined world '${worldId}'.` }], isError: true } } const manifest = info.manifest const lines: string[] = [] - lines.push(`World: ${manifest?.name ?? worldId} (${worldId})`) + lines.push(`World: ${manifest?.name ?? info.slug ?? worldId} (${info.slug ?? worldId})`) + lines.push(`Protocol ID: ${worldId}`) if (manifest?.description) lines.push(`Description: ${manifest.description}`) if (manifest?.objective) lines.push(`Objective: ${manifest.objective}`) if (manifest?.type) lines.push(`Type: ${manifest.type}`) diff --git a/src/types.ts b/src/types.ts index c5df52f..9e9b15f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,7 +29,7 @@ export interface P2PMessage { signature: string // Ed25519 sig over canonical JSON (all fields except signature) } -export interface PeerAnnouncement { +export interface AgentAnnouncement { from: string publicKey: string alias?: string @@ -38,7 +38,7 @@ export interface PeerAnnouncement { capabilities?: string[] timestamp: number signature: string - peers: Array<{ + agents: Array<{ agentId: string publicKey: string alias?: string @@ -47,9 +47,9 @@ export interface PeerAnnouncement { }> } -// ── Peer record types ─────────────────────────────────────────────────────── +// ── Agent record types ────────────────────────────────────────────────────── -export interface PeerRecord { +export interface AgentRecord { agentId: string publicKey: string alias: string @@ -59,13 +59,28 @@ export interface PeerRecord { lastSeen: number } -export interface DiscoveredPeerRecord extends PeerRecord { +export interface DiscoveredAgentRecord extends AgentRecord { tofuCachedAt?: number // timestamp when TOFU binding was first established discoveredVia?: string source: "manual" | "bootstrap" | "gossip" | "gateway" version?: string } +// ── World record types ────────────────────────────────────────────────────── + +export interface WorldRecord { + worldId: string + slug: string + publicKey: string + endpoints: Endpoint[] + lastSeen: number +} + +export interface DiscoveredWorldRecord extends WorldRecord { + source: "gateway" | "gossip" | "manual" + discoveredVia?: string +} + // ── Plugin config ─────────────────────────────────────────────────────────── export interface PluginConfig { diff --git a/src/world-db.ts b/src/world-db.ts new file mode 100644 index 0000000..45d904f --- /dev/null +++ b/src/world-db.ts @@ -0,0 +1,103 @@ +import * as fs from "fs" +import * as path from "path" +import { DiscoveredWorldRecord, Endpoint } from "./types" + +interface WorldStore { + version: number + worlds: Record +} + +let dbPath: string +let store: WorldStore = { version: 1, worlds: {} } +let _saveTimer: ReturnType | null = null +const SAVE_DEBOUNCE_MS = 1000 + +function load(): void { + if (fs.existsSync(dbPath)) { + try { + const raw = JSON.parse(fs.readFileSync(dbPath, "utf-8")) + store = { + version: 1, + worlds: typeof raw?.worlds === "object" && raw.worlds ? raw.worlds : {}, + } + } catch { + store = { version: 1, worlds: {} } + } + } else { + store = { version: 1, worlds: {} } + } +} + +function saveImmediate(): void { + if (_saveTimer) { + clearTimeout(_saveTimer) + _saveTimer = null + } + fs.writeFileSync(dbPath, JSON.stringify(store, null, 2)) +} + +function save(): void { + if (_saveTimer) return + _saveTimer = setTimeout(() => { + _saveTimer = null + fs.writeFileSync(dbPath, JSON.stringify(store, null, 2)) + }, SAVE_DEBOUNCE_MS) +} + +export function initWorldDb(dataDir: string): void { + dbPath = path.join(dataDir, "worlds.json") + load() +} + +export function flushWorldDb(): void { + if (_saveTimer) saveImmediate() +} + +export function listWorlds(): DiscoveredWorldRecord[] { + return Object.values(store.worlds).sort((a, b) => b.lastSeen - a.lastSeen) +} + +export function getWorld(worldId: string): DiscoveredWorldRecord | null { + return store.worlds[worldId] ?? null +} + +export function getWorldBySlug(slug: string): DiscoveredWorldRecord | null { + return listWorlds().find((world) => world.slug === slug) ?? null +} + +export function upsertWorld( + worldId: string, + opts: { + slug?: string + publicKey?: string + endpoints?: Endpoint[] + lastSeen?: number + source?: "gateway" | "gossip" | "manual" + discoveredVia?: string + } = {} +): void { + const now = Date.now() + const existing = store.worlds[worldId] + const slug = opts.slug ?? existing?.slug ?? worldId + + store.worlds[worldId] = { + worldId, + slug, + publicKey: opts.publicKey ?? existing?.publicKey ?? "", + endpoints: opts.endpoints ?? existing?.endpoints ?? [], + lastSeen: opts.lastSeen ?? existing?.lastSeen ?? now, + source: opts.source ?? existing?.source ?? "gossip", + discoveredVia: opts.discoveredVia ?? existing?.discoveredVia, + } + + if (opts.lastSeen === undefined) { + store.worlds[worldId].lastSeen = now + } + + save() +} + +export function removeWorldRecord(worldId: string): void { + delete store.worlds[worldId] + saveImmediate() +} diff --git a/test/agentid-peer-db.test.mjs b/test/agentid-agent-db.test.mjs similarity index 65% rename from test/agentid-peer-db.test.mjs rename to test/agentid-agent-db.test.mjs index bbc8a7f..ac08c56 100644 --- a/test/agentid-peer-db.test.mjs +++ b/test/agentid-agent-db.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict" import * as fs from "fs" import * as path from "path" import * as os from "os" -import { initDb, upsertPeer, upsertDiscoveredPeer, listPeers, getPeer, removePeer, flushDb, tofuVerifyAndCache, tofuReplaceKey, setTofuTtl, getPeerIds, pruneStale, getEndpointAddress, findPeersByCapability } from "../dist/peer-db.js" +import { initDb, upsertAgent, upsertDiscoveredAgent, listAgents, getAgent, removeAgent, flushDb, tofuVerifyAndCache, tofuReplaceKey, setTofuTtl, getAgentIds, pruneStale, getEndpointAddress, findAgentsByCapability } from "../dist/agent-db.js" import { generateIdentity } from "../dist/identity.js" let tmpDir @@ -19,38 +19,38 @@ afterEach(() => { }) describe("peer-db (agentId-keyed)", () => { - it("upsertDiscoveredPeer stores by agentId", () => { + it("upsertDiscoveredAgent stores by agentId", () => { const id = generateIdentity() - upsertDiscoveredPeer(id.agentId, id.publicKey, { source: "gateway" }) - const peer = getPeer(id.agentId) + upsertDiscoveredAgent(id.agentId, id.publicKey, { source: "gateway" }) + const peer = getAgent(id.agentId) assert.ok(peer) assert.equal(peer.agentId, id.agentId) assert.equal(peer.publicKey, id.publicKey) }) - it("getPeerIds returns agentIds", () => { + it("getAgentIds returns agentIds", () => { const id1 = generateIdentity() const id2 = generateIdentity() - upsertDiscoveredPeer(id1.agentId, id1.publicKey, { source: "gateway" }) - upsertDiscoveredPeer(id2.agentId, id2.publicKey, { source: "gossip" }) - const ids = getPeerIds() + upsertDiscoveredAgent(id1.agentId, id1.publicKey, { source: "gateway" }) + upsertDiscoveredAgent(id2.agentId, id2.publicKey, { source: "gossip" }) + const ids = getAgentIds() assert.ok(ids.includes(id1.agentId)) assert.ok(ids.includes(id2.agentId)) }) - it("upsertPeer works with agentId", () => { - upsertPeer("abcdef1234567890", "Alice") - const peer = getPeer("abcdef1234567890") + it("upsertAgent works with agentId", () => { + upsertAgent("abcdef1234567890", "Alice") + const peer = getAgent("abcdef1234567890") assert.ok(peer) assert.equal(peer.alias, "Alice") }) - it("removePeer works with agentId", () => { + it("removeAgent works with agentId", () => { const id = generateIdentity() - upsertDiscoveredPeer(id.agentId, id.publicKey, {}) - assert.ok(getPeer(id.agentId)) - removePeer(id.agentId) - assert.equal(getPeer(id.agentId), null) + upsertDiscoveredAgent(id.agentId, id.publicKey, {}) + assert.ok(getAgent(id.agentId)) + removeAgent(id.agentId) + assert.equal(getAgent(id.agentId), null) }) it("TOFU: tofuVerifyAndCache accepts first key", () => { @@ -68,24 +68,24 @@ describe("peer-db (agentId-keyed)", () => { it("pruneStale removes old peers but protects manual", () => { const id1 = generateIdentity() const id2 = generateIdentity() - upsertDiscoveredPeer(id1.agentId, id1.publicKey, { + upsertDiscoveredAgent(id1.agentId, id1.publicKey, { source: "gossip", lastSeen: 1000, }) - upsertPeer(id2.agentId, "Manual") + upsertAgent(id2.agentId, "Manual") - assert.equal(listPeers().length, 2) + assert.equal(listAgents().length, 2) const pruned = pruneStale(1000) assert.ok(pruned >= 1) - assert.equal(getPeer(id1.agentId), null) - assert.ok(getPeer(id2.agentId)) + assert.equal(getAgent(id1.agentId), null) + assert.ok(getAgent(id2.agentId)) }) it("TOFU: tofuCachedAt is set on first cache", () => { const id = generateIdentity() const before = Date.now() tofuVerifyAndCache(id.agentId, id.publicKey) - const peer = getPeer(id.agentId) + const peer = getAgent(id.agentId) assert.ok(peer) assert.ok(typeof peer.tofuCachedAt === "number") assert.ok(peer.tofuCachedAt >= before) @@ -102,12 +102,12 @@ describe("peer-db (agentId-keyed)", () => { assert.equal(tofuVerifyAndCache(id1.agentId, id1.publicKey), true) // Backdate tofuCachedAt to simulate expiry - const peer = getPeer(id1.agentId) + const peer = getAgent(id1.agentId) peer.tofuCachedAt = Date.now() - 100 // 100ms ago, well past 1ms TTL // A different key should now be accepted assert.equal(tofuVerifyAndCache(id1.agentId, id2.publicKey), true) - const updated = getPeer(id1.agentId) + const updated = getAgent(id1.agentId) assert.equal(updated.publicKey, id2.publicKey) // Restore default TTL @@ -137,7 +137,7 @@ describe("peer-db (agentId-keyed)", () => { tofuVerifyAndCache(id1.agentId, id1.publicKey) tofuReplaceKey(id1.agentId, id2.publicKey) - const peer = getPeer(id1.agentId) + const peer = getAgent(id1.agentId) assert.equal(peer.publicKey, id2.publicKey) assert.ok(peer.tofuCachedAt) @@ -148,7 +148,7 @@ describe("peer-db (agentId-keyed)", () => { it("tofuReplaceKey creates new record if peer not found", () => { const id = generateIdentity() tofuReplaceKey(id.agentId, id.publicKey) - const peer = getPeer(id.agentId) + const peer = getAgent(id.agentId) assert.ok(peer) assert.equal(peer.publicKey, id.publicKey) assert.ok(peer.tofuCachedAt) @@ -156,36 +156,36 @@ describe("peer-db (agentId-keyed)", () => { it("getEndpointAddress returns best address for transport", () => { const id = generateIdentity() - upsertDiscoveredPeer(id.agentId, id.publicKey, { + upsertDiscoveredAgent(id.agentId, id.publicKey, { endpoints: [ { transport: "tcp", address: "10.0.0.1", port: 8099, priority: 1, ttl: 86400 }, { transport: "quic", address: "1.2.3.4", port: 8098, priority: 10, ttl: 3600 }, ], }) - const peer = getPeer(id.agentId) + const peer = getAgent(id.agentId) assert.equal(getEndpointAddress(peer, "tcp"), "10.0.0.1") assert.equal(getEndpointAddress(peer, "quic"), "1.2.3.4") assert.equal(getEndpointAddress(peer, "tailscale"), null) }) }) -describe("findPeersByCapability", () => { +describe("findAgentsByCapability", () => { it("exact match returns peer with that capability", () => { const id = generateIdentity() - upsertDiscoveredPeer(id.agentId, id.publicKey, { capabilities: ["world:pixel-city"] }) - const results = findPeersByCapability("world:pixel-city") + upsertDiscoveredAgent(id.agentId, id.publicKey, { capabilities: ["chat:dm"] }) + const results = findAgentsByCapability("chat:dm") assert.equal(results.length, 1) assert.equal(results[0].agentId, id.agentId) }) - it("prefix match returns all world:* peers", () => { + it("prefix match returns all chat:* peers", () => { const a = generateIdentity() const b = generateIdentity() const c = generateIdentity() - upsertDiscoveredPeer(a.agentId, a.publicKey, { capabilities: ["world:pixel-city"] }) - upsertDiscoveredPeer(b.agentId, b.publicKey, { capabilities: ["world:dungeon"] }) - upsertDiscoveredPeer(c.agentId, c.publicKey, { capabilities: ["chat"] }) - const results = findPeersByCapability("world:") + upsertDiscoveredAgent(a.agentId, a.publicKey, { capabilities: ["chat:dm"] }) + upsertDiscoveredAgent(b.agentId, b.publicKey, { capabilities: ["chat:group"] }) + upsertDiscoveredAgent(c.agentId, c.publicKey, { capabilities: ["chat"] }) + const results = findAgentsByCapability("chat:") assert.equal(results.length, 2) assert.ok(results.some((p) => p.agentId === a.agentId)) assert.ok(results.some((p) => p.agentId === b.agentId)) @@ -193,13 +193,19 @@ describe("findPeersByCapability", () => { it("returns empty array when no match", () => { const id = generateIdentity() - upsertDiscoveredPeer(id.agentId, id.publicKey, { capabilities: ["chat"] }) - assert.deepEqual(findPeersByCapability("world:"), []) + upsertDiscoveredAgent(id.agentId, id.publicKey, { capabilities: ["chat"] }) + assert.deepEqual(findAgentsByCapability("chat:"), []) + }) + + it("world capabilities are ignored in agent db", () => { + const id = generateIdentity() + upsertDiscoveredAgent(id.agentId, id.publicKey, { capabilities: ["world:pixel-city"] }) + assert.deepEqual(findAgentsByCapability("world:"), []) }) it("peer with no capabilities is not matched", () => { const id = generateIdentity() - upsertDiscoveredPeer(id.agentId, id.publicKey, {}) - assert.deepEqual(findPeersByCapability("world:"), []) + upsertDiscoveredAgent(id.agentId, id.publicKey, {}) + assert.deepEqual(findAgentsByCapability("chat:"), []) }) }) diff --git a/test/base58.test.mjs b/test/base58.test.mjs index f7e2311..4d7b570 100644 --- a/test/base58.test.mjs +++ b/test/base58.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" import { base58Encode, deriveDidKey, toPublicKeyMultibase } from "../packages/agent-world-sdk/dist/identity.js" -import { base58Decode } from "../packages/agent-world-sdk/dist/peer-protocol.js" +import { base58Decode } from "../packages/agent-world-sdk/dist/agent-protocol.js" const encodeCases = [ { bytes: [0], encoded: "1" }, diff --git a/test/canonicalize.test.mjs b/test/canonicalize.test.mjs index b42d2e9..0c03a76 100644 --- a/test/canonicalize.test.mjs +++ b/test/canonicalize.test.mjs @@ -22,7 +22,7 @@ describe("canonicalize", () => { it("sorts keys inside arrays of objects", () => { const input = { - peers: [ + agents: [ { agentId: "aabbcc01", publicKey: "pk1", lastSeen: 100 }, { lastSeen: 200, agentId: "aabbcc02", publicKey: "pk2" }, ], @@ -41,8 +41,8 @@ describe("canonicalize", () => { }); it("produces identical serialization regardless of key insertion order", () => { - const a = { from: "aabbcc01", publicKey: "pk", timestamp: 1, peers: [{ agentId: "x", lastSeen: 1 }] }; - const b = { peers: [{ lastSeen: 1, agentId: "x" }], timestamp: 1, publicKey: "pk", from: "aabbcc01" }; + const a = { from: "aabbcc01", publicKey: "pk", timestamp: 1, agents: [{ agentId: "x", lastSeen: 1 }] }; + const b = { agents: [{ lastSeen: 1, agentId: "x" }], timestamp: 1, publicKey: "pk", from: "aabbcc01" }; assert.equal( JSON.stringify(canonicalize(a)), JSON.stringify(canonicalize(b)) @@ -63,12 +63,12 @@ describe("signMessage + verifySignature with nested data", () => { assert.equal(verifySignature(pubB64, data, sig), true); }); - it("verifies signature on object with nested peers array", () => { + it("verifies signature on object with nested agents array", () => { const data = { from: "aabbcc01", publicKey: pubB64, timestamp: Date.now(), - peers: [ + agents: [ { agentId: "aabbcc02", publicKey: "pk2", lastSeen: 100 }, { agentId: "aabbcc03", publicKey: "pk3", lastSeen: 200 }, ], @@ -82,13 +82,13 @@ describe("signMessage + verifySignature with nested data", () => { from: "aabbcc01", publicKey: pubB64, timestamp: 999, - peers: [{ agentId: "aabbcc02", publicKey: "pk2", lastSeen: 100 }], + agents: [{ agentId: "aabbcc02", publicKey: "pk2", lastSeen: 100 }], }; const sig = signMessage(privB64, dataSign); // Verify with different key insertion order const dataVerify = { - peers: [{ lastSeen: 100, agentId: "aabbcc02", publicKey: "pk2" }], + agents: [{ lastSeen: 100, agentId: "aabbcc02", publicKey: "pk2" }], timestamp: 999, publicKey: pubB64, from: "aabbcc01", @@ -101,13 +101,13 @@ describe("signMessage + verifySignature with nested data", () => { from: "aabbcc01", publicKey: pubB64, timestamp: 999, - peers: [{ agentId: "aabbcc02", publicKey: "pk2", lastSeen: 100 }], + agents: [{ agentId: "aabbcc02", publicKey: "pk2", lastSeen: 100 }], }; const sig = signMessage(privB64, data); const tampered = { ...data, - peers: [{ agentId: "evil0000", publicKey: "pk2", lastSeen: 100 }], + agents: [{ agentId: "evil0000", publicKey: "pk2", lastSeen: 100 }], }; assert.equal(verifySignature(pubB64, tampered, sig), false); }); diff --git a/test/gateway-announce-default.test.mjs b/test/gateway-announce-default.test.mjs index 4c45794..00e964e 100644 --- a/test/gateway-announce-default.test.mjs +++ b/test/gateway-announce-default.test.mjs @@ -33,7 +33,7 @@ test("startGatewayAnnounce defaults to the local gateway HTTP port", async () => return { ok: true, async json() { - return { peers: [] } + return { agents: [] } }, } } @@ -54,7 +54,7 @@ test("startGatewayAnnounce defaults to the local gateway HTTP port", async () => publicAddr: null, publicPort: 8099, capabilities: ["world"], - peerDb: { + agentDb: { size: 0, upsert() {}, }, diff --git a/test/gateway-heartbeat.test.mjs b/test/gateway-heartbeat.test.mjs index d2da8d1..28d0a05 100644 --- a/test/gateway-heartbeat.test.mjs +++ b/test/gateway-heartbeat.test.mjs @@ -29,6 +29,19 @@ function signAnnounce(kp, worldId) { return { ...payload, signature } } +function signAgentAnnounce(kp, capabilities = ["chat"]) { + const payload = { + from: kp.agentId, + publicKey: kp.publicKey, + alias: `Agent ${kp.agentId.slice(0, 8)}`, + endpoints: [{ transport: "tcp", address: "10.0.0.1", port: 8099, priority: 1 }], + capabilities, + timestamp: Date.now(), + } + const signature = signWithDomainSeparator(DOMAIN_SEPARATORS.ANNOUNCE, payload, kp.secretKey) + return { ...payload, signature } +} + function signHeartbeat(kp) { const ts = Date.now() const payload = { agentId: kp.agentId, ts } @@ -71,7 +84,7 @@ describe("Gateway /agents/:agentId/heartbeat", () => { const kp = makeKeypair() // First announce so the agent exists - const ann = signAnnounce(kp, "hb-sig-test") + const ann = signAgentAnnounce(kp) const annResp = await app.inject({ method: "POST", url: "/agents", payload: ann }) assert.equal(annResp.statusCode, 200) @@ -118,12 +131,12 @@ describe("Gateway /agents/:agentId/heartbeat", () => { const kp = makeKeypair() // Announce - const ann = signAnnounce(kp, "hb-lastseen") + const ann = signAgentAnnounce(kp) await app.inject({ method: "POST", url: "/agents", payload: ann }) // Record initial lastSeen const before = JSON.parse( - (await app.inject({ method: "GET", url: "/worlds/hb-lastseen" })).body + (await app.inject({ method: "GET", url: `/agents/${kp.agentId}` })).body ) const initialLastSeen = before.lastSeen @@ -139,7 +152,7 @@ describe("Gateway /agents/:agentId/heartbeat", () => { // Verify lastSeen updated const afterResp = JSON.parse( - (await app.inject({ method: "GET", url: "/worlds/hb-lastseen" })).body + (await app.inject({ method: "GET", url: `/agents/${kp.agentId}` })).body ) assert.ok(afterResp.lastSeen >= initialLastSeen, "lastSeen should be updated after heartbeat") }) @@ -171,9 +184,8 @@ describe("Gateway /worlds/:worldId/heartbeat", () => { it("returns 403 for invalid signature", async () => { const kp = makeKeypair() - const worldId = "world-hb-sig-test" - - const ann = signAnnounce(kp, worldId) + const worldId = kp.agentId + const ann = signAnnounce(kp, "world-hb-sig-test") const annResp = await app.inject({ method: "POST", url: "/agents", payload: ann }) assert.equal(annResp.statusCode, 200) @@ -192,9 +204,9 @@ describe("Gateway /worlds/:worldId/heartbeat", () => { it("updates lastSeen in registry", async () => { const kp = makeKeypair() - const worldId = "world-hb-lastseen" + const worldId = kp.agentId - const ann = signAnnounce(kp, worldId) + const ann = signAnnounce(kp, "world-hb-lastseen") await app.inject({ method: "POST", url: "/agents", payload: ann }) const before = JSON.parse( @@ -234,11 +246,11 @@ describe("Gateway stale TTL at 90s", () => { it("prunes agents after stale TTL", async () => { const kp = makeKeypair() - const ann = signAnnounce(kp, "ttl-test") + const ann = signAgentAnnounce(kp) await app.inject({ method: "POST", url: "/agents", payload: ann }) // Agent should be visible - let resp = await app.inject({ method: "GET", url: "/worlds/ttl-test" }) + let resp = await app.inject({ method: "GET", url: `/agents/${kp.agentId}` }) assert.equal(resp.statusCode, 200) // Wait for TTL to expire @@ -261,7 +273,7 @@ describe("Gateway stale TTL at 90s", () => { try { const kp = makeKeypair() - const ann = signAnnounce(kp, "keep-alive") + const ann = signAgentAnnounce(kp) await app2.inject({ method: "POST", url: "/agents", payload: ann }) // Send heartbeat before TTL expires @@ -274,8 +286,8 @@ describe("Gateway stale TTL at 90s", () => { await new Promise((r) => setTimeout(r, 50)) // Agent should still be visible - const worldResp = await app2.inject({ method: "GET", url: "/worlds/keep-alive" }) - assert.equal(worldResp.statusCode, 200, "Agent should still be visible after heartbeat") + const agentResp = await app2.inject({ method: "GET", url: `/agents/${kp.agentId}` }) + assert.equal(agentResp.statusCode, 200, "Agent should still be visible after heartbeat") } finally { await stop2() fs.rmSync(tmpDir2, { recursive: true }) diff --git a/test/gateway-world-record.test.mjs b/test/gateway-world-record.test.mjs index 98ace15..92a51ab 100644 --- a/test/gateway-world-record.test.mjs +++ b/test/gateway-world-record.test.mjs @@ -55,18 +55,18 @@ describe("Gateway /worlds/:worldId", () => { it("GET /worlds/:worldId includes publicKey after announce", async () => { const kp = makeKeypair() - const worldId = "pixel-city" + const slug = "pixel-city" - const annResp = await announce(kp, worldId) + const annResp = await announce(kp, slug) assert.equal(annResp.statusCode, 200, `announce failed: ${annResp.body}`) - const resp = await app.inject({ method: "GET", url: `/worlds/${worldId}` }) + const resp = await app.inject({ method: "GET", url: `/worlds/${kp.agentId}` }) assert.equal(resp.statusCode, 200) const body = JSON.parse(resp.body) - assert.equal(body.worldId, worldId) + assert.equal(body.worldId, kp.agentId) + assert.equal(body.slug, slug) assert.equal(body.publicKey, kp.publicKey, "publicKey must be present in /worlds/:worldId response") - assert.equal(body.agentId, kp.agentId) }) it("GET /worlds/:worldId publicKey matches the announcing agent", async () => { @@ -76,8 +76,8 @@ describe("Gateway /worlds/:worldId", () => { await announce(kp1, "arena-alpha") await announce(kp2, "arena-beta") - const r1 = JSON.parse((await app.inject({ method: "GET", url: "/worlds/arena-alpha" })).body) - const r2 = JSON.parse((await app.inject({ method: "GET", url: "/worlds/arena-beta" })).body) + const r1 = JSON.parse((await app.inject({ method: "GET", url: `/worlds/${kp1.agentId}` })).body) + const r2 = JSON.parse((await app.inject({ method: "GET", url: `/worlds/${kp2.agentId}` })).body) assert.equal(r1.publicKey, kp1.publicKey) assert.equal(r2.publicKey, kp2.publicKey) @@ -91,31 +91,30 @@ describe("Gateway /worlds/:worldId", () => { it("DELETE /worlds/:worldId removes a known world", async () => { const kp = makeKeypair() - const worldId = "delete-me" + const slug = "delete-me" - await announce(kp, worldId) - const before = await app.inject({ method: "GET", url: `/worlds/${worldId}` }) + await announce(kp, slug) + const before = await app.inject({ method: "GET", url: `/worlds/${kp.agentId}` }) assert.equal(before.statusCode, 200) - const del = await app.inject({ method: "DELETE", url: `/worlds/${worldId}` }) + const del = await app.inject({ method: "DELETE", url: `/worlds/${kp.agentId}` }) assert.equal(del.statusCode, 200) const body = JSON.parse(del.body) assert.equal(body.ok, true) assert.equal(body.removed, 1) - const after = await app.inject({ method: "GET", url: `/worlds/${worldId}` }) + const after = await app.inject({ method: "GET", url: `/worlds/${kp.agentId}` }) assert.equal(after.statusCode, 404) }) it("DELETE /worlds/:worldId returns 403 when GATEWAY_ADMIN_KEY is set and token is missing", async () => { const kp = makeKeypair() - const worldId = "protected-world" - await announce(kp, worldId) + await announce(kp, "protected-world") const prev = process.env.GATEWAY_ADMIN_KEY process.env.GATEWAY_ADMIN_KEY = "secret-test-key" try { - const resp = await app.inject({ method: "DELETE", url: `/worlds/${worldId}` }) + const resp = await app.inject({ method: "DELETE", url: `/worlds/${kp.agentId}` }) assert.equal(resp.statusCode, 403) } finally { if (prev === undefined) delete process.env.GATEWAY_ADMIN_KEY @@ -125,15 +124,14 @@ describe("Gateway /worlds/:worldId", () => { it("DELETE /worlds/:worldId succeeds with correct GATEWAY_ADMIN_KEY bearer token", async () => { const kp = makeKeypair() - const worldId = "protected-world-2" - await announce(kp, worldId) + await announce(kp, "protected-world-2") const prev = process.env.GATEWAY_ADMIN_KEY process.env.GATEWAY_ADMIN_KEY = "secret-test-key" try { const resp = await app.inject({ method: "DELETE", - url: `/worlds/${worldId}`, + url: `/worlds/${kp.agentId}`, headers: { authorization: "Bearer secret-test-key" }, }) assert.equal(resp.statusCode, 200) diff --git a/test/gateway-worlds.test.mjs b/test/gateway-worlds.test.mjs index 4e4c6aa..1322ecf 100644 --- a/test/gateway-worlds.test.mjs +++ b/test/gateway-worlds.test.mjs @@ -11,28 +11,28 @@ describe("Gateway GET /worlds", () => { before(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gateway-worlds-")) - // Seed registry with a world agent so /worlds has data after loadRegistry() + const worldId = "aw:sha256:aaaa" const registry = { version: 1, savedAt: Date.now(), - agents: { - "aw:sha256:aaaa": { - agentId: "aw:sha256:aaaa", + worlds: { + [worldId]: { + worldId, + slug: "test-world", publicKey: "dGVzdA==", - alias: "Test World", endpoints: [{ transport: "tcp", address: "10.0.0.1", port: 8099, priority: 1 }], - capabilities: ["world:test-world"], lastSeen: Date.now(), }, }, } - fs.writeFileSync(path.join(tmpDir, "registry.json"), JSON.stringify(registry)) + fs.writeFileSync(path.join(tmpDir, "worlds-registry.json"), JSON.stringify(registry)) + fs.writeFileSync(path.join(tmpDir, "agents-registry.json"), JSON.stringify({ version: 1, savedAt: Date.now(), agents: {} })) ;({ app, start, stop } = await createGatewayApp({ dataDir: tmpDir, httpPort: 0, staleTtlMs: 60 * 60 * 1000, })) - // start() calls loadRegistry() which reads the seeded file + // start() calls loadRegistries() which reads the seeded files await start() }) @@ -50,8 +50,8 @@ describe("Gateway GET /worlds", () => { assert.equal(body.worlds.length, 1) const world = body.worlds[0] - assert.equal(world.worldId, "test-world") - assert.equal(world.name, "Test World") + assert.equal(world.worldId, "aw:sha256:aaaa") + assert.equal(world.slug, "test-world") assert.equal(world.reachable, true) assert.ok(Array.isArray(world.endpoints), "endpoints must be an array in /worlds response") assert.equal(world.endpoints.length, 1) @@ -84,7 +84,7 @@ describe("Gateway GET /worlds", () => { const schemas = Object.keys(spec.components?.schemas ?? {}).sort() assert.ok(schemas.includes("WorldSummary"), "must include WorldSummary schema") assert.ok(schemas.includes("Endpoint"), "must include Endpoint schema") - assert.ok(schemas.includes("PeerRecord"), "must include PeerRecord schema") + assert.ok(schemas.includes("AgentRecord"), "must include AgentRecord schema") for (const [route, schemaName] of [ ["/agents", "AnnounceRequest"], diff --git a/test/index-lifecycle.test.mjs b/test/index-lifecycle.test.mjs index 7a8265c..718b3d6 100644 --- a/test/index-lifecycle.test.mjs +++ b/test/index-lifecycle.test.mjs @@ -7,9 +7,10 @@ const require = createRequire(import.meta.url) const MODULE_IDS = [ "../dist/index.js", "../dist/identity.js", - "../dist/peer-db.js", - "../dist/peer-server.js", - "../dist/peer-client.js", + "../dist/agent-db.js", + "../dist/world-db.js", + "../dist/agent-server.js", + "../dist/agent-client.js", "../dist/channel.js", "../dist/transport.js", "../dist/transport-quic.js", @@ -32,14 +33,16 @@ function createHarness({ const fs = require("node:fs") const childProcess = require("node:child_process") const identityMod = require("../dist/identity.js") - const peerDbMod = require("../dist/peer-db.js") - const peerServerMod = require("../dist/peer-server.js") - const peerClientMod = require("../dist/peer-client.js") + const agentDbMod = require("../dist/agent-db.js") + const worldDbMod = require("../dist/world-db.js") + const agentServerMod = require("../dist/agent-server.js") + const agentClientMod = require("../dist/agent-client.js") const channelMod = require("../dist/channel.js") const transportMod = require("../dist/transport.js") const transportQuicMod = require("../dist/transport-quic.js") - const peers = new Map() + const agents = new Map() + const worlds = new Map() const sendCalls = [] const gatewayMessages = [] const timers = new Map() @@ -51,22 +54,28 @@ function createHarness({ existsSync: fs.existsSync, execSync: childProcess.execSync, loadOrCreateIdentity: identityMod.loadOrCreateIdentity, - initDb: peerDbMod.initDb, - listPeers: peerDbMod.listPeers, - getPeer: peerDbMod.getPeer, - flushDb: peerDbMod.flushDb, - getPeerIds: peerDbMod.getPeerIds, - setTofuTtl: peerDbMod.setTofuTtl, - findPeersByCapability: peerDbMod.findPeersByCapability, - upsertDiscoveredPeer: peerDbMod.upsertDiscoveredPeer, - removePeer: peerDbMod.removePeer, - startPeerServer: peerServerMod.startPeerServer, - stopPeerServer: peerServerMod.stopPeerServer, - setSelfMeta: peerServerMod.setSelfMeta, - handleUdpMessage: peerServerMod.handleUdpMessage, - getPeerPingInfo: peerClientMod.getPeerPingInfo, - sendP2PMessage: peerClientMod.sendP2PMessage, - broadcastLeave: peerClientMod.broadcastLeave, + initDb: agentDbMod.initDb, + listAgents: agentDbMod.listAgents, + getAgent: agentDbMod.getAgent, + flushDb: agentDbMod.flushDb, + getAgentIds: agentDbMod.getAgentIds, + setTofuTtl: agentDbMod.setTofuTtl, + findAgentsByCapability: agentDbMod.findAgentsByCapability, + upsertDiscoveredAgent: agentDbMod.upsertDiscoveredAgent, + removeAgent: agentDbMod.removeAgent, + initWorldDb: worldDbMod.initWorldDb, + listWorlds: worldDbMod.listWorlds, + getWorld: worldDbMod.getWorld, + getWorldBySlug: worldDbMod.getWorldBySlug, + upsertWorld: worldDbMod.upsertWorld, + flushWorldDb: worldDbMod.flushWorldDb, + startAgentServer: agentServerMod.startAgentServer, + stopAgentServer: agentServerMod.stopAgentServer, + setSelfMeta: agentServerMod.setSelfMeta, + handleUdpMessage: agentServerMod.handleUdpMessage, + getAgentPingInfo: agentClientMod.getAgentPingInfo, + sendP2PMessage: agentClientMod.sendP2PMessage, + broadcastLeave: agentClientMod.broadcastLeave, wireInboundToGateway: channelMod.wireInboundToGateway, TransportManager: transportMod.TransportManager, UDPTransport: transportQuicMod.UDPTransport, @@ -86,16 +95,16 @@ function createHarness({ privateKey: Buffer.alloc(32).toString("base64"), }) - peerDbMod.initDb = () => {} - peerDbMod.listPeers = () => [...peers.values()] - peerDbMod.getPeer = (agentId) => peers.get(agentId) ?? null - peerDbMod.flushDb = () => {} - peerDbMod.getPeerIds = () => [...peers.keys()] - peerDbMod.setTofuTtl = () => {} - peerDbMod.findPeersByCapability = (capability) => - [...peers.values()].filter((peer) => peer.capabilities?.includes(capability)) - peerDbMod.upsertDiscoveredPeer = (agentId, publicKey, opts = {}) => { - const existing = peers.get(agentId) ?? { + agentDbMod.initDb = () => {} + agentDbMod.listAgents = () => [...agents.values()] + agentDbMod.getAgent = (agentId) => agents.get(agentId) ?? null + agentDbMod.flushDb = () => {} + agentDbMod.getAgentIds = () => [...agents.keys()] + agentDbMod.setTofuTtl = () => {} + agentDbMod.findAgentsByCapability = (capability) => + [...agents.values()].filter((agent) => agent.capabilities?.includes(capability)) + agentDbMod.upsertDiscoveredAgent = (agentId, publicKey, opts = {}) => { + const existing = agents.get(agentId) ?? { agentId, publicKey: "", alias: "", @@ -103,7 +112,7 @@ function createHarness({ capabilities: [], source: "gossip", } - peers.set(agentId, { + agents.set(agentId, { ...existing, publicKey: existing.publicKey || publicKey, alias: opts.alias ?? existing.alias, @@ -112,22 +121,49 @@ function createHarness({ source: opts.source ?? existing.source, }) } - peerDbMod.removePeer = (agentId) => { - peers.delete(agentId) + agentDbMod.removeAgent = (agentId) => { + agents.delete(agentId) } - peerServerMod.startPeerServer = async () => {} - peerServerMod.stopPeerServer = async () => {} - peerServerMod.setSelfMeta = () => {} - peerServerMod.handleUdpMessage = () => {} + worldDbMod.initWorldDb = () => {} + worldDbMod.listWorlds = () => [...worlds.values()] + worldDbMod.getWorld = (worldId) => worlds.get(worldId) ?? null + worldDbMod.getWorldBySlug = (slug) => + [...worlds.values()].find((world) => world.slug === slug) ?? null + worldDbMod.upsertWorld = (worldId, opts = {}) => { + const existing = worlds.get(worldId) ?? { + worldId, + slug: worldId, + publicKey: "", + endpoints: [], + lastSeen: 0, + source: "gossip", + } + worlds.set(worldId, { + ...existing, + ...opts, + worldId, + slug: opts.slug ?? existing.slug, + publicKey: opts.publicKey ?? existing.publicKey, + endpoints: opts.endpoints ?? existing.endpoints, + lastSeen: opts.lastSeen ?? Date.now(), + source: opts.source ?? existing.source, + }) + } + worldDbMod.flushWorldDb = () => {} + + agentServerMod.startAgentServer = async () => {} + agentServerMod.stopAgentServer = async () => {} + agentServerMod.setSelfMeta = () => {} + agentServerMod.handleUdpMessage = () => {} - peerClientMod.getPeerPingInfo = async () => pingInfo - peerClientMod.sendP2PMessage = async (_identity, targetAddr, event, content, port, timeoutMs, opts) => { + agentClientMod.getAgentPingInfo = async () => pingInfo + agentClientMod.sendP2PMessage = async (_identity, targetAddr, event, content, port, timeoutMs, opts) => { sendCalls.push({ targetAddr, event, content, port, timeoutMs, opts }) if (event === "world.join") return joinResponse return { ok: true } } - peerClientMod.broadcastLeave = async () => {} + agentClientMod.broadcastLeave = async () => {} channelMod.wireInboundToGateway = () => {} @@ -201,8 +237,9 @@ function createHarness({ register(api) return { - peers, - peerServer: peerServerMod, + agents, + worlds, + agentServer: agentServerMod, sendCalls, gatewayMessages, fetchCalls, @@ -226,22 +263,28 @@ function createHarness({ fs.existsSync = originals.existsSync childProcess.execSync = originals.execSync identityMod.loadOrCreateIdentity = originals.loadOrCreateIdentity - peerDbMod.initDb = originals.initDb - peerDbMod.listPeers = originals.listPeers - peerDbMod.getPeer = originals.getPeer - peerDbMod.flushDb = originals.flushDb - peerDbMod.getPeerIds = originals.getPeerIds - peerDbMod.setTofuTtl = originals.setTofuTtl - peerDbMod.findPeersByCapability = originals.findPeersByCapability - peerDbMod.upsertDiscoveredPeer = originals.upsertDiscoveredPeer - peerDbMod.removePeer = originals.removePeer - peerServerMod.startPeerServer = originals.startPeerServer - peerServerMod.stopPeerServer = originals.stopPeerServer - peerServerMod.setSelfMeta = originals.setSelfMeta - peerServerMod.handleUdpMessage = originals.handleUdpMessage - peerClientMod.getPeerPingInfo = originals.getPeerPingInfo - peerClientMod.sendP2PMessage = originals.sendP2PMessage - peerClientMod.broadcastLeave = originals.broadcastLeave + agentDbMod.initDb = originals.initDb + agentDbMod.listAgents = originals.listAgents + agentDbMod.getAgent = originals.getAgent + agentDbMod.flushDb = originals.flushDb + agentDbMod.getAgentIds = originals.getAgentIds + agentDbMod.setTofuTtl = originals.setTofuTtl + agentDbMod.findAgentsByCapability = originals.findAgentsByCapability + agentDbMod.upsertDiscoveredAgent = originals.upsertDiscoveredAgent + agentDbMod.removeAgent = originals.removeAgent + worldDbMod.initWorldDb = originals.initWorldDb + worldDbMod.listWorlds = originals.listWorlds + worldDbMod.getWorld = originals.getWorld + worldDbMod.getWorldBySlug = originals.getWorldBySlug + worldDbMod.upsertWorld = originals.upsertWorld + worldDbMod.flushWorldDb = originals.flushWorldDb + agentServerMod.startAgentServer = originals.startAgentServer + agentServerMod.stopAgentServer = originals.stopAgentServer + agentServerMod.setSelfMeta = originals.setSelfMeta + agentServerMod.handleUdpMessage = originals.handleUdpMessage + agentClientMod.getAgentPingInfo = originals.getAgentPingInfo + agentClientMod.sendP2PMessage = originals.sendP2PMessage + agentClientMod.broadcastLeave = originals.broadcastLeave channelMod.wireInboundToGateway = originals.wireInboundToGateway transportMod.TransportManager = originals.TransportManager transportQuicMod.UDPTransport = originals.UDPTransport @@ -295,18 +338,18 @@ describe("plugin lifecycle", () => { const joinCall = harness.sendCalls.find((call) => call.event === "world.join") assert.equal(joinCall?.targetAddr, "203.0.113.10") - const worldPeer = harness.peers.get(worldAgentId) - assert.ok(worldPeer) - assert.deepEqual(worldPeer.endpoints, [ + const worldRecord = harness.worlds.get(worldAgentId) + assert.ok(worldRecord) + assert.deepEqual(worldRecord.endpoints, [ { transport: "tcp", address: "203.0.113.10", port: 9000, priority: 1, ttl: 3600 }, ]) - assert.deepEqual(worldPeer.capabilities, ["world:arena"]) + assert.equal(worldRecord.slug, "arena") await harness.service.stop() const leaveCall = harness.sendCalls.find((call) => call.event === "world.leave") assert.equal(leaveCall?.targetAddr, "203.0.113.10") - assert.deepEqual(leaveCall?.opts?.endpoints, worldPeer.endpoints) + assert.deepEqual(leaveCall?.opts?.endpoints, undefined) } finally { harness.restore() } @@ -332,21 +375,19 @@ describe("plugin lifecycle", () => { ok: true, status: 200, json: async () => ({ - worlds: [{ worldId: "arena", agentId: worldAgentId, name: "Arena", endpoints: [worldEndpoint] }], + worlds: [{ worldId: worldAgentId, slug: "arena", endpoints: [worldEndpoint] }], }), } } - if (requestUrl.endsWith("/worlds/arena")) { + if (requestUrl.endsWith(`/worlds/${encodeURIComponent(worldAgentId)}`)) { return { ok: true, status: 200, json: async () => ({ - world: { - agentId: worldAgentId, - name: "Arena", - publicKey: worldPublicKey, - endpoints: [worldEndpoint], - }, + worldId: worldAgentId, + slug: "arena", + publicKey: worldPublicKey, + endpoints: [worldEndpoint], }), } } @@ -361,9 +402,9 @@ describe("plugin lifecycle", () => { const listed = await listWorlds.execute("tool-list", {}) assert.equal(listed.isError, undefined) - const discoveredPeer = harness.peers.get(worldAgentId) - assert.ok(discoveredPeer, "peer should be discovered after list_worlds") - assert.deepEqual(discoveredPeer.endpoints, [worldEndpoint], "endpoints should be populated from /worlds") + const discoveredWorld = harness.worlds.get(worldAgentId) + assert.ok(discoveredWorld, "world should be discovered after list_worlds") + assert.deepEqual(discoveredWorld.endpoints, [worldEndpoint], "endpoints should be populated from /worlds") const joinWorld = harness.tools.get("join_world") const joined = await joinWorld.execute("tool-join", { world_id: "arena" }) @@ -371,12 +412,12 @@ describe("plugin lifecycle", () => { const joinCall = harness.sendCalls.find((call) => call.event === "world.join") assert.equal(joinCall?.targetAddr, "203.0.113.10") - assert.ok(harness.fetchCalls.some(([requestUrl]) => String(requestUrl).endsWith("/worlds/arena"))) + assert.ok(harness.fetchCalls.some(([requestUrl]) => String(requestUrl).endsWith(`/worlds/${encodeURIComponent(worldAgentId)}`))) - const worldPeer = harness.peers.get(worldAgentId) - assert.ok(worldPeer) - assert.equal(worldPeer.publicKey, worldPublicKey) - assert.deepEqual(worldPeer.endpoints, [worldEndpoint]) + const worldRecord = harness.worlds.get(worldAgentId) + assert.ok(worldRecord) + assert.equal(worldRecord.publicKey, worldPublicKey) + assert.deepEqual(worldRecord.endpoints, [worldEndpoint]) } finally { harness.restore() } @@ -420,12 +461,12 @@ describe("plugin lifecycle", () => { const result = await joinWorld.execute("tool-2", { address: "203.0.113.10:9000" }) assert.equal(result.isError, undefined) - assert.ok(harness.peers.get("aw:sha256:member-1")) + assert.ok(harness.agents.get("aw:sha256:member-1")) await harness.runIntervals() assert.equal(refreshCalls, 1) - assert.equal(harness.peers.get("aw:sha256:member-1"), undefined) - assert.ok(harness.peers.get(worldAgentId)) + assert.equal(harness.agents.get("aw:sha256:member-1"), undefined) + assert.ok(harness.worlds.get(worldAgentId)) await harness.runIntervals() assert.equal(refreshCalls, 1) @@ -472,16 +513,16 @@ describe("plugin lifecycle", () => { const result = await joinWorld.execute("tool-3", { address: "203.0.113.10:9000" }) assert.equal(result.isError, undefined) - assert.equal(harness.peerServer.isCoMember("aw:sha256:member-1"), true) + assert.equal(harness.agentServer.isCoMember("aw:sha256:member-1"), true) await harness.runIntervals() await harness.runIntervals() assert.equal(refreshCalls, 2) - assert.equal(harness.peerServer.isCoMember("aw:sha256:member-1"), true) + assert.equal(harness.agentServer.isCoMember("aw:sha256:member-1"), true) await harness.runIntervals() assert.equal(refreshCalls, 3) - assert.equal(harness.peerServer.isCoMember("aw:sha256:member-1"), false) + assert.equal(harness.agentServer.isCoMember("aw:sha256:member-1"), false) } finally { harness.restore() } @@ -577,10 +618,13 @@ describe("world_action tool", () => { }) it("rejects ambiguous world_id when multiple worlds are joined", async () => { - const worldAgentId = "aw:sha256:world-host" + const worldByAddress = { + "203.0.113.10": "aw:sha256:world-host-1", + "203.0.113.11": "aw:sha256:world-host-2", + } let joinCount = 0 const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, + pingInfo: { ok: true, data: { agentId: worldByAddress["203.0.113.10"], publicKey: MOCK_WORLD_PUB } }, joinResponse: { ok: true, data: { @@ -592,9 +636,14 @@ describe("world_action tool", () => { }) // Override sendP2PMessage to return different worldIds - const peerClientMod = createRequire(import.meta.url)("../dist/peer-client.js") - const origSend = peerClientMod.sendP2PMessage - peerClientMod.sendP2PMessage = async (_identity, targetAddr, event, content, port, timeoutMs, opts) => { + const agentClientMod = createRequire(import.meta.url)("../dist/agent-client.js") + const origPing = agentClientMod.getAgentPingInfo + const origSend = agentClientMod.sendP2PMessage + agentClientMod.getAgentPingInfo = async (targetAddr) => ({ + ok: true, + data: { agentId: worldByAddress[targetAddr], publicKey: MOCK_WORLD_PUB }, + }) + agentClientMod.sendP2PMessage = async (_identity, targetAddr, event, content, port, timeoutMs, opts) => { harness.sendCalls.push({ targetAddr, event, content, port, timeoutMs, opts }) if (event === "world.join") { joinCount++ @@ -615,7 +664,7 @@ describe("world_action tool", () => { const joinWorld = harness.tools.get("join_world") await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - await joinWorld.execute("t-2", { address: "203.0.113.10:9001" }) + await joinWorld.execute("t-2", { address: "203.0.113.11:9001" }) const worldAction = harness.tools.get("world_action") const result = await worldAction.execute("t-3", { action: "say" }) @@ -624,7 +673,8 @@ describe("world_action tool", () => { assert.ok(result.content[0].text.includes("Multiple worlds")) assert.ok(result.content[0].text.includes("Specify world_id")) } finally { - peerClientMod.sendP2PMessage = origSend + agentClientMod.getAgentPingInfo = origPing + agentClientMod.sendP2PMessage = origSend harness.restore() } }) diff --git a/test/key-rotation-sdk.test.mjs b/test/key-rotation-sdk.test.mjs index 4273014..0cb1aa6 100644 --- a/test/key-rotation-sdk.test.mjs +++ b/test/key-rotation-sdk.test.mjs @@ -5,8 +5,8 @@ import Fastify from "fastify" const nacl = (await import("tweetnacl")).default const { - registerPeerRoutes, - PeerDb, + registerAgentRoutes, + AgentDb, PROTOCOL_VERSION, agentIdFromPublicKey, signWithDomainSeparator, @@ -63,14 +63,14 @@ function makeApp(t, opts = {}) { await fastify.close() }) - const peerDb = new PeerDb() - registerPeerRoutes(fastify, { + const agentDb = new AgentDb() + registerAgentRoutes(fastify, { identity: makeIdentity(), - peerDb, + agentDb, ...opts, }) - return { fastify, peerDb } + return { fastify, agentDb } } test("sdk /peer/key-rotation rejects mismatched newAgentId binding with stable 400 error", async (t) => { @@ -121,7 +121,7 @@ test("sdk /peer/key-rotation rejects mismatched newAgentId binding with stable 4 }) test("sdk /peer/key-rotation accepts correctly bound rotations and persists the new key", async (t) => { - const { fastify, peerDb } = makeApp(t) + const { fastify, agentDb } = makeApp(t) const oldKey = makeIdentity() const newKey = makeIdentity() @@ -162,12 +162,12 @@ test("sdk /peer/key-rotation accepts correctly bound rotations and persists the assert.equal(response.statusCode, 200) assert.deepEqual(response.json(), { ok: true }) - assert.equal(peerDb.get(oldKey.agentId)?.publicKey, newKey.pubB64) + assert.equal(agentDb.get(oldKey.agentId)?.publicKey, newKey.pubB64) }) test("sdk /peer/message returns the callback response body on the happy path", async (t) => { const sender = makeIdentity() - const { fastify, peerDb } = makeApp(t, { + const { fastify, agentDb } = makeApp(t, { onMessage: async (_agentId, event, content, reply) => { reply({ ok: true, @@ -190,7 +190,7 @@ test("sdk /peer/message returns the callback response body on the happy path", a event: "chat", echoedContent: { text: "hello" }, }) - assert.equal(peerDb.get(sender.agentId)?.publicKey, sender.pubB64) + assert.equal(agentDb.get(sender.agentId)?.publicKey, sender.pubB64) }) test("sdk /peer/message preserves callback error replies", async (t) => { diff --git a/test/key-rotation.test.mjs b/test/key-rotation.test.mjs index 3825a7a..202dcad 100644 --- a/test/key-rotation.test.mjs +++ b/test/key-rotation.test.mjs @@ -11,8 +11,8 @@ const require = createRequire(import.meta.url) const pkgVersion = require("../package.json").version const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") -const { startPeerServer, stopPeerServer, addWorldMembers } = await import("../dist/peer-server.js") -const { initDb, getPeer } = await import("../dist/peer-db.js") +const { startAgentServer, stopAgentServer, addWorldMembers } = await import("../dist/agent-server.js") +const { initDb, getAgent } = await import("../dist/agent-db.js") const { agentIdFromPublicKey, signWithDomainSeparator, DOMAIN_SEPARATORS, signHttpRequest, canonicalize } = await import("../dist/identity.js") function makeKeypair() { @@ -98,11 +98,11 @@ describe("key rotation endpoint", () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "awn-kr-test-")) initDb(tmpDir) port = 18099 - await startPeerServer(port, { testMode: true }) + await startAgentServer(port, { testMode: true }) }) after(async () => { - await stopPeerServer() + await stopAgentServer() fs.rmSync(tmpDir, { recursive: true }) }) @@ -257,7 +257,7 @@ describe("key rotation endpoint", () => { ), }) assert.equal(validSeedResp.status, 200) - assert.equal(getPeer(oldKey.agentId)?.publicKey, oldKey.publicKey) + assert.equal(getAgent(oldKey.agentId)?.publicKey, oldKey.publicKey) const signable = { agentId: oldKey.agentId, @@ -297,7 +297,7 @@ describe("key rotation endpoint", () => { assert.deepEqual(await resp.json(), { error: "newAgentId does not match newPublicKey", }) - assert.equal(getPeer(oldKey.agentId)?.publicKey, oldKey.publicKey) + assert.equal(getAgent(oldKey.agentId)?.publicKey, oldKey.publicKey) }) test("rejects wrong type/version", async () => { diff --git a/test/request-signing.test.mjs b/test/request-signing.test.mjs index f641c31..5750211 100644 --- a/test/request-signing.test.mjs +++ b/test/request-signing.test.mjs @@ -22,8 +22,8 @@ const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default -const { startPeerServer, stopPeerServer, addWorldMembers } = await import("../dist/peer-server.js") -const { initDb, flushDb } = await import("../dist/peer-db.js") +const { startAgentServer, stopAgentServer, addWorldMembers } = await import("../dist/agent-server.js") +const { initDb, flushDb } = await import("../dist/agent-db.js") const { agentIdFromPublicKey, signMessage, @@ -33,7 +33,7 @@ const { verifyHttpResponseHeaders, computeContentDigest, } = await import("../dist/identity.js") -const { sendP2PMessage } = await import("../dist/peer-client.js") +const { sendP2PMessage } = await import("../dist/agent-client.js") const PORT = 18115 @@ -63,12 +63,12 @@ describe("request signing", () => { senderKey = makeIdentity() dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "awn-reqsign-")) initDb(dataDir) - await startPeerServer(PORT, { identity: selfKey, testMode: true }) + await startAgentServer(PORT, { identity: selfKey, testMode: true }) addWorldMembers("test-world", [senderKey.agentId]) }) after(async () => { - await stopPeerServer() + await stopAgentServer() flushDb() fs.rmSync(dataDir, { recursive: true, force: true }) }) diff --git a/test/response-signing.test.mjs b/test/response-signing.test.mjs index 4c2b560..6edfe1f 100644 --- a/test/response-signing.test.mjs +++ b/test/response-signing.test.mjs @@ -19,8 +19,8 @@ const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default -const { startPeerServer, stopPeerServer } = await import("../dist/peer-server.js") -const { initDb } = await import("../dist/peer-db.js") +const { startAgentServer, stopAgentServer } = await import("../dist/agent-server.js") +const { initDb } = await import("../dist/agent-db.js") const { agentIdFromPublicKey, DOMAIN_SEPARATORS } = await import("../dist/identity.js") const PORT = 18110 @@ -78,11 +78,11 @@ describe("P2a — response signing on /peer/* endpoints", () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "awn-rsig-")) initDb(tmpDir) selfKey = makeKeypair() - await startPeerServer(PORT, { testMode: true, identity: { agentId: selfKey.agentId, publicKey: selfKey.publicKey, privateKey: selfKey.privateKey } }) + await startAgentServer(PORT, { testMode: true, identity: { agentId: selfKey.agentId, publicKey: selfKey.publicKey, privateKey: selfKey.privateKey } }) }) after(async () => { - await stopPeerServer() + await stopAgentServer() fs.rmSync(tmpDir, { recursive: true }) }) diff --git a/test/transport-enforcement.test.mjs b/test/transport-enforcement.test.mjs index e70a403..9512b72 100644 --- a/test/transport-enforcement.test.mjs +++ b/test/transport-enforcement.test.mjs @@ -18,12 +18,12 @@ import * as path from "node:path" const nacl = (await import("tweetnacl")).default const { - startPeerServer, stopPeerServer, + startAgentServer, stopAgentServer, addWorldMembers, removeWorld, isCoMember, clearWorldMembers, handleUdpMessage, onMessage, -} = await import("../dist/peer-server.js") -const { initDb, flushDb } = await import("../dist/peer-db.js") +} = await import("../dist/agent-server.js") +const { initDb, flushDb } = await import("../dist/agent-db.js") const { agentIdFromPublicKey, signHttpRequest, signWithDomainSeparator, DOMAIN_SEPARATORS, canonicalize } = await import("../dist/identity.js") const PORT = 18125 @@ -67,13 +67,13 @@ describe("Transport enforcement — world-scoped isolation", () => { selfKey = makeIdentity() memberKey = makeIdentity() strangerKey = makeIdentity() - await startPeerServer(PORT, { identity: selfKey, testMode: true }) + await startAgentServer(PORT, { identity: selfKey, testMode: true }) addWorldMembers("test-world", [memberKey.agentId]) }) after(async () => { clearWorldMembers() - await stopPeerServer() + await stopAgentServer() flushDb() fs.rmSync(tmpDir, { recursive: true, force: true }) }) diff --git a/test/transport-types.test.mjs b/test/transport-types.test.mjs index ceab1c8..f72dca5 100644 --- a/test/transport-types.test.mjs +++ b/test/transport-types.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" -describe("v2 PeerAnnouncement format", () => { +describe("v2 AgentAnnouncement format", () => { it("announcement has from (agentId) and endpoints with port/ttl", () => { const announcement = { from: "abcdef1234567890", @@ -14,11 +14,11 @@ describe("v2 PeerAnnouncement format", () => { { transport: "quic", address: "1.2.3.4", port: 8098, priority: 10, ttl: 3600 }, { transport: "tcp", address: "10.0.0.1", port: 8099, priority: 1, ttl: 86400 }, ], - peers: [ + agents: [ { agentId: "1234567890abcdef", publicKey: "pk2", - alias: "peer2", + alias: "agent2", lastSeen: Date.now(), endpoints: [{ transport: "quic", address: "5.6.7.8", port: 8098, priority: 10, ttl: 3600 }], }, @@ -29,7 +29,7 @@ describe("v2 PeerAnnouncement format", () => { assert.equal(announcement.endpoints.length, 2) assert.equal(announcement.endpoints[0].port, 8098) assert.equal(announcement.endpoints[0].ttl, 3600) - assert.equal(announcement.peers[0].agentId, "1234567890abcdef") + assert.equal(announcement.agents[0].agentId, "1234567890abcdef") }) it("P2PMessage uses from (agentId), no fromYgg", () => { diff --git a/test/world-db.test.mjs b/test/world-db.test.mjs new file mode 100644 index 0000000..a55565a --- /dev/null +++ b/test/world-db.test.mjs @@ -0,0 +1,58 @@ +import { describe, it, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { + initWorldDb, + flushWorldDb, + listWorlds, + getWorld, + getWorldBySlug, + upsertWorld, + removeWorldRecord, +} from "../dist/world-db.js" + +describe("world-db", () => { + let tmpDir + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "world-db-")) + initWorldDb(tmpDir) + }) + + afterEach(() => { + flushWorldDb() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it("stores and looks up worlds by worldId and slug", () => { + upsertWorld("aw:sha256:world-1", { + slug: "arena", + publicKey: "pub-1", + endpoints: [{ transport: "tcp", address: "203.0.113.10", port: 9000, priority: 1, ttl: 3600 }], + source: "gateway", + }) + + assert.equal(getWorld("aw:sha256:world-1")?.publicKey, "pub-1") + assert.equal(getWorldBySlug("arena")?.worldId, "aw:sha256:world-1") + }) + + it("persists worlds to worlds.json", () => { + upsertWorld("aw:sha256:world-2", { slug: "lobby", publicKey: "pub-2", source: "manual" }) + flushWorldDb() + + initWorldDb(tmpDir) + const worlds = listWorlds() + assert.equal(worlds.length, 1) + assert.equal(worlds[0].worldId, "aw:sha256:world-2") + assert.equal(worlds[0].slug, "lobby") + }) + + it("removes a world record immediately", () => { + upsertWorld("aw:sha256:world-3", { slug: "sandbox" }) + removeWorldRecord("aw:sha256:world-3") + assert.equal(getWorld("aw:sha256:world-3"), null) + }) +}) diff --git a/test/world-members.test.mjs b/test/world-members.test.mjs index 7e57f39..43c0616 100644 --- a/test/world-members.test.mjs +++ b/test/world-members.test.mjs @@ -164,7 +164,7 @@ describe("World-scoped member discovery", () => { ) assert.equal( - announceResp.peers.some((peer) => peer.agentId === announcedAgentId), + announceResp.agents.some((peer) => peer.agentId === announcedAgentId), true ) diff --git a/test/world-state-broadcast.test.mjs b/test/world-state-broadcast.test.mjs index d4dafa1..6a97c6e 100644 --- a/test/world-state-broadcast.test.mjs +++ b/test/world-state-broadcast.test.mjs @@ -144,9 +144,9 @@ describe("World state broadcast delivery", () => { it("creates a known non-member broadcast fixture via peer announce", async () => { const { agentId, announceResp } = await createKnownNonMemberFixture() - assert.equal(Array.isArray(announceResp.peers), true) + assert.equal(Array.isArray(announceResp.agents), true) assert.equal( - announceResp.peers.some( + announceResp.agents.some( (peer) => peer.agentId === agentId && peer.endpoints.some((ep) => ep.port === 29003) @@ -177,9 +177,9 @@ describe("World state broadcast delivery", () => { const { agentId, announceResp } = await createKnownNonMemberFixture() - assert.equal(Array.isArray(announceResp.peers), true) + assert.equal(Array.isArray(announceResp.agents), true) assert.equal( - announceResp.peers.some((peer) => peer.agentId === agentId), + announceResp.agents.some((peer) => peer.agentId === agentId), true ) From b40b058d1b89d37cf0e373bf01b431e79ec503e4 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 18:43:17 +0800 Subject: [PATCH 11/11] =?UTF-8?q?refactor(awn):=20rename=20peer=20?= =?UTF-8?q?=E2=86=92=20agent=20terminology=20across=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - peer_db.rs → agent_db.rs (PeerRecord → AgentRecord, PeerDb → AgentDb) - peers.json → agents.json (store version bumped to 3) - CLI command: awn peers → awn agents - IPC endpoint: /ipc/peers → /ipc/agents - Status output: Known peers → Known agents, Peer port → Listen port - PeersResponse → AgentsResponse, PeersQuery → AgentsQuery - SKILL.md updated to match new terminology Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/awn-cli/skills/SKILL.md | 18 +-- .../awn-cli/src/{peer_db.rs => agent_db.rs} | 126 +++++++++--------- packages/awn-cli/src/daemon.rs | 56 ++++---- packages/awn-cli/src/main.rs | 48 +++---- 4 files changed, 124 insertions(+), 124 deletions(-) rename packages/awn-cli/src/{peer_db.rs => agent_db.rs} (78%) diff --git a/packages/awn-cli/skills/SKILL.md b/packages/awn-cli/skills/SKILL.md index 860e74f..89fb6ee 100644 --- a/packages/awn-cli/skills/SKILL.md +++ b/packages/awn-cli/skills/SKILL.md @@ -46,7 +46,7 @@ Download a prebuilt binary from [GitHub Releases](https://github.com/ReScienceLa ### Start the daemon -The daemon runs a background service that maintains identity, peer DB, and gateway connectivity. +The daemon runs a background service that maintains identity, agent DB, and gateway connectivity. ``` awn daemon start @@ -56,9 +56,9 @@ awn daemon start --data-dir ~/.awn --gateway-url https://gateway.agentworlds.ai ### Basic commands ``` -awn status # agent ID, version, known peers -awn peers # list known peers -awn peers --capability world: # filter by capability prefix +awn status # agent ID, version, known agents +awn agents # list known agents +awn agents --capability world: # filter by capability prefix awn worlds # list available worlds from Gateway ``` @@ -69,7 +69,7 @@ All commands support `--json` for structured, machine-readable output: ``` awn --json status awn --json worlds -awn --json peers --capability world: +awn --json agents --capability world: ``` ## Command Groups @@ -85,8 +85,8 @@ awn --json peers --capability world: | Command | Description | |---------|-------------| -| `status` | Show agent ID, version, peer count, gateway URL | -| `peers` | List known peers (optionally filtered by capability) | +| `status` | Show agent ID, version, agent count, gateway URL | +| `agents` | List known agents (optionally filtered by capability) | | `worlds` | List available worlds from Gateway + local cache | ## For AI Agents @@ -104,10 +104,10 @@ When using this CLI programmatically: ``` awn daemon start → loads/creates Ed25519 identity (~/.awn/identity.json) - → opens peer DB (~/.awn/peers.json) + → opens agent DB (~/.awn/agents.json) → starts IPC server on localhost:8199 -awn status / peers / worlds +awn status / agents / worlds → connects to daemon via localhost HTTP → returns result as human text or JSON ``` diff --git a/packages/awn-cli/src/peer_db.rs b/packages/awn-cli/src/agent_db.rs similarity index 78% rename from packages/awn-cli/src/peer_db.rs rename to packages/awn-cli/src/agent_db.rs index efbc52f..18d5ce9 100644 --- a/packages/awn-cli/src/peer_db.rs +++ b/packages/awn-cli/src/agent_db.rs @@ -15,7 +15,7 @@ pub struct Endpoint { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct PeerRecord { +pub struct AgentRecord { pub agent_id: String, pub public_key: String, #[serde(default)] @@ -39,14 +39,14 @@ pub struct PeerRecord { } #[derive(Debug, Serialize, Deserialize)] -struct PeerStore { +struct AgentStore { version: u32, - peers: HashMap, + agents: HashMap, } -pub struct PeerDb { +pub struct AgentDb { path: PathBuf, - store: PeerStore, + store: AgentStore, tofu_ttl_ms: u64, } @@ -57,28 +57,28 @@ fn now_ms() -> u64 { .as_millis() as u64 } -impl PeerDb { +impl AgentDb { pub fn open(data_dir: &Path) -> Self { fs::create_dir_all(data_dir).ok(); - let path = data_dir.join("peers.json"); + let path = data_dir.join("agents.json"); let store = if path.exists() { match fs::read_to_string(&path) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or(PeerStore { + Ok(raw) => serde_json::from_str(&raw).unwrap_or(AgentStore { version: 2, - peers: HashMap::new(), + agents: HashMap::new(), }), - Err(_) => PeerStore { - version: 2, - peers: HashMap::new(), + Err(_) => AgentStore { + version: 3, + agents: HashMap::new(), }, } } else { - PeerStore { - version: 2, - peers: HashMap::new(), + AgentStore { + version: 3, + agents: HashMap::new(), } }; - PeerDb { + AgentDb { path, store, tofu_ttl_ms: 7 * 24 * 60 * 60 * 1000, @@ -92,17 +92,17 @@ impl PeerDb { } pub fn size(&self) -> usize { - self.store.peers.len() + self.store.agents.len() } - pub fn get(&self, agent_id: &str) -> Option<&PeerRecord> { - self.store.peers.get(agent_id) + pub fn get(&self, agent_id: &str) -> Option<&AgentRecord> { + self.store.agents.get(agent_id) } - pub fn list(&self) -> Vec<&PeerRecord> { - let mut peers: Vec<_> = self.store.peers.values().collect(); - peers.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); - peers + pub fn list(&self) -> Vec<&AgentRecord> { + let mut entries: Vec<_> = self.store.agents.values().collect(); + entries.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); + entries } pub fn upsert( @@ -116,7 +116,7 @@ impl PeerDb { last_seen: Option, ) { let now = now_ms(); - if let Some(existing) = self.store.peers.get_mut(agent_id) { + if let Some(existing) = self.store.agents.get_mut(agent_id) { if existing.public_key.is_empty() && !public_key.is_empty() { existing.public_key = public_key.to_string(); } @@ -141,9 +141,9 @@ impl PeerDb { } } } else { - self.store.peers.insert( + self.store.agents.insert( agent_id.to_string(), - PeerRecord { + AgentRecord { agent_id: agent_id.to_string(), public_key: public_key.to_string(), alias: alias.unwrap_or("").to_string(), @@ -161,14 +161,14 @@ impl PeerDb { } pub fn remove(&mut self, agent_id: &str) -> bool { - self.store.peers.remove(agent_id).is_some() + self.store.agents.remove(agent_id).is_some() } - pub fn find_by_capability(&self, cap: &str) -> Vec<&PeerRecord> { + pub fn find_by_capability(&self, cap: &str) -> Vec<&AgentRecord> { let is_prefix = cap.ends_with(':'); let mut matches: Vec<_> = self .store - .peers + .agents .values() .filter(|p| { p.capabilities @@ -182,7 +182,7 @@ impl PeerDb { pub fn tofu_verify(&mut self, agent_id: &str, public_key: &str) -> bool { let now = now_ms(); - if let Some(existing) = self.store.peers.get_mut(agent_id) { + if let Some(existing) = self.store.agents.get_mut(agent_id) { if existing.public_key.is_empty() { existing.public_key = public_key.to_string(); existing.tofu_cached_at = Some(now); @@ -205,10 +205,10 @@ impl PeerDb { existing.last_seen = now; return true; } - // New peer — cache key - self.store.peers.insert( + // New agent — cache key + self.store.agents.insert( agent_id.to_string(), - PeerRecord { + AgentRecord { agent_id: agent_id.to_string(), public_key: public_key.to_string(), alias: String::new(), @@ -235,7 +235,7 @@ mod tests { #[test] fn test_open_empty() { let tmp = TempDir::new().unwrap(); - let db = PeerDb::open(tmp.path()); + let db = AgentDb::open(tmp.path()); assert_eq!(db.size(), 0); assert!(db.list().is_empty()); } @@ -243,18 +243,18 @@ mod tests { #[test] fn test_upsert_and_get() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "pubkey1", Some("Alice"), None, None, None, None); assert_eq!(db.size(), 1); - let peer = db.get("aw:sha256:aaa").unwrap(); - assert_eq!(peer.alias, "Alice"); - assert_eq!(peer.public_key, "pubkey1"); + let record = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(record.alias, "Alice"); + assert_eq!(record.public_key, "pubkey1"); } #[test] fn test_upsert_updates_existing() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "pk1", Some("Alice"), None, None, None, None); let eps = vec![Endpoint { transport: "tcp".into(), @@ -264,16 +264,16 @@ mod tests { ttl: 3600, }]; db.upsert("aw:sha256:aaa", "", Some("Alice Updated"), Some(eps), None, None, None); - let peer = db.get("aw:sha256:aaa").unwrap(); - assert_eq!(peer.public_key, "pk1"); // not overwritten with empty - assert_eq!(peer.alias, "Alice Updated"); - assert_eq!(peer.endpoints.len(), 1); + let record = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(record.public_key, "pk1"); // not overwritten with empty + assert_eq!(record.alias, "Alice Updated"); + assert_eq!(record.endpoints.len(), 1); } #[test] fn test_remove() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "pk1", None, None, None, None, None); assert!(db.remove("aw:sha256:aaa")); assert_eq!(db.size(), 0); @@ -284,20 +284,20 @@ mod tests { fn test_persist_and_reload() { let tmp = TempDir::new().unwrap(); { - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "pk1", Some("Alice"), None, None, None, None); db.flush(); } - let db = PeerDb::open(tmp.path()); + let db = AgentDb::open(tmp.path()); assert_eq!(db.size(), 1); - let peer = db.get("aw:sha256:aaa").unwrap(); - assert_eq!(peer.alias, "Alice"); + let record = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(record.alias, "Alice"); } #[test] fn test_find_by_capability_exact() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "pk1", None, None, Some(vec!["world:arena".into()]), None, None); db.upsert("aw:sha256:bbb", "pk2", None, None, Some(vec!["world:lobby".into()]), None, None); let found = db.find_by_capability("world:arena"); @@ -308,7 +308,7 @@ mod tests { #[test] fn test_find_by_capability_prefix() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "pk1", None, None, Some(vec!["world:arena".into()]), None, None); db.upsert("aw:sha256:bbb", "pk2", None, None, Some(vec!["world:lobby".into()]), None, None); db.upsert("aw:sha256:ccc", "pk3", None, None, Some(vec!["agent".into()]), None, None); @@ -319,7 +319,7 @@ mod tests { #[test] fn test_list_sorted_by_last_seen() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:old", "pk1", None, None, None, None, Some(1000)); db.upsert("aw:sha256:new", "pk2", None, None, None, None, Some(9000)); db.upsert("aw:sha256:mid", "pk3", None, None, None, None, Some(5000)); @@ -330,19 +330,19 @@ mod tests { } #[test] - fn test_tofu_new_peer() { + fn test_tofu_new_agent() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); - let peer = db.get("aw:sha256:aaa").unwrap(); - assert_eq!(peer.public_key, "pk1"); - assert!(peer.tofu_cached_at.is_some()); + let record = db.get("aw:sha256:aaa").unwrap(); + assert_eq!(record.public_key, "pk1"); + assert!(record.tofu_cached_at.is_some()); } #[test] fn test_tofu_same_key_passes() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); } @@ -350,7 +350,7 @@ mod tests { #[test] fn test_tofu_different_key_fails() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); assert!(!db.tofu_verify("aw:sha256:aaa", "pk2")); } @@ -358,7 +358,7 @@ mod tests { #[test] fn test_tofu_empty_key_accepts() { let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "", None, None, None, None, None); assert!(db.tofu_verify("aw:sha256:aaa", "pk1")); assert_eq!(db.get("aw:sha256:aaa").unwrap().public_key, "pk1"); @@ -367,8 +367,8 @@ mod tests { #[test] fn test_corrupt_file_loads_empty() { let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("peers.json"), "invalid json").unwrap(); - let db = PeerDb::open(tmp.path()); + fs::write(tmp.path().join("agents.json"), "invalid json").unwrap(); + let db = AgentDb::open(tmp.path()); assert_eq!(db.size(), 0); } @@ -376,10 +376,10 @@ mod tests { fn test_ts_format_compatibility() { // Verify JSON field names match TS camelCase convention let tmp = TempDir::new().unwrap(); - let mut db = PeerDb::open(tmp.path()); + let mut db = AgentDb::open(tmp.path()); db.upsert("aw:sha256:aaa", "pk1", Some("Test"), None, None, None, None); db.flush(); - let raw = fs::read_to_string(tmp.path().join("peers.json")).unwrap(); + let raw = fs::read_to_string(tmp.path().join("agents.json")).unwrap(); assert!(raw.contains("agentId"), "must use camelCase agentId"); assert!(raw.contains("publicKey"), "must use camelCase publicKey"); assert!(raw.contains("firstSeen"), "must use camelCase firstSeen"); diff --git a/packages/awn-cli/src/daemon.rs b/packages/awn-cli/src/daemon.rs index b52087b..279bc62 100644 --- a/packages/awn-cli/src/daemon.rs +++ b/packages/awn-cli/src/daemon.rs @@ -10,17 +10,17 @@ use tokio::sync::oneshot; use crate::crypto; use crate::identity::{self, Identity}; -use crate::peer_db::{Endpoint, PeerDb, PeerRecord}; +use crate::agent_db::{Endpoint, AgentDb, AgentRecord}; const DEFAULT_IPC_PORT: u16 = 8199; #[derive(Clone)] pub struct DaemonState { pub identity: Identity, - pub peer_db: Arc>, + pub agent_db: Arc>, pub data_dir: PathBuf, pub gateway_url: String, - pub peer_port: u16, + pub listen_port: u16, } #[derive(Serialize, Deserialize)] @@ -28,15 +28,15 @@ pub struct StatusResponse { pub agent_id: String, pub pub_b64: String, pub version: String, - pub peer_port: u16, + pub listen_port: u16, pub gateway_url: String, - pub known_peers: usize, + pub known_agents: usize, pub data_dir: String, } #[derive(Serialize, Deserialize)] -pub struct PeersResponse { - pub peers: Vec, +pub struct AgentsResponse { + pub agents: Vec, } #[derive(Serialize, Deserialize)] @@ -58,7 +58,7 @@ pub struct WorldSummary { } #[derive(Deserialize)] -pub struct PeersQuery { +pub struct AgentsQuery { pub capability: Option, } @@ -88,24 +88,24 @@ impl DaemonHandle { pub async fn start_daemon( data_dir: PathBuf, gateway_url: String, - peer_port: u16, + listen_port: u16, ipc_port: u16, ) -> Result { let identity = identity::load_or_create_identity(&data_dir, "identity") .map_err(|e| DaemonError::Identity(e.to_string()))?; - let peer_db = PeerDb::open(&data_dir); + let agent_db = AgentDb::open(&data_dir); let state = DaemonState { identity, - peer_db: Arc::new(Mutex::new(peer_db)), + agent_db: Arc::new(Mutex::new(agent_db)), data_dir, gateway_url, - peer_port, + listen_port, }; let app = Router::new() .route("/ipc/status", get(handle_status)) - .route("/ipc/peers", get(handle_peers)) + .route("/ipc/agents", get(handle_agents)) .route("/ipc/worlds", get(handle_worlds)) .route("/ipc/ping", get(handle_ping)) .with_state(state); @@ -134,29 +134,29 @@ pub async fn start_daemon( } async fn handle_status(State(state): State) -> Json { - let peer_count = state.peer_db.lock().unwrap().size(); + let agent_count = state.agent_db.lock().unwrap().size(); Json(StatusResponse { agent_id: state.identity.agent_id.clone(), pub_b64: state.identity.pub_b64.clone(), version: env!("CARGO_PKG_VERSION").to_string(), - peer_port: state.peer_port, + listen_port: state.listen_port, gateway_url: state.gateway_url.clone(), - known_peers: peer_count, + known_agents: agent_count, data_dir: state.data_dir.to_string_lossy().to_string(), }) } -async fn handle_peers( +async fn handle_agents( State(state): State, - axum::extract::Query(query): axum::extract::Query, -) -> Json { - let db = state.peer_db.lock().unwrap(); - let peers = if let Some(cap) = &query.capability { + axum::extract::Query(query): axum::extract::Query, +) -> Json { + let db = state.agent_db.lock().unwrap(); + let agents = if let Some(cap) = &query.capability { db.find_by_capability(cap).into_iter().cloned().collect() } else { db.list().into_iter().cloned().collect() }; - Json(PeersResponse { peers }) + Json(AgentsResponse { agents }) } async fn handle_worlds(State(state): State) -> Json { @@ -191,7 +191,7 @@ async fn handle_worlds(State(state): State) -> Json // Merge with local cache { - let db = state.peer_db.lock().unwrap(); + let db = state.agent_db.lock().unwrap(); let local_worlds = db.find_by_capability("world:"); for lw in local_worlds { if !worlds.iter().any(|w| w.agent_id == lw.agent_id) { @@ -301,13 +301,13 @@ mod tests { let resp: StatusResponse = reqwest::get(&url).await.unwrap().json().await.unwrap(); assert!(resp.agent_id.starts_with("aw:sha256:")); assert_eq!(resp.version, env!("CARGO_PKG_VERSION")); - assert_eq!(resp.peer_port, 8099); + assert_eq!(resp.listen_port, 8099); handle.shutdown(); } #[tokio::test] - async fn test_daemon_peers_empty() { + async fn test_daemon_agents_empty() { let tmp = TempDir::new().unwrap(); let handle = start_daemon( tmp.path().to_path_buf(), @@ -318,9 +318,9 @@ mod tests { .await .unwrap(); - let url = format!("http://{}/ipc/peers", handle.addr); - let resp: PeersResponse = reqwest::get(&url).await.unwrap().json().await.unwrap(); - assert!(resp.peers.is_empty()); + let url = format!("http://{}/ipc/agents", handle.addr); + let resp: AgentsResponse = reqwest::get(&url).await.unwrap().json().await.unwrap(); + assert!(resp.agents.is_empty()); handle.shutdown(); } diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index 1863aa2..e325194 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -1,7 +1,7 @@ mod crypto; mod daemon; mod identity; -mod peer_db; +mod agent_db; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -30,8 +30,8 @@ enum Commands { }, /// Show this agent's identity, transport, and status Status, - /// List known peers - Peers { + /// List known agents + Agents { /// Filter by capability prefix (e.g. "world:") #[arg(long)] capability: Option, @@ -44,13 +44,13 @@ enum Commands { enum DaemonAction { /// Start the AWN daemon Start { - /// Data directory for identity and peer DB + /// Data directory for identity and agent DB #[arg(long)] data_dir: Option, /// Gateway URL #[arg(long)] gateway_url: Option, - /// Peer server port + /// Listen port for the agent server #[arg(long, default_value_t = 8099)] port: u16, }, @@ -124,12 +124,12 @@ async fn main() { println!("{}", serde_json::to_string(&status).unwrap()); } else { println!("=== AWN Status ==="); - println!("Agent ID: {}", status.agent_id); - println!("Version: v{}", status.version); - println!("Peer port: {}", status.peer_port); - println!("Gateway: {}", status.gateway_url); - println!("Known peers: {}", status.known_peers); - println!("Data dir: {}", status.data_dir); + println!("Agent ID: {}", status.agent_id); + println!("Version: v{}", status.version); + println!("Listen port: {}", status.listen_port); + println!("Gateway: {}", status.gateway_url); + println!("Known agents: {}", status.known_agents); + println!("Data dir: {}", status.data_dir); } } } @@ -143,34 +143,34 @@ async fn main() { } } } - Commands::Peers { ref capability } => { + Commands::Agents { ref capability } => { let ipc = resolve_ipc_port(&cli); - let mut url = format!("http://127.0.0.1:{ipc}/ipc/peers"); + let mut url = format!("http://127.0.0.1:{ipc}/ipc/agents"); if let Some(cap) = capability { url = format!("{url}?capability={}", urlencoding(cap)); } match reqwest::get(&url).await { Ok(resp) => { - if let Ok(data) = resp.json::().await { + if let Ok(data) = resp.json::().await { if cli.json { println!("{}", serde_json::to_string(&data).unwrap()); - } else if data.peers.is_empty() { - println!("No peers found."); + } else if data.agents.is_empty() { + println!("No agents found."); } else { - println!("=== Known Peers ({}) ===", data.peers.len()); - for p in &data.peers { - let alias = if p.alias.is_empty() { + println!("=== Known Agents ({}) ===", data.agents.len()); + for a in &data.agents { + let alias = if a.alias.is_empty() { String::new() } else { - format!(" — {}", p.alias) + format!(" — {}", a.alias) }; - let caps = if p.capabilities.is_empty() { + let caps = if a.capabilities.is_empty() { String::new() } else { - format!(" [{}]", p.capabilities.join(", ")) + format!(" [{}]", a.capabilities.join(", ")) }; - let ago = (now_ms().saturating_sub(p.last_seen)) / 1000; - println!(" {}{}{} — {}s ago", p.agent_id, alias, caps, ago); + let ago = (now_ms().saturating_sub(a.last_seen)) / 1000; + println!(" {}{}{} — {}s ago", a.agent_id, alias, caps, ago); } } }